diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 0000000000..0c4b142e9a --- /dev/null +++ b/.github/dco.yml @@ -0,0 +1,2 @@ +require: + members: false diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000000..809199dc68 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,82 @@ +name: Cyrus IMAP CI + +on: + workflow_dispatch: + push: + branches: + - master + - main + - 'cyrus-imapd-*' + pull_request: + branches: + - master + - main + - 'cyrus-imapd-*' +jobs: + build: + runs-on: ubuntu-latest + container: + image: cyrusimapdocker/cyrus-buster:latest + options: --sysctl net.ipv6.conf.all.disable_ipv6=0 --init + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: install missing or frequently-updated deps + shell: bash + run: | + cpanm IO::File::fcntl + cpanm Digest::CRC + cpanm XML::Simple # XXX could be grabbed from apt by docker image + cpanm Mail::IMAPTalk # in image, but fetch latest! + - name: setup git safe directory + shell: bash + run: git config --global --add safe.directory /__w/cyrus-imapd/cyrus-imapd + - name: fetch upstream release tags + if: ${{ github.repository != 'cyrusimap/cyrus-imapd' }} + shell: bash + run: | + git remote add upstream https://github.com/cyrusimap/cyrus-imapd.git + # n.b. --no-tags does not mean "no tags", it means "no automatic tag + # following". we're explicitly fetching the tags we want, we do not + # need every other tag that's reachable from them + git fetch --no-tags upstream 'refs/tags/cyrus-imapd-*:refs/tags/cyrus-imapd-*' + - name: configure and build + shell: bash + run: | + echo "building cyrus version" $(./tools/git-version.sh) + ./tools/build-with-cyruslibs.sh + - name: report version information + shell: bash + run: | + echo "debian" $(cat /etc/debian_version) + echo "Mail::IMAPTalk" $(cpanm --info Mail::IMAPTalk) + /usr/cyrus/libexec/master -V + /usr/cyrus/sbin/cyr_buildinfo + - name: update jmap test suite + working-directory: /srv/JMAP-TestSuite.git + shell: bash + run: | + git fetch + git checkout origin/master + git clean -f -x -d + cpanm --installdeps . + - name: set up cassandane + working-directory: cassandane + shell: bash + run: | + cp -af cassandane.ini.dockertests cassandane.ini + chown cyrus:mail cassandane.ini + make -j8 + - name: run cassandane quietly + id: cass1 + continue-on-error: true + working-directory: cassandane + run: setpriv --reuid=cyrus --regid=mail --clear-groups --inh-caps='-chown,-dac_override,-dac_read_search,-fowner,-fsetid,-kill,-setgid,-setuid,-setpcap,-linux_immutable,-net_bind_service,-net_broadcast,-net_admin,-net_raw,-ipc_lock,-ipc_owner,-sys_module,-sys_rawio,-sys_chroot,-sys_ptrace,-sys_pacct,-sys_admin,-sys_boot,-sys_nice,-sys_resource,-sys_time,-sys_tty_config,-mknod,-lease,-audit_write,-audit_control,-setfcap,-mac_override,-mac_admin,-syslog,-wake_alarm,-block_suspend,-audit_read,-cap_38,-cap_39,-cap_40' ./testrunner.pl --slow -f prettier -j 8 !Test::Core + - name: rerun cassandane failures noisily + if: ${{ steps.cass1.outcome == 'failure' }} + working-directory: cassandane + run: setpriv --reuid=cyrus --regid=mail --clear-groups --inh-caps='-chown,-dac_override,-dac_read_search,-fowner,-fsetid,-kill,-setgid,-setuid,-setpcap,-linux_immutable,-net_bind_service,-net_broadcast,-net_admin,-net_raw,-ipc_lock,-ipc_owner,-sys_module,-sys_rawio,-sys_chroot,-sys_ptrace,-sys_pacct,-sys_admin,-sys_boot,-sys_nice,-sys_resource,-sys_time,-sys_tty_config,-mknod,-lease,-audit_write,-audit_control,-setfcap,-mac_override,-mac_admin,-syslog,-wake_alarm,-block_suspend,-audit_read,-cap_38,-cap_39,-cap_40' ./testrunner.pl -f pretty -j 8 --rerun + - name: collect logs + if: always() + run: cat /tmp/cass/*/conf/log/syslog diff --git a/.gitignore b/.gitignore index f5cae8ee8c..b3d7b458cb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.gcda *.la *.lo +*.loT *.o *.orig *.patch @@ -14,6 +15,7 @@ *.tar.bz2 *.*-cunit.c *~ +.clang-format .cunit-*.c .deps .dirstamp @@ -32,8 +34,13 @@ backup/ctl_backups backup/cyr_backup backup/restore bench/cyrdbbench -cmulocal/ +cmulocal/libtool.m4 +cmulocal/ltoptions.m4 +cmulocal/ltsugar.m4 +cmulocal/ltversion.m4 +cmulocal/lt~obsolete.m4 compile +confdefs.h config.guess config.h config.h.in @@ -42,6 +49,7 @@ config.status config.status.lineno config.sub configure +conftest* com_err/et/compile_et coverage.xml cscope.out @@ -79,11 +87,14 @@ imap/cyr_deny imap/cyr_df imap/cyr_expire imap/cyr_info +imap/cyr_ls +imap/cyr_pwd imap/cyr_sequence imap/cyr_sphinxmgr imap/cyr_synclog imap/cyr_userseen imap/cyr_virusscan +imap/cyr_withlock_run imap/cyrdump imap/dav_reconstruct imap/deliver @@ -94,6 +105,7 @@ imap/http_carddav_js.h imap/http_err.c imap/http_err.h imap/httpd +imap/ical_apply_patch imap/idled imap/imap_err.c imap/imap_err.h @@ -104,8 +116,6 @@ imap/jmap_err.h imap/lmtp_err.c imap/lmtp_err.h imap/lmtpd -imap/lmtpstats.c -imap/lmtpstats.h imap/mbexamine imap/mbpath imap/mbtool @@ -120,10 +130,9 @@ imap/pop3d imap/promdata.c imap/promdata.h imap/promstatsd -imap/pushstats.c -imap/pushstats.h imap/quota imap/reconstruct +imap/relocate_by_id imap/search_test imap/smmapd imap/squatter @@ -199,10 +208,8 @@ sieve/sieve_err.h sieve/sievec sieve/sieved sieve/test +sieve/test_mailbox sieve/tests/ stamp-h1 timsieved/timsieved -vzic/test-output/ -vzic/vzic_test -vzic/cyr_vzic ylwrap diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000000..e13078b301 --- /dev/null +++ b/.mailmap @@ -0,0 +1,57 @@ +# See https://git-scm.com/docs/gitmailmap, but this format is one of: +# Proper Name +# Proper Name Commit Name + +# People, alphabetical by last name. Most recent commit used where ambiguous. +Greg Banks +Greg Banks +Robert Bricheno +cketti +Julien Coloos +Chris Davies +Bron Gondwana brong +Bron Gondwana +Bron Gondwana +Bron Gondwana +Bron Gondwana +Bron Gondwana +Bron Gondwana +Quanah Gibson-Mount +Matthew Horsfall +Matthew Horsfall alh +Anthony Howe +Neil Jenkins +Dave McMurtrie +Rob Mueller +Sven Mueller +Ken Murchison +Ken Murchison Kenneth S Murchison +David Murray CambridgeDave +John Gardiner Myers John Gardiner Meyers +Chris Newman +Chris Newman +Rob N ★ Robert Norris +Rob N ★ +Rob N ★ +Nicola Nye Nicola N +Дилян Палаузов +Olivier ROLAND +Ricardo Signes +Ricardo Signes +Ricardo Signes +Jean-Francois Smigielski Jean-Francois SMIGIELSKI +Robert Stepanek rsto@fastmailteam.com +Robert Stepanek +Robert Stepanek Robert +Robert Stepanek +Robert Stepanek +Robert Stepanek +Ondřej Surý +Partha Susarla +Partha Susarla +ellie timoney +ellie timoney Ellie Timoney +ellie timoney elliefm + +# Mystery committers +Mystery Person root diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index cd43d5b7db..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ -language: c -sudo: required -dist: trusty -group: edge -services: -- docker -before_install: -- echo '{"ipv6":true,"fixed-cidr-v6":"2001:db8:1::/64"}' | sudo tee /etc/docker/daemon.json -- echo 'core' | sudo tee /proc/sys/kernel/core_pattern -- sudo service docker restart -- docker pull cyrusimapdocker/cyrus-jessie -script: -- docker run --ulimit core=-1 -it cyrusimapdocker/cyrus-jessie /bin/sh -c "cd / && - ./entrypoint.sh" -branches: - only: - - master - - cyrus-imapd-3.0 -notifications: - email: - recipients: - - jenkins@cyrus.works - on_pull_requests: false - on_success: change - on_failure: always - slack: - on_pull_requests: false - on_success: change - on_failure: always - rooms: - secure: KkxhNDQv+mKP4Pot8bEgzwXyP+wlxuy2CGRTlDAknhlzRZZMJKM2qV5pHm10X/JmUpgwVapqthDvejzgwC574g6Z1MRZ3DWtTfZtkcjEq+dDjnoJYJ7sfWI2GVVmU4OiJfTlvafawbLIpnUh791t4psRwPCyQd6B1/kvu3R9ITrq826wsJ5K8OnSRGSUmRl+C0UOUGYh9p1AVUP3rxtCcR2hetIsC/RG4uSHzt/wQNAQ/EkvZzga0kLXJoaolWavcdymubKJMnUenWLCkXUxQlTY3IKjD5itaXMYt4/aX6UhYS+77Sd/8UpUvx8uZXEu55si5P9tJIIsug1QqlooH/cz0ZQzFWH4D4esqqXuvNAiyBmlZ9LDzM4dtIzacuMKP5oAuwUhHpF0CSvvfE/KaQ446j1jX/CEVs6JgtTiijunmkGu86ylkWpboMHqal4BGKw15L0rkRw6VyLGY9S7GD6rQ3ojFGv9CjMQN960IBUgIDQmyJmu+6UihuVCvwZB5cPtGLyLh8sW/kKbUu6b4ElgszDI2DZutJeRPhD2YDmfNTBO/EYz/h/KWNgZNJzXZ5wSpaz7LMSfBUyRABdPEvddzctt3/k0c48J+sEY8NYqVTg8njh6Nz3aAWKp8zejQFfpEXyGa2JSMz4f0LR4dwNN1pTDVkeMJl5FiGvn8s4= diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..b4782650c2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to Cyrus + +So, you want to contribute to Cyrus? Great! + +You'll probably want to join the [cyrus-devel mailing +list](https://www.cyrusimap.org/imap/support/feedback-mailing-lists.html#feedback-mailing-lists) +where development issues get discussed. You don't need to, but if you're +considering doing a substantial amount of work, it's a good idea to ask about +it first. + +This document is meant to be a quick overview of the most important things you +need to know to get your work reviewed, approved, and into Cyrus. + +## Your Code + +Cyrus doesn't have a hard and fast style guide, *but it will*. For now, +consult the [Cyrus hacking +docs](https://www.cyrusimap.org/imap/developer/guidance/hacking.html), which +spell out some of the standards of formatting and construction. This document +is, at present, quite out of date. You are probably best served by just +copying the style of the surrounding code. + + +## The Tests + +You should run the tests. Submitting a change that breaks existing tests isn't +good for anybody! If your pull request changes the code but doesn't add a +test, you should explain why. "Code changes add tests" is the default +assumption. + +There are two kinds of tests: + +* The [Cassandane test + suite](https://www.cyrusimap.org/imap/developer/developer-testing.html) is an + integration test suite. It can and should be run against your build of + Cyrus, and it's right there in the repo under `./cassandane`. +* The [cunit tests](https://www.cyrusimap.org/imap/developer/unit-tests.html) + are located in the Cyrus IMAP repository, in `./cunit` and run by `make + check`. You should run these, too. + +## Submitting Your Work + +We use GitHub, including pull requests. Submit a pull request. One of the +committers should review it soon. If they don't, the best place to ask for +a review is the cyrus-devel mailing list, mentioned above. + +Remember to sign your commits. This just means that they should be made with +`git commit --signoff`. More importantly, it is how you certify the [Developer +Certificate of Origin](https://developercertificate.org/), which states your +assertion that you have the legal right to submit your code to Cyrus for +redistribution as part of Cyrus. + +**All code is reviewed before merge.** This includes code submitted by +committers. This means that if you want to know what awaits you in code +review, you can look at some recently merged or closed pull requests. + +## Cyrus Versioning and Bugfix Policy + +Cyrus is free software that comes with no guarantees, but we try to fix bugs +when they're found. The policy on that is something like this: + +* We release a new **development snapshot** of Cyrus about once a month. While + we won't make a release that doesn't *compile*, all other bets are off. If + we discover a critical security problem in a development snapshot, we'll just + merge the fix when it's ready. Running these in production is *your* + liability to worry about. These versions are numbered vX.Y.Z, where Y is + odd. +* We release a **new major version** about once a year. We release these when + we believe that all the new features work correctly and there are no known + regressions, other than those we've documented as intentional. These + versions are numbered vX.Y.0, where Y is even. +* We release **new minor version** for major releases once in a while, when + we've built up enough backported bugfixes, or when we've been waiting long + enough to ship the ones we've already applied. There are numbered vX.Y.Z, + where Y is even and Z is nonzero. + +We stop releasing minor releases for major releases after two years. While we +might push bugfixes for significant problems to the git branch for an old major +release, we won't undertake a new release. If you're running an old version of +Cyrus, it's up to you (or your package manager) to track and package new +patches. + +If we discover a security vulnerability in a non-development-snapshot version +of Cyrus, we practice responsible disclosure. We produce a fix, then inform +downstream package mangers of that fix, with an embargo date so that the fix +can be released publicly at the same time that updated packages become +available. In general, we do not pursue security fixes for major versions of +Cyrus over three years old. There may be exceptions to this, but generally you +should try to run a recent stable release. diff --git a/COPYING b/COPYING index 44859eecd0..28234a75de 100644 --- a/COPYING +++ b/COPYING @@ -42,4 +42,5 @@ ALL versions of the Cyrus IMAP server are covered by the following copyright: If you find this software useful and valuable in your work, we would welcome any support you can offer toward continuing this work. -http://cyrusimap.org/ for information on how to contact us and contributing. +http://www.cyrusimap.org/ for information on how to contact us and +contributing. diff --git a/Makefile.am b/Makefile.am index 418e5961e4..03b7d58ae9 100644 --- a/Makefile.am +++ b/Makefile.am @@ -66,8 +66,24 @@ endif # HAVE_LDAP AM_LDFLAGS = $(COV_LDFLAGS) $(ICU_LIBS) +# have distcheck try to build with all optional components enabled, to aid +# detection of missing files for these components +AM_DISTCHECK_CONFIGURE_FLAGS = \ + --enable-http \ + --enable-calalarmd \ + --enable-replication \ + --with-openssl=yes \ + --enable-nntp \ + --enable-murder \ + --enable-idled \ + --enable-sieve \ + --enable-autocreate \ + --enable-backup \ + --enable-xapian \ + --enable-jmap \ + --with-ldap + BUILT_SOURCES = \ - imap/imap_err.c \ lib/imapopts.c \ lib/imapopts.h @@ -78,8 +94,6 @@ CLEANFILES = \ DISTCLEANFILES = \ com_err/et/compile_et \ - imap/http_caldav_js.h \ - imap/http_carddav_js.h \ imap/http_err.c \ imap/http_err.h \ imap/imap_err.c \ @@ -104,26 +118,24 @@ DISTCLEANFILES = \ MAINTAINERCLEANFILES = \ doc/legacy/murder.png \ doc/legacy/netnews.png \ + imap/http_caldav_js.h \ + imap/http_carddav_js.h \ man/imapd.conf.5 \ man/sieveshell.1 \ - imap/lmtpstats.h \ - imap/pushstats.h \ sieve/addr.h \ sieve/sieve.h SUBDIRS = . DIST_SUBDIRS = . perl/annotator perl/imap perl/sieve/managesieve +dist_sbin_SCRIPTS = dist_sysconf_DATA = lib_LTLIBRARIES = lib/libcyrus_min.la lib/libcyrus.la -EXTRA_PROGRAMS = check_PROGRAMS = libexec_PROGRAMS = sbin_PROGRAMS = noinst_HEADERS = noinst_LTLIBRARIES = noinst_PROGRAMS = -EXTRA_DIST = \ - VERSION if COM_ERR COMPILE_ET_DEP = com_err/et/compile_et @@ -135,11 +147,14 @@ bin_PROGRAMS = imtest/imtest if SERVER BUILT_SOURCES += \ - imap/mupdate_err.c \ - imap/nntp_err.c \ + imap/http_err.c \ + imap/http_err.h \ + imap/imap_err.c \ + imap/imap_err.h \ imap/lmtp_err.c \ - imap/lmtpstats.c \ - imap/pushstats.c \ + imap/lmtp_err.h \ + imap/mupdate_err.c \ + imap/mupdate_err.h \ imap/promdata.c \ imap/promdata.h \ lib/htmlchar.c \ @@ -156,7 +171,8 @@ libexec_PROGRAMS += \ imap/lmtpd \ imap/pop3d \ imap/promstatsd \ - imap/smmapd + imap/smmapd \ + ptclient/ptloader sbin_PROGRAMS += \ imap/arbitron \ @@ -167,13 +183,15 @@ sbin_PROGRAMS += \ imap/ctl_mboxlist \ imap/cvt_cyrusdb \ imap/cyr_df \ + imap/cyr_ls \ + imap/cyr_pwd \ + imap/cyr_withlock_run \ imap/cyrdump \ imap/cyr_dbtool \ imap/cyr_deny \ imap/cyr_expire \ imap/cyr_info \ imap/cyr_buildinfo \ - imap/cyr_sequence \ imap/cyr_synclog \ imap/cyr_userseen \ imap/cyr_virusscan \ @@ -184,7 +202,13 @@ sbin_PROGRAMS += \ imap/mbtool \ imap/quota \ imap/reconstruct \ - imap/cvt_xlist_specialuse + imap/relocate_by_id \ + imap/cvt_xlist_specialuse \ + ptclient/ptdump \ + ptclient/ptexpire + +dist_sbin_SCRIPTS += \ + imap/cyr_cd.sh noinst_PROGRAMS += \ imap/message_test \ @@ -201,6 +225,7 @@ endif if NNTPD libexec_PROGRAMS += imap/nntpd sbin_PROGRAMS += imap/fetchnews +BUILT_SOURCES += imap/nntp_err.c endif # NNTPD libexec_PROGRAMS += imap/fud @@ -226,14 +251,11 @@ AM_LDFLAGS += $(HTTP_LIBS) BUILT_SOURCES += \ imap/http_caldav_js.h \ imap/http_carddav_js.h \ - imap/http_err.c \ - imap/http_err.h \ - imap/jmap_err.c \ - imap/jmap_err.h \ imap/tz_err.c \ imap/tz_err.h libexec_PROGRAMS += imap/httpd +check_PROGRAMS += imap/ical_apply_patch sbin_PROGRAMS += \ imap/ctl_zoneinfo \ imap/dav_reconstruct @@ -262,30 +284,6 @@ if SIEVE check_PROGRAMS += notifyd/notifytest libexec_PROGRAMS += notifyd/notifyd endif # SIEVE - -else # SERVER -EXTRA_DIST += \ - imap/mupdate_err.c \ - imap/nntp_err.c \ - imap/lmtp_err.c \ - imap/lmtpstats.c \ - imap/pushstats.c \ - imap/promdata.c \ - imap/promdata.h \ - imap/rfc822_header.c \ - imap/rfc822_header.h \ - imap/mailbox_header_cache.h - -if !HTTPD -EXTRA_DIST += \ - imap/http_err.c \ - imap/http_err.h \ - imap/jmap_err.c \ - imap/jmap_err.h \ - imap/tz_err.c \ - imap/tz_err.h -endif # HTTPD - endif # SERVER if CMULOCAL @@ -293,12 +291,6 @@ dist_sysconf_DATA += depot/rc.local.imap depot/rc.local.ptclient sbin_PROGRAMS += netnews/remotepurge endif # CMULOCAL -if PTCLIENT -sbin_PROGRAMS += ptclient/ptdump ptclient/ptexpire -libexec_PROGRAMS += \ - ptclient/ptloader -endif # PTCLIENT - if PERL SUBDIRS += perl/annotator perl/imap noinst_LTLIBRARIES += perl/libcyrus.la perl/libcyrus_min.la @@ -314,7 +306,7 @@ endif # PERL BUILT_SOURCES += sieve/addr.c sieve/sieve.c sieve/sieve_err.c noinst_LTLIBRARIES += sieve/libcyrus_sieve_lex.la lib_LTLIBRARIES += sieve/libcyrus_sieve.la -check_PROGRAMS += sieve/test +check_PROGRAMS += sieve/test sieve/test_mailbox sbin_PROGRAMS += sieve/sievec sieve/sieved if SERVER @@ -323,9 +315,11 @@ endif # SERVER endif # SIEVE -EXTRA_DIST += \ +EXTRA_DIST = \ COPYING \ README.md \ + VERSION \ + cassandane \ com_err/et/et_c.awk \ com_err/et/et_h.awk \ com_err/et/test1.et \ @@ -336,17 +330,6 @@ EXTRA_DIST += \ contrib/deliver-notify-zephyr.patch \ contrib/add-cyrus-user \ contrib/README \ - contrib/cyrus-graphtools.1.0 \ - contrib/cyrus-graphtools.1.0/cgi-bin/cyrus_master.pl \ - contrib/cyrus-graphtools.1.0/cgi-bin/graph_cyrus_db.pl \ - contrib/cyrus-graphtools.1.0/cgi-bin/graph_cyrus_db-sum.pl \ - contrib/cyrus-graphtools.1.0/html \ - contrib/cyrus-graphtools.1.0/html/index.html \ - contrib/cyrus-graphtools.1.0/README \ - contrib/cyrus-graphtools.1.0/script \ - contrib/cyrus-graphtools.1.0/script/cyrus.pl \ - contrib/cyrus-graphtools.1.0/script/run \ - contrib/cyrus-graphtools.1.0/script/cyrusrc \ contrib/cyrusv2.mc \ contrib/dkim_canon_ischedule.patch \ contrib/notify_unix/notify \ @@ -367,18 +350,17 @@ EXTRA_DIST += \ doc/legacy/netnews.png \ doc \ docsrc \ - imap/http_caldav_js.h \ imap/http_caldav.js \ - imap/http_carddav_js.h \ imap/http_carddav.js \ imap/http_err.et \ imap/imap_err.et \ imap/jmap_err.et \ imap/lmtp_err.et \ + imap/mailbox_header_cache.gperf \ imap/mupdate_err.et \ imap/nntp_err.et \ + imap/promdata.p \ imap/rfc822_header.st \ - imap/mailbox_header_cache.gperf \ imap/tz_err.et \ lib/charset/aliases.txt \ lib/charset/UnicodeData.txt \ @@ -386,16 +368,12 @@ EXTRA_DIST += \ lib/charset/us-ascii.t \ lib/htmlchar.st \ lib/imapoptions \ - lib/test/cyrusdb.c \ - lib/test/cyrusdb.INPUT \ - lib/test/cyrusdblong.INPUT \ - lib/test/cyrusdblong.OUTPUT \ - lib/test/cyrusdb.OUTPUT \ - lib/test/cyrusdbtxn.INPUT \ - lib/test/cyrusdbtxn.OUTPUT \ - lib/test/pool.c \ - lib/test/rnddb.c \ - master/CYRUS-MASTER.mib \ + man/arbitronsort.pl.1 \ + man/masssievec.8 \ + man/mkimap.8 \ + man/mknewsgroups.8 \ + man/rehash.8 \ + man/translatesieve.8 \ master/README \ netnews/inn.diffs \ perl/annotator/Daemon.pm \ @@ -411,9 +389,7 @@ EXTRA_DIST += \ perl/imap/examples/auditmbox.pl \ perl/imap/examples/imapcollate.pl \ perl/imap/examples/imapdu.pl \ - perl/imap/examples/test-imsp.pl \ perl/imap/IMAP/Admin.pm \ - perl/imap/IMAP/IMSP.pm \ perl/imap/IMAP/Shell.pm \ perl/imap/IMAP.pm \ perl/imap/IMAP.xs \ @@ -523,35 +499,30 @@ TEXINFO_TEX = com_err/et/texinfo.tex dist_noinst_SCRIPTS = \ com_err/et/compile_et.sh \ com_err/et/config_script \ - imap/lmtpstats.snmp \ - imap/pushstats.snmp \ imap/promdatagen \ lib/mkchartable.pl \ - lib/test/run \ perl/sieve/scripts/installsieve.pl \ perl/sieve/scripts/sieveshell.pl \ tools/arbitronsort.pl \ tools/compile_st.pl \ tools/config2header \ - tools/config2man \ tools/config2rst \ tools/config2sample \ tools/fixsearchpath.pl \ tools/git-version.sh \ - tools/jenkins-build.sh \ tools/masssievec \ tools/mkimap \ tools/mknewsgroups \ + tools/perl2rst \ tools/rehash \ - tools/translatesieve \ - snmp/snmpgen + tools/translatesieve noinst_MAN = \ com_err/et/com_err.3 \ com_err/et/compile_et.1 noinst_TEXINFOS = com_err/et/com_err.texinfo pkgconfigdir = $(libdir)/pkgconfig -pkgconfig_DATA = libcyrus_min.pc libcyrus.pc libcyrus_sieve.pc +pkgconfig_DATA = libcyrus_min.pc libcyrus.pc libcyrus_sieve.pc libcyrus_imap.pc com_err_et_libcyrus_com_err_la_SOURCES = \ com_err/et/com_err.c \ @@ -571,6 +542,17 @@ com_err/et/compile_et: com_err/et/compile_et.sh com_err/et/config_script \ # ---- Libraries ---- +# SIEVE is the libraries that sieve-using components need to link with +# +# This is empty if sieve is not enabled, so it can be used unconditionally +# elsewhere. + +if SIEVE +LD_SIEVE_ADD = sieve/libcyrus_sieve.la $(LD_BASIC_ADD) +else +LD_SIEVE_ADD = +endif + # BASIC is the libraries that every Cyrus program (except master) will # need to link with. # @@ -583,7 +565,7 @@ LD_BASIC_ADD = lib/libcyrus.la lib/libcyrus_min.la ${LIBS} \ # UTILITY is the libraries that utility programs which use Cyrus' # mailbox and message handling code need to link with. -LD_UTILITY_ADD = imap/libcyrus_imap.la $(LD_BASIC_ADD) $(COM_ERR_LIBS) +LD_UTILITY_ADD = imap/libcyrus_imap.la $(LD_BASIC_ADD) $(LD_SIEVE_ADD) $(COM_ERR_LIBS) # SERVER is the libraries that network-facing servers need to link with # @@ -592,17 +574,6 @@ LD_UTILITY_ADD = imap/libcyrus_imap.la $(LD_BASIC_ADD) $(COM_ERR_LIBS) # the SASL library. LD_SERVER_ADD = $(LD_UTILITY_ADD) $(LIB_WRAP) -# SIEVE is the libraries that sieve-using components need to link with -# -# This is empty if sieve is not enabled, so it can be used unconditionally -# elsewhere. - -if SIEVE -LD_SIEVE_ADD = sieve/libcyrus_sieve.la $(LD_BASIC_ADD) -else -LD_SIEVE_ADD = -endif - # ---- if USE_LIBCHARDET @@ -617,6 +588,12 @@ AM_CPPFLAGS += $(JANSSON_CFLAGS) LD_SERVER_ADD += $(JANSSON_LIBS) endif +if HAVE_GUESSTZ +AM_LDFLAGS += $(GUESSTZ_LIBS) +AM_CPPFLAGS += $(GUESSTZ_CFLAGS) +LD_SERVER_ADD += $(GUESSTZ_LIBS) +endif + if CUNIT CUNIT_PROJECT = cunit/default.cunit @@ -639,14 +616,17 @@ cunit_FRAMEWORK = \ cunit_TESTS = \ cunit/aaa-db.testc \ cunit/annotate.testc \ + cunit/arrayu64.testc \ cunit/backend.testc \ cunit/binhex.testc \ cunit/bitvector.testc \ cunit/buf.testc \ - cunit/byteorder64.testc \ + cunit/bufarray.testc \ + cunit/byteorder.testc \ cunit/charset.testc \ cunit/command.testc \ cunit/conversations.testc \ + cunit/cyr_qsort_r.testc \ cunit/crc32.testc \ cunit/dlist.testc \ cunit/duplicate.testc \ @@ -658,18 +638,24 @@ cunit_TESTS = \ cunit/imapurl.testc \ cunit/imparse.testc \ cunit/libconfig.testc \ + cunit/mailbox.testc \ cunit/mboxname.testc \ cunit/md5.testc \ cunit/message.testc \ - cunit/msgid.testc \ + cunit/message_iter_msgid.testc \ + cunit/message_guid.testc \ cunit/parseaddr.testc \ cunit/parse.testc \ + cunit/proc.testc \ + cunit/procinfo.testc \ cunit/prot.testc \ cunit/ptrarray.testc \ cunit/quota.testc \ cunit/rfc822tok.testc \ cunit/search_expr.testc \ - cunit/seqset.testc + cunit/seqset.testc \ + cunit/slowio.testc \ + cunit/smallarrayu64.testc if SIEVE cunit_TESTS += cunit/sieve.testc @@ -680,12 +666,20 @@ cunit_TESTS += \ cunit/squat.testc \ cunit/strarray.testc \ cunit/strconcat.testc \ + cunit/strhash.testc \ + cunit/stristr.testc \ cunit/times.testc \ cunit/tok.testc \ - cunit/vparse.testc + cunit/vparse.testc \ + cunit/dynarray.testc \ + cunit/xsha1.testc + +if HTTPD +cunit_TESTS += cunit/ical_support.testc +endif cunit_unit_SOURCES = $(cunit_FRAMEWORK) $(cunit_TESTS) \ - imap/mutex_fake.c imap/spool.c + imap/mutex_fake.c imap/spool.c imap/search_expr.c cunit_unit_LDADD = $(LD_SIEVE_ADD) $(LD_UTILITY_ADD) -lcunit CUNIT_PL = $(top_srcdir)/cunit/cunit.pl --project $(CUNIT_PROJECT) @@ -712,7 +706,11 @@ cunit/registers.h: $(CUNIT_PROJECT) $(AM_V_GEN)$(CUNIT_PL) --generate-register-function $@ # To run under Valgrind, do: make VG=1 check -VALGRIND = libtool --mode=execute valgrind --tool=memcheck --leak-check=full --suppressions=vg.supp +VALGRIND = libtool --mode=execute valgrind \ + --tool=memcheck \ + --leak-check=full \ + --suppressions=${abs_top_srcdir}/cunit/vg.supp \ + --error-exitcode=75 check-local: @echo "Running unit tests" @@ -728,6 +726,33 @@ check-local: fi ; \ exit $$retval +# Run every test individually, as if you had run them one at a time as +# `make check-suitename:testname` manually. Mainly useful for detecting +# abstraction failures within our testing infrastructure -- if tests +# succeed with `make check` but fail with `make check-discrete`, or +# vice-versa, some state is leaking between tests! +# +# This assumes your sh supports arithmetic and process substitutions. +# Sorry <3 +check-discrete: cunit/unit + @echo "Running unit tests, one at a time" + @vg= ; \ + test -z "$$VG" || vg="$(VALGRIND) --quiet --log-fd=3" ; \ + cd cunit ; \ + fails=0 ; \ + tests=0 ; \ + while read t ; do \ + tests=$$(($$tests + 1)) ; \ + if $$vg ./unit $$t >/dev/null 3>&2 2>/dev/null ; then \ + echo "[ OK ] $$t" ; \ + else \ + fails=$$(($$fails + 1)) ; \ + echo "[FAILED] $$t" ; \ + fi ; \ + done < <( ./unit -l ) ; \ + echo "$$fails/$$tests tests failed" ; \ + exit $$fails + check-%: cunit/unit @echo "Running unit tests for $*" @vg= ; \ @@ -755,9 +780,10 @@ include_HEADERS = \ lib/chartable.h \ lib/command.h \ lib/crc32.h \ - lib/crc32c.h \ lib/cyr_lock.h \ + lib/cyr_qsort_r.h \ lib/cyrusdb.h \ + lib/dynarray.h \ lib/glob.h \ lib/gmtoff.h \ lib/hash.h \ @@ -777,9 +803,13 @@ include_HEADERS = \ lib/murmurhash2.h \ lib/nonblock.h \ lib/parseaddr.h \ + lib/proc.h \ + lib/procinfo.h \ lib/retry.h \ lib/rfc822tok.h \ + lib/seqset.h \ lib/signals.h \ + lib/smallarrayu64.h \ lib/sqldb.h \ lib/strarray.h \ lib/strhash.h \ @@ -788,7 +818,8 @@ include_HEADERS = \ lib/tok.h \ lib/vparse.h \ lib/wildmat.h \ - lib/xmalloc.h + lib/xmalloc.h \ + lib/xunlink.h nodist_include_HEADERS = \ lib/imapopts.h @@ -797,12 +828,13 @@ nobase_include_HEADERS = sieve/sieve_interface.h nobase_nodist_include_HEADERS = sieve/sieve_err.h noinst_HEADERS += \ - lib/byteorder64.h \ + lib/byteorder.h \ lib/gai.h \ lib/libconfig.h \ lib/md5.h \ lib/prot.h \ lib/ptrarray.h \ + lib/slowio.h \ lib/util.h \ lib/xsha1.h \ lib/xstrlcat.h \ @@ -872,7 +904,7 @@ imap_ctl_deliver_LDADD = $(LD_UTILITY_ADD) imap_ctl_mboxlist_SOURCES = imap/cli_fatal.c imap/ctl_mboxlist.c imap/mutex_fake.c imap_ctl_mboxlist_LDADD = $(LD_UTILITY_ADD) -imap_ctl_zoneinfo_SOURCES = imap/cli_fatal.c imap/ctl_zoneinfo.c imap/mutex_fake.c imap/zoneinfo_db.c +imap_ctl_zoneinfo_SOURCES = imap/cli_fatal.c imap/ctl_zoneinfo.c imap/mutex_fake.c imap/zoneinfo_db.c imap/xml_support.c imap_ctl_zoneinfo_LDADD = $(LD_UTILITY_ADD) imap_cvt_cyrusdb_SOURCES = imap/cli_fatal.c imap/cvt_cyrusdb.c imap/mutex_fake.c @@ -890,6 +922,15 @@ imap_cyr_deny_LDADD = $(LD_UTILITY_ADD) imap_cyr_df_SOURCES = imap/cli_fatal.c imap/cyr_df.c imap/mutex_fake.c imap_cyr_df_LDADD = $(LD_UTILITY_ADD) +imap_cyr_ls_SOURCES = imap/cli_fatal.c imap/cyr_ls.c imap/mutex_fake.c +imap_cyr_ls_LDADD = $(LD_UTILITY_ADD) + +imap_cyr_pwd_SOURCES = imap/cli_fatal.c imap/cyr_pwd.c imap/mutex_fake.c +imap_cyr_pwd_LDADD = $(LD_UTILITY_ADD) + +imap_cyr_withlock_run_SOURCES = imap/cli_fatal.c imap/cyr_withlock_run.c imap/mutex_fake.c +imap_cyr_withlock_run_LDADD = $(LD_UTILITY_ADD) + imap_cyr_expire_SOURCES = imap/cli_fatal.c imap/cyr_expire.c imap/mutex_fake.c imap_cyr_expire_LDADD = $(LD_UTILITY_ADD) @@ -899,9 +940,6 @@ imap_cyr_info_LDADD = $(LD_UTILITY_ADD) imap_cyr_buildinfo_SOURCES = imap/cli_fatal.c imap/cyr_buildinfo.c imap/mutex_fake.c master/masterconf.c imap_cyr_buildinfo_LDADD = $(LD_UTILITY_ADD) -imap_cyr_sequence_SOURCES = imap/cli_fatal.c imap/cyr_sequence.c imap/mutex_fake.c -imap_cyr_sequence_LDADD = $(LD_UTILITY_ADD) - imap_cyr_synclog_SOURCES = imap/cli_fatal.c imap/cyr_synclog.c imap/mutex_fake.c imap_cyr_synclog_LDADD = $(LD_UTILITY_ADD) @@ -914,13 +952,13 @@ imap_cyr_virusscan_LDADD = $(LD_UTILITY_ADD) $(CLAMAV_LIBS) imap_deliver_SOURCES = \ imap/deliver.c \ - imap/lmtp_err.c \ imap/lmtpengine.c \ - imap/lmtpstats.c \ imap/mutex_fake.c \ imap/proxy.c \ imap/spool.c +nodist_imap_deliver_SOURCES = imap/lmtp_err.c + imap_deliver_LDADD = $(LD_UTILITY_ADD) imap_fetchnews_SOURCES = imap/cli_fatal.c imap/fetchnews.c imap/mutex_fake.c @@ -941,8 +979,6 @@ imap_imapd_SOURCES = \ imap/imapd.c \ imap/imapd.h \ imap/mutex_fake.c \ - imap/pushstats.c \ - imap/pushstats.h \ imap/proxy.c \ imap/proxy.h \ imap/sync_support.c \ @@ -961,14 +997,15 @@ imap_ipurge_SOURCES = imap/cli_fatal.c imap/ipurge.c imap/mutex_fake.c imap_ipurge_LDADD = $(LD_UTILITY_ADD) nodist_imap_libcyrus_imap_la_SOURCES = \ + imap/http_err.c \ + imap/http_err.h \ imap/imap_err.c \ + imap/imap_err.h \ imap/mupdate_err.c \ imap/mupdate_err.h \ imap/promdata.c \ imap/promdata.h -dist_imap_libcyrus_imap_la_SOURCES = - imap_libcyrus_imap_la_LIBADD = \ $(COM_ERR_LIBS) \ $(LIB_UUID) \ @@ -977,25 +1014,35 @@ imap_libcyrus_imap_la_LIBADD = \ lib/libcyrus.la imap_libcyrus_imap_la_CFLAGS = $(AM_CFLAGS) $(CFLAG_VISIBILITY) -imap_libcyrus_imap_la_CXXFLAGS = $(AM_CXXFLAGS) +imap_libcyrus_imap_la_CXXFLAGS = $(AM_CXXFLAGS) $(CFLAG_VISIBILITY) imap_libcyrus_imap_la_SOURCES = \ imap/annotate.c \ imap/annotate.h \ imap/append.c \ imap/append.h \ + imap/attachextract.c \ + imap/attachextract.h \ imap/backend.c \ imap/backend.h \ imap/conversations.c \ imap/conversations.h \ imap/convert_code.c \ imap/convert_code.h \ + imap/css3_color.c \ + imap/css3_color.h \ + imap/dav_db.c \ + imap/dav_db.h \ imap/dlist.c \ imap/dlist.h \ imap/duplicate.c \ imap/duplicate.h \ imap/global.c \ imap/global.h \ + imap/haproxy.c \ + imap/haproxy.h \ + imap/http_client.c \ + imap/http_client.h \ imap/idle.c \ imap/idle.h \ imap/idlemsg.c \ @@ -1004,6 +1051,10 @@ imap_libcyrus_imap_la_SOURCES = \ imap/imapparse.h \ imap/index.c \ imap/index.h \ + imap/jmap_util.c \ + imap/jmap_util.h \ + imap/json_support.c \ + imap/json_support.h \ imap/mailbox.c \ imap/mailbox.h \ imap/mbdump.c \ @@ -1030,9 +1081,6 @@ imap_libcyrus_imap_la_SOURCES = \ imap/notify.h \ imap/partlist.c \ imap/partlist.h \ - imap/proc.c \ - imap/proc.h \ - imap/promdata.p \ imap/prometheus.c \ imap/prometheus.h \ imap/protocol.h \ @@ -1050,11 +1098,15 @@ imap_libcyrus_imap_la_SOURCES = \ imap/search_query.c \ imap/search_query.h \ imap/search_part.h \ + imap/search_sort.h \ imap/seen.h \ imap/seen_db.c \ - imap/sequence.c \ - imap/sequence.h \ - imap/setproctitle.c \ + imap/sievedir.c \ + imap/sievedir.h \ + imap/smtpclient.c \ + imap/smtpclient.h \ + imap/spool.c \ + imap/spool.h \ imap/statuscache.h \ imap/statuscache_db.c \ imap/sync_log.c \ @@ -1075,6 +1127,12 @@ imap_libcyrus_imap_la_SOURCES = \ imap/xstats.h \ imap/xstats_metrics.h +if SIEVE +imap_libcyrus_imap_la_SOURCES += \ + imap/sieve_db.c \ + imap/sieve_db.h +endif # SIEVE + if OBJECTSTORE imap_libcyrus_imap_la_SOURCES += \ imap/objectstore_db.c \ @@ -1111,19 +1169,29 @@ imap_libcyrus_imap_la_SOURCES += \ imap/caldav_db.h \ imap/carddav_db.c \ imap/carddav_db.h \ - imap/dav_db.c \ - imap/dav_db.h \ imap/dav_util.c \ imap/dav_util.h \ + imap/defaultalarms.c \ + imap/defaultalarms.h \ imap/ical_support.c \ imap/ical_support.h \ imap/icu_wrap.h \ imap/icu_wrap.cpp \ + imap/calsched_support.c \ + imap/calsched_support.h \ imap/vcard_support.c \ imap/vcard_support.h \ imap/webdav_db.c \ imap/webdav_db.h +if CUNIT + +cunit_TESTS += cunit/http_jwt.testc +cunit_unit_SOURCES += imap/http_jwt.c + +endif # CUNIT + + endif # HTTPD if USE_XAPIAN @@ -1133,22 +1201,30 @@ imap_libcyrus_imap_la_SOURCES += \ imap/xapian_wrap.cpp imap_libcyrus_imap_la_LIBADD += $(XAPIAN_LIBS) imap_libcyrus_imap_la_CXXFLAGS += $(XAPIAN_CXXFLAGS) + +if HAVE_CLD2 +imap_libcyrus_imap_la_LIBADD += $(CLD2_LIBS) +imap_libcyrus_imap_la_CXXFLAGS += $(CLD2_CFLAGS) +endif + endif imap_lmtpd_SOURCES = \ imap/lmtpd.c \ imap/lmtpd.h \ - imap/lmtp_err.c \ - imap/lmtp_err.h \ imap/lmtpengine.c \ imap/lmtpengine.h \ - imap/lmtpstats.c \ - imap/lmtpstats.h \ imap/mutex_fake.c \ imap/proxy.c \ imap/spool.c \ + imap/sync_support.c \ + imap/sync_support.h \ master/service.c +nodist_imap_lmtpd_SOURCES = imap/lmtp_err.c + +imap_lmtpd_CFLAGS = -DBUILD_LMTPD $(CFLAGS) + if AUTOCREATE imap_lmtpd_SOURCES += \ imap/autocreate.c \ @@ -1156,15 +1232,22 @@ imap_lmtpd_SOURCES += \ endif # AUTOCREATE if SIEVE -imap_lmtpd_SOURCES += imap/lmtp_sieve.c imap/lmtp_sieve.h imap/smtpclient.c -endif # SIEVE +imap_lmtpd_SOURCES += imap/lmtp_sieve.c imap/lmtp_sieve.h imap/smtpclient.c \ + imap/zoneinfo_db.c +if HTTPD +imap_lmtpd_SOURCES += imap/caldav_util.c imap/defaultalarms.c imap/itip_support.c -imap_lmtpd_LDADD = $(LD_SIEVE_ADD) $(LD_SERVER_ADD) +if JMAP +imap_lmtpd_SOURCES += \ + imap/jmap_util.c imap/jmap_mail_query.c imap/jmap_mail_query_parse.c \ + imap/dav_util.c imap/jmap_notif.c imap/jmap_ical.c imap/jcal.c imap/xcal.c +endif # JMAP -SNMPGEN = $(abs_top_srcdir)/snmp/snmpgen +endif # HTTPD -imap/%stats.h imap/%stats.c: imap/%stats.snmp $(SNMPGEN) - $(AM_V_GEN)(cd $(@D) && $(SNMPGEN) $(realpath $<)) +endif # SIEVE + +imap_lmtpd_LDADD = $(LD_SIEVE_ADD) $(LD_SERVER_ADD) imap_mbexamine_SOURCES = imap/cli_fatal.c imap/mbexamine.c imap/mutex_fake.c imap_mbexamine_LDADD = $(LD_UTILITY_ADD) @@ -1188,9 +1271,8 @@ imap_mupdate_SOURCES = \ imap_mupdate_LDADD = $(LD_SERVER_ADD) -lpthread imap_mupdate_CFLAGS = $(AM_CFLAGS) -pthread -nodist_imap_nntpd_SOURCES = \ - imap/nntp_err.c \ - imap/nntp_err.h +nodist_imap_nntpd_SOURCES = imap/nntp_err.c + imap_nntpd_SOURCES = \ imap/mutex_fake.c \ imap/nntpd.c \ @@ -1199,33 +1281,28 @@ imap_nntpd_SOURCES = \ imap/smtpclient.h \ imap/spool.c \ imap/spool.h \ + imap/sync_support.c \ + imap/sync_support.h \ master/service.c -imap_nntpd_LDADD = $(LD_SERVER_ADD) +imap_nntpd_LDADD = $(LD_SIEVE_ADD) $(LD_SERVER_ADD) nodist_imap_httpd_SOURCES = \ - imap/http_caldav_js.h \ - imap/http_carddav_js.h \ - imap/http_err.c \ - imap/http_err.h \ - imap/jmap_err.c \ - imap/jmap_err.h \ imap/tz_err.c \ imap/tz_err.h imap_httpd_SOURCES = \ - imap/css3_color.c \ - imap/css3_color.h \ + imap/caldav_util.c \ + imap/caldav_util.h \ imap/http_admin.c \ imap/http_applepush.c \ imap/http_caldav.c \ - imap/http_caldav.h \ + imap/http_caldav_js.h \ imap/http_caldav_sched.c \ imap/http_caldav_sched.h \ imap/http_carddav.c \ imap/http_carddav.h \ + imap/http_carddav_js.h \ imap/http_cgi.c \ - imap/http_client.c \ - imap/http_client.h \ imap/http_dav.c \ imap/http_dav.h \ imap/http_dav_sharing.c \ @@ -1234,6 +1311,8 @@ imap_httpd_SOURCES = \ imap/http_h2.c \ imap/http_h2.h \ imap/http_ischedule.c \ + imap/http_jwt.c \ + imap/http_jwt.h \ imap/http_prometheus.c \ imap/http_proxy.c \ imap/http_proxy.h \ @@ -1244,12 +1323,16 @@ imap_httpd_SOURCES = \ imap/http_ws.h \ imap/httpd.c \ imap/httpd.h \ + imap/itip_support.c \ + imap/itip_support.h \ imap/jcal.c \ imap/jcal.h \ imap/mutex_fake.c \ imap/proxy.c \ imap/smtpclient.c \ imap/spool.c \ + imap/sync_support.c \ + imap/sync_support.h \ imap/xcal.c \ imap/xcal.h \ imap/xml_support.c \ @@ -1259,23 +1342,57 @@ imap_httpd_SOURCES = \ master/masterconf.c \ master/service.c +imap_httpd_LDADD = $(LD_SIEVE_ADD) $(LD_SERVER_ADD) + if JMAP +BUILT_SOURCES += \ + imap/jmap_err.c \ + imap/jmap_err.h + imap_httpd_SOURCES += \ + imap/defaultalarms.c \ + imap/defaultalarms.h \ imap/http_jmap.c \ imap/http_jmap.h \ + imap/jmap_admin.c \ imap/jmap_api.c \ + imap/jmap_api.h \ + imap/jmap_backup.c \ imap/jmap_calendar.c \ + imap/jmap_calendar.h \ imap/jmap_contact.c \ + imap/jmap_blob.c \ + imap/jmap_core.c \ imap/jmap_ical.c \ imap/jmap_ical.h \ imap/jmap_mail.c \ + imap/jmap_mail.h \ + imap/jmap_mail_query.c \ + imap/jmap_mail_query.h \ + imap/jmap_mail_query_parse.c \ + imap/jmap_mail_query_parse.h \ imap/jmap_mail_submission.c \ imap/jmap_mailbox.c \ - imap/jmap_user.c - -imap_libcyrus_imap_la_SOURCES += \ + imap/jmap_mdn.c \ + imap/jmap_notes.c \ + imap/jmap_notif.c \ + imap/jmap_notif.h \ + imap/jmap_push.c \ + imap/jmap_push.h \ imap/jmap_util.c \ - jmap/jmap_util.h + imap/jmap_util.h + +if SIEVE +imap_httpd_SOURCES += \ + imap/jmap_sieve.c \ + imap/jmap_vacation.c +endif + +nodist_imap_httpd_SOURCES += \ + imap/jmap_err.c \ + imap/jmap_err.h + +imap_httpd_LDADD += $(LD_SIEVE_ADD) if CUNIT @@ -1285,12 +1402,12 @@ endif # CUNIT endif # JMAP -imap_httpd_LDADD = $(LD_SERVER_ADD) - imap_pop3d_SOURCES = \ imap/mutex_fake.c \ imap/pop3d.c \ imap/proxy.c \ + imap/sync_support.c \ + imap/sync_support.h \ master/service.c if AUTOCREATE @@ -1318,9 +1435,15 @@ imap_quota_LDADD = $(LD_UTILITY_ADD) imap_reconstruct_SOURCES = imap/cli_fatal.c imap/mutex_fake.c imap/reconstruct.c imap_reconstruct_LDADD = $(LD_UTILITY_ADD) +imap_relocate_by_id_SOURCES = imap/cli_fatal.c imap/mutex_fake.c imap/relocate_by_id.c +imap_relocate_by_id_LDADD = $(LD_UTILITY_ADD) + imap_dav_reconstruct_SOURCES = imap/cli_fatal.c imap/mutex_fake.c imap/dav_reconstruct.c imap_dav_reconstruct_LDADD = $(LD_UTILITY_ADD) +imap_ical_apply_patch_SOURCES = imap/cli_fatal.c imap/mutex_fake.c imap/ical_apply_patch.c +imap_ical_apply_patch_LDADD = $(LD_UTILITY_ADD) + imap_search_test_SOURCES = imap/search_test.c imap/mutex_fake.c imap_search_test_LDADD = $(LD_UTILITY_ADD) @@ -1353,12 +1476,14 @@ imap_unexpunge_LDADD = $(LD_UTILITY_ADD) %_err.h %_err.c: %_err.et $(COMPILE_ET_DEP) $(AM_V_GEN)(cd $(@D) && $(COMPILE_ET) $(realpath $<)) +if HAVE_XXD # xxd cannot have path details in its input filename, otherwise it junks up # the variable names in the output file. so do a tricky directory change. # the /dev/null redirection on cd is to prevent shell environments with # CDPATH echoing the path change on stdout and consequently into the .h file %_js.h: %.js - $(AM_V_GEN)(cd $( /dev/null && xxd -i $( $@ + $(AM_V_GEN)(cd $( /dev/null && $(XXD) -i $( $@.NEW && mv $@.NEW $@ +endif if MAINTAINER_MODE imap/rfc822_header.c: imap/rfc822_header.st @@ -1388,6 +1513,7 @@ lib_libcyrus_la_SOURCES = \ lib/auth.c \ lib/auth_krb.c \ lib/auth_krb5.c \ + lib/auth_mboxgroups.c \ lib/auth_pts.c \ lib/auth_unix.c \ lib/bitvector.c \ @@ -1395,6 +1521,7 @@ lib_libcyrus_la_SOURCES = \ lib/bsearch.c \ lib/charset.c \ lib/command.c \ + lib/cyr_qsort_r.c \ lib/cyrusdb.c \ lib/cyrusdb_flat.c \ lib/cyrusdb_quotalegacy.c \ @@ -1414,13 +1541,14 @@ lib_libcyrus_la_SOURCES = \ lib/murmurhash.c \ lib/mkgmtime.c \ lib/parseaddr.c \ + lib/procinfo.c \ lib/prot.c \ lib/ptrarray.c \ lib/rfc822tok.c \ + lib/seqset.c \ lib/signals.c \ lib/stristr.c \ lib/times.c \ - lib/tok.c \ lib/wildmat.c if USE_CYRUSDB_SQL lib_libcyrus_la_SOURCES += lib/cyrusdb_sql.c @@ -1438,7 +1566,7 @@ lib_libcyrus_la_SOURCES += lib/nonblock_fcntl.c else lib_libcyrus_la_SOURCES += lib/nonblock_ioctl.c endif -lib_libcyrus_la_LIBADD = libcrc32.la ${LIB_SASL} $(SSL_LIBS) $(GCOV_LIBS) +lib_libcyrus_la_LIBADD = libcrc32.la ${LIB_SASL} $(SSL_LIBS) $(GCOV_LIBS) $(LIBM) lib_libcyrus_la_CFLAGS = $(AM_CFLAGS) $(CFLAG_VISIBILITY) if USE_ZEROSKIP @@ -1449,7 +1577,7 @@ AM_CPPFLAGS += $(ZEROSKIP_CFLAGS) endif noinst_LTLIBRARIES += libcrc32.la -libcrc32_la_SOURCES = lib/crc32.c lib/crc32c.c +libcrc32_la_SOURCES = lib/crc32.c libcrc32_la_CFLAGS = -O3 $(AM_CFLAGS) $(CFLAG_VISIBILITY) nodist_lib_libcyrus_min_la_SOURCES = \ @@ -1459,21 +1587,28 @@ lib_libcyrus_min_la_SOURCES = \ lib/arrayu64.c \ lib/assert.c \ lib/bufarray.c \ - lib/byteorder64.c \ + lib/byteorder.c \ + lib/dynarray.c \ lib/hash.c \ lib/hashset.c \ lib/hashu64.c \ lib/libconfig.c \ lib/mpool.c \ + lib/proc.c \ lib/retry.c \ + lib/setproctitle.c \ + lib/slowio.c \ + lib/smallarrayu64.c \ lib/strarray.c \ lib/strhash.c \ + lib/tok.c \ lib/util.c \ lib/vparse.c \ lib/xmalloc.c \ lib/xstrlcat.c \ lib/xstrlcpy.c \ - lib/xstrnchr.c + lib/xstrnchr.c \ + lib/xunlink.c if !HAVE_SSL lib_libcyrus_min_la_SOURCES += lib/xsha1.c endif @@ -1497,9 +1632,11 @@ else lib_libcyrus_min_la_SOURCES += lib/map_nommap.c endif endif -lib_libcyrus_min_la_LIBADD = $(LTLIBOBJS) $(LIB_UUID) $(GCOV_LIBS) +lib_libcyrus_min_la_LIBADD = $(LTLIBOBJS) $(LIB_UUID) $(GCOV_LIBS) $(LIBCAP_LIBS) lib_libcyrus_min_la_CFLAGS = $(AM_CFLAGS) $(CFLAG_VISIBILITY) +# n.b. the order of the -m arguments to mkchartable.pl is important, +# in particular ellie believes unifix.txt must be before UnicodeData.txt lib/chartable.c: lib/mkchartable.pl lib/charset/unifix.txt \ $(top_srcdir)/lib/charset/*.t \ lib/charset/UnicodeData.txt lib/charset/aliases.txt @@ -1527,7 +1664,8 @@ dist_man1_MANS = \ man/pop3test.1 \ man/sieveshell.1 \ man/sivtest.1 \ - man/smtptest.1 + man/smtptest.1 \ + man/synctest.1 dist_man3_MANS = \ man/imclient.3 @@ -1547,6 +1685,7 @@ dist_man8_MANS = \ man/ctl_deliver.8 \ man/ctl_mboxlist.8 \ man/cvt_cyrusdb.8 \ + man/cvt_xlist_specialuse.8 \ man/cyr_backup.8 \ man/cyr_buildinfo.8 \ man/cyr_dbtool.8 \ @@ -1554,24 +1693,34 @@ dist_man8_MANS = \ man/cyr_df.8 \ man/cyr_expire.8 \ man/cyr_info.8 \ + man/cyr_ls.8 \ man/cyr_synclog.8 \ + man/cyr_userseen.8 \ man/cyr_virusscan.8 \ + man/cyrdump.8 \ man/deliver.8 \ man/fud.8 \ man/idled.8 \ man/imapd.8 \ man/ipurge.8 \ man/lmtpd.8 \ + man/lmtpproxyd.8 \ man/master.8 \ man/mbexamine.8 \ man/mbpath.8 \ man/mbtool.8 \ man/notifyd.8 \ man/pop3d.8 \ + man/pop3proxyd.8 \ + man/promstatsd.8 \ + man/proxyd.8 \ + man/ptdump.8 \ + man/ptexpire.8 \ + man/ptloader.8 \ man/quota.8 \ man/reconstruct.8 \ + man/relocate_by_id.8 \ man/restore.8 \ - man/rmnews.8 \ man/smmapd.8 \ man/timsieved.8 \ man/tls_prune.8 \ @@ -1589,33 +1738,68 @@ dist_man8_MANS += \ endif # NNTPD if HTTPD +dist_man1_MANS += \ + man/dav_reconstruct.1 dist_man8_MANS += \ man/ctl_zoneinfo.8 \ man/httpd.8 endif # HTTPD -if BENCH -check_PROGRAMS += bench/cyrdbbench -bench_cyrdbbench_SOURCES = bench/cyrdbbench.c imap/mutex_fake.c -bench_cyrdbbench_LDADD = $(LD_BASIC_ADD) -endif # BENCH - if REPLICATION dist_man8_MANS += \ man/sync_client.8 \ man/sync_reset.8 \ man/sync_server.8 +endif # REPLICATION + +if MURDER +dist_man8_MANS += \ + man/mupdate.8 +endif # MURDER + +if PERL +dist_man8_MANS += \ + man/cyradm.8 +endif # PERL + +if SIEVE +dist_man8_MANS += \ + man/sievec.8 \ + man/sieved.8 endif +if MAINTAINER_MODE +## make sure feature-dependent man pages are included in distribution +dist_man1_MANS += \ + man/dav_reconstruct.1 +dist_man8_MANS += \ + man/ctl_zoneinfo.8 \ + man/cyradm.8 \ + man/fetchnews.8 \ + man/httpd.8 \ + man/mupdate.8 \ + man/nntpd.8 \ + man/sievec.8 \ + man/sieved.8 \ + man/squatter.8 \ + man/sync_client.8 \ + man/sync_reset.8 \ + man/sync_server.8 +endif # MAINTAINER_MODE + +if BENCH +check_PROGRAMS += bench/cyrdbbench +bench_cyrdbbench_SOURCES = bench/cyrdbbench.c imap/mutex_fake.c +bench_cyrdbbench_LDADD = $(LD_BASIC_ADD) +endif # BENCH + master_master_SOURCES = \ - master/cyrusMasterMIB.c \ - master/cyrusMasterMIB.h \ master/master.c \ master/master.h \ master/masterconf.c \ master/masterconf.h \ master/service.h -master_master_LDADD = lib/libcyrus_min.la $(LIB_UCDSNMP) $(LIBS) $(GCOV_LIBS) -lm +master_master_LDADD = lib/libcyrus_min.la $(LIBS) $(GCOV_LIBS) $(LIBM) netnews_remotepurge_SOURCES = \ @@ -1669,6 +1853,7 @@ ptclient_ptexpire_LDADD = $(LD_UTILITY_ADD) ptclient_ptloader_SOURCES = \ imap/mutex_fake.c \ + ptclient/http.c \ ptclient/ptloader.c \ ptclient/ptloader.h \ master/service-thread.c @@ -1723,7 +1908,6 @@ sieve_libcyrus_sieve_la_SOURCES = \ sieve/interp.h \ sieve/message.c \ sieve/message.h \ - sieve/rebuild.c \ sieve/script.c \ sieve/script.h \ sieve/sieve.y \ @@ -1733,6 +1917,15 @@ sieve_libcyrus_sieve_la_SOURCES = \ sieve/variables.h \ sieve/varlist.c \ sieve/varlist.h + +if JMAP +sieve_libcyrus_sieve_la_SOURCES += \ + imap/jmap_mail_query_parse.c \ + imap/jmap_mail_query_parse.h \ + imap/json_support.c \ + imap/json_support.h +endif + sieve_libcyrus_sieve_la_LIBADD = \ sieve/libcyrus_sieve_lex.la \ $(COM_ERR_LIBS) \ @@ -1743,9 +1936,22 @@ sieve_libcyrus_sieve_la_CFLAGS = $(AM_CFLAGS) $(CFLAG_VISIBILITY) sieve_sievec_LDADD = $(LD_SIEVE_ADD) sieve_sieved_LDADD = $(LD_SIEVE_ADD) -sieve_test_SOURCES = sieve/test.c imap/mutex_fake.c +sieve_test_SOURCES = \ + sieve/test.c \ + imap/mutex_fake.c \ + sieve/sieve_interface.h +if JMAP +sieve_test_SOURCES += \ + imap/jmap_util.c imap/jmap_mail_query.c imap/jmap_mail_query_parse.c +endif sieve_test_LDADD = $(LD_SIEVE_ADD) $(LD_UTILITY_ADD) +sieve_test_mailbox_SOURCES = \ + sieve/test_mailbox.c \ + imap/mutex_fake.c \ + sieve/sieve_interface.h +sieve_test_mailbox_LDADD = $(LD_SIEVE_ADD) $(LD_UTILITY_ADD) + timsieved_timsieved_SOURCES = \ imap/mutex_fake.c \ imap/proxy.c \ @@ -1830,7 +2036,7 @@ clean-docsrc: ## XXX doesn't detect if other rst sources are updated... man/.sphinx-build.stamp: docsrc/.sphinx-build.stamp - $(AM_V_GEN)DOCSRC=$(top_builddir)/docsrc $(SPHINX_BUILD) $(SPHINX_OPTS) -b cyrman $(top_builddir)/docsrc $(top_builddir)/man + $(AM_V_GEN)DOCSRC=$(top_builddir)/docsrc $(SPHINX_BUILD) $(SPHINX_OPTS) -b man $(top_builddir)/docsrc $(top_builddir)/man @touch $@ clean-man: @@ -1993,6 +2199,7 @@ install-binsymlinks: SUFFIXES = .fig.png .fig.png: + mkdir -p $(@D) fig2dev -L png $< $@ valgrind: @@ -2000,9 +2207,3 @@ valgrind: libtool: $(LIBTOOL_DEPS) $(SHELL) ./config.status libtool - -# Install for Cassandane -cassandane: - $(MAKE) DESTDIR=`cd ../inst ; /bin/pwd` install - -export diff --git a/README.md b/README.md index 5a7ba03075..eaabd6ef3f 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,75 @@ -master: [![Build Status:master](https://api.travis-ci.org/cyrusimap/cyrus-imapd.svg?branch=master)](https://travis-ci.org/cyrusimap/cyrus-imapd) - stable(3.0): [![Build Status:3.x](https://api.travis-ci.org/cyrusimap/cyrus-imapd.svg?branch=cyrus-imapd-3.0)](https://travis-ci.org/cyrusimap/cyrus-imapd) -[![IRC](https://img.shields.io/badge/IRC-%23cyrus-1e72ff.svg?style=flat)](http://webchat.freenode.net/?channels=cyrus) +master: [![Build Status:master](https://github.com/cyrusimap/cyrus-imapd/actions/workflows/main.yml/badge.svg)](https://github.com/cyrusimap/cyrus-imapd/actions/workflows/main.yml) + stable(3.10): [![Build Status:3.10](https://github.com/cyrusimap/cyrus-imapd/actions/workflows/main.yml/badge.svg?branch=cyrus-imapd-3.10)](https://github.com/cyrusimap/cyrus-imapd/actions/workflows/main.yml) ----- Welcome ======= -This is the Cyrus IMAP Server, version series 3.0.x. +This is the Cyrus IMAP Server, developer version 3.11. This version is under +active development, and is not considered "stable". -No further development work will progress on anything older than version 2.3. -Versions 2.3 and 2.4 still receive security updates, but new features or -non-security bugfixes are unlikely to be backported. Version 2.5 still -receives security updates and non-security bugfixes. Version 3.0 is under -active development. +The current stable series is 3.10. + +Versions 3.2 to 3.8 still receive security updates, and some non-security +bug fixes. What is Cyrus ============= -Cyrus is an IMAP server, where IMAP (Internet Message Access Protocol) +Cyrus is an IMAP server, where IMAP (Internet Message Access Protocol) is a protocol for accessing mail. -The Cyrus IMAP server differs from other IMAP server implementations in -that it is generally intended to be run on "sealed" servers, where -normal users are not permitted to log in. The mailbox database is stored -in parts of the filesystem that are private to the Cyrus IMAP system. -All user access to mail is through the IMAP, NNTP, or POP3 protocols. +The Cyrus IMAP server differs from other IMAP server implementations in +that it is generally intended to be run on "sealed" servers, where +normal users are not permitted to log in. The mailbox database is stored +in parts of the filesystem that are private to the Cyrus IMAP system. +All user access to content is through JMAP, IMAP, NNTP, POP3, CalDAV, CardDAV, +and WebDAV protocols. -The private mailbox database design gives the server large advantages in -efficiency, scalability, and administrability. Multiple concurrent -read/write connections to the same mailbox are permitted. The server -supports access control lists on mailboxes and storage quotas on mailbox -hierarchies. +The private mailbox database design gives the server large advantages in +efficiency, scalability, and administrability. Multiple concurrent +read/write connections to the same mailbox are permitted. The server +supports access control lists on mailboxes and storage quotas on mailbox +hierarchies. Cyrus goals =========== -To be the best open source secure, scalable mail server, providing -breadth and depth of functionality across email, contacts, calendar +To be the best open source secure, scalable mail server, providing +breadth and depth of functionality across email, contacts, calendar and related messaging services! How to get Cyrus ================ -Cyrus comes in three flavours: +Cyrus comes in three flavours: -1. Our release source tarballs from ftp://ftp.cyrusimap.org/cyrus-imapd/ +1. Our release source tarballs from https://github.com/cyrusimap/cyrus-imapd/releases * Recommended for most users. * These are packaged by the Cyrus team. * The docs are pre-built for you in doc/html. - * They're definitively tagged to a particular release version with up to date release notes. + * They're definitively tagged to a particular release version with up to + date release notes. 2. Raw source from https://github.com/cyrusimap/cyrus-imapd - * Use this if you need a version of Cyrus that contains an unreleased patch/fix/feature. - * These bundles require a lot more dependencies to build than a packaged tarball. + * Use this if you need a version of Cyrus that contains an unreleased + patch/fix/feature. + * These bundles require a lot more dependencies to build than a packaged + tarball. 3. Operating System distribution packages. - * Cyrus IMAP packages are shipped with every major distribution, including but not limited to Fedora, Red Hat Enterprise Linux, CentOS, Scientific Linux, Debian, Ubuntu, openSUSE, Gentoo, Mageia and ClearOS. - * Please be aware that we don't maintain these packages and as such, some distributions are out of date. - * If you run into problems with a packed distribution, please contact the source of the distribution. + * Cyrus IMAP packages are shipped with every major distribution, including + but not limited to Fedora, Red Hat Enterprise Linux, CentOS, Scientific + Linux, Debian, Ubuntu, openSUSE, Gentoo, Mageia and ClearOS. + * Please be aware that we don't maintain these packages and as such, some + distributions are out of date. + * If you run into problems with a packed distribution, please contact the + source of the distribution. How to install Cyrus from packaged releases =============================================== -Please be sure to read the documentation. The latest version is online -at http://www.cyrusimap.org, but the version current for this +Please be sure to read the documentation. The latest version is online +at https://www.cyrusimap.org, but the version current for this distribution can be found in the doc/ subdirectory. For Cyrus tarball releases, the basic installation instructions are: @@ -86,7 +93,7 @@ from source (see next section). The latest development code is on the branch called 'master', and the latest code destined for the stable release is on the branch 'cyrus-imapd-$major.$minor'. So the current -stable release is called cyrus-imapd-2.5 +stable release is called cyrus-imapd-3.10 Unlike releases, the git repository doesn't have a pre-built ./configure script. You need to generate it with autoreconf: @@ -96,9 +103,14 @@ Unlike releases, the git repository doesn't have a pre-built $ make $ sudo make install -If you need to build a local copy of the docs current to the version of the code, these need to be built: see doc/README.docs +GNU Make is required. If you're not on Linux, it might be called 'gmake'. + +If you need to build a local copy of the docs current to the version of the +code, these need to be built: see doc/README.docs -Read through doc/html/imap/developer.html for more detailed instructions on building and contributing. The latest version is online at http://www.cyrusimap.org/imap/developer.html +Read through doc/html/imap/developer.html for more detailed instructions on +building and contributing. The latest version is online at +https://www.cyrusimap.org/imap/developer.html How to install Cyrus libraries from git source ============================================== @@ -140,23 +152,22 @@ Then continue to install Cyrus. Are you upgrading? ================== -Read doc/legacy/install-upgrade.html +Read doc/html/imap/download/upgrade.html Think you've found a bug or have a new feature? =============================================== -Fantastic! We'd love to hear about it, especially if you have a patch to -contribute. +Fantastic! We'd love to hear about it, especially if you have a patch to +contribute. The best way to make contributions to the project is to fork it on github, make your changes on your fork, and then send a pull request. -Check https://github.com/cyrusimap/cyrus-imapd/issues/ for any -outstanding bugs. Old bugs can be found at -https://bugzilla.cyrusimap.org/ +Check https://github.com/cyrusimap/cyrus-imapd/issues/ for any +outstanding bugs. -Our guide at http://www.cyrusimap.org/feedback-bugs.html has all the +Our guide at https://www.cyrusimap.org/support.html has all the information about how to contact us and how best to get your change accepted. Licensing Information @@ -167,10 +178,8 @@ See the COPYING file in this distribution. Contact us ========== -Whether you have a success story to share, or a bug to file, or a -request for help or a feature to add or some documentation to contribute -or you'd just like to say hi, we want to hear from you! See -http://www.cyrusimap.org/feedback.html for various ways you can get hold -of us. - - +Whether you have a success story to share, or a bug to file, or a +request for help or a feature to add or some documentation to contribute +or you'd just like to say hi, we want to hear from you! See +https://www.cyrusimap.org/support.html for various ways you can get hold +of us. diff --git a/backup/backupd.c b/backup/backupd.c index 8168463273..33a91fe018 100644 --- a/backup/backupd.c +++ b/backup/backupd.c @@ -56,14 +56,15 @@ #include "lib/bsearch.h" #include "lib/imparse.h" #include "lib/map.h" +#include "lib/proc.h" #include "lib/signals.h" +#include "lib/slowio.h" #include "lib/strarray.h" #include "lib/util.h" #include "lib/xmalloc.h" #include "imap/global.h" #include "imap/imap_err.h" -#include "imap/proc.h" #include "imap/sync_support.h" #include "imap/telemetry.h" #include "imap/tls.h" @@ -84,6 +85,7 @@ static sasl_conn_t *backupd_saslconn = NULL; static int backupd_starttls_done = 0; static int backupd_compress_done = 0; static int backupd_logfd = -1; +static struct proc_handle *proc_handle = NULL; struct open_backup { char *name; @@ -159,7 +161,7 @@ EXPORTED void fatal(const char* s, int code) if (recurse_code) { /* We were called recursively. Just give up */ - proc_cleanup(); + proc_cleanup(&proc_handle); exit(recurse_code); } recurse_code = code; @@ -171,6 +173,9 @@ EXPORTED void fatal(const char* s, int code) prot_flush(backupd_out); } syslog(LOG_ERR, "Fatal error: %s", s); + + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + shut_down(code); } @@ -184,7 +189,7 @@ EXPORTED int service_init(int argc __attribute__((unused)), { // FIXME should this be calling fatal? fatal exits directly if (geteuid() == 0) fatal("must run as the Cyrus user", EX_USAGE); - setproctitle_init(argc, argv, envp); + proc_settitle_init(argc, argv, envp); /* set signal handlers */ signals_set_shutdown(&shut_down); @@ -222,16 +227,15 @@ EXPORTED int service_main(int argc __attribute__((unused)), { const char *localip, *remoteip; sasl_security_properties_t *secprops = NULL; - int timeout; + int r, timeout; signals_poll(); backupd_in = prot_new(0, 0); backupd_out = prot_new(1, 1); - /* Force use of LITERAL+ so we don't need two way communications */ + /* Allow use of LITERAL+ */ prot_setisclient(backupd_in, 1); - prot_setisclient(backupd_out, 1); /* Find out name of client host */ backupd_clienthost = get_clienthost(0, &localip, &remoteip); @@ -268,10 +272,13 @@ EXPORTED int service_main(int argc __attribute__((unused)), tcp_disable_nagle(1); /* XXX magic fd */ } - proc_register(config_ident, backupd_clienthost, NULL, NULL, NULL); + r = proc_register(&proc_handle, 0, + config_ident, backupd_clienthost, NULL, NULL, NULL); + if (r) fatal("unable to register process", EX_IOERR); + proc_settitle(config_ident, backupd_clienthost, NULL, NULL, NULL); /* Set inactivity timer */ - timeout = config_getint(IMAPOPT_SYNC_TIMEOUT); + timeout = config_getduration(IMAPOPT_SYNC_TIMEOUT, 's'); if (timeout < 3) timeout = 3; prot_settimeout(backupd_in, timeout); @@ -295,7 +302,7 @@ static void backupd_reset(void) { open_backups_list_close(&backupd_open_backups, 0); - proc_cleanup(); + proc_cleanup(&proc_handle); if (backupd_in) { prot_NONBLOCK(backupd_in); @@ -355,6 +362,8 @@ static void backupd_reset(void) saslprops.ssf = 0; backup_cleanup_staging_path(); + + slowio_reset(); } static void dobanner(void) @@ -460,7 +469,7 @@ static struct open_backup *open_backups_list_add(struct open_backups_list *list, list->head = open; list->count++; - open->name = xstrdup(name); + open->name = xstrdupnull(name); open->backup = backup; open->timestamp = time(0); @@ -479,7 +488,7 @@ static struct open_backup *open_backups_list_find(struct open_backups_list *list struct open_backup *open; for (open = list->head; open; open = open->next) { - if (strcmp(name, open->name) == 0) + if (strcmpnull(name, open->name) == 0) return open; } @@ -645,7 +654,7 @@ static void cmdloop(void) if (!backupd_userid) goto nologin; if (!strcmp(cmd.s, "Apply")) { struct dlist *dl = NULL; - c = dlist_parse(&dl, /*parsekeys*/ 1, /*isbackup*/ 1, backupd_in); + c = dlist_parse(&dl, /*parsekeys*/ 1, /*isarchive*/ 0, /*isbackup*/ 1, backupd_in); if (c == EOF) goto missingargs; if (c == '\r') c = prot_getc(backupd_in); if (c != '\n') goto extraargs; @@ -681,7 +690,7 @@ static void cmdloop(void) if (!backupd_userid) goto nologin; if (!strcmp(cmd.s, "Get")) { struct dlist *dl = NULL; - c = dlist_parse(&dl, /*parsekeys*/ 1, /*isbackup*/ 1, backupd_in); + c = dlist_parse(&dl, /*parsekeys*/ 1, /*isarchive*/ 0, /*isbackup*/ 1, backupd_in); if (c == EOF) goto missingargs; if (c == '\r') c = prot_getc(backupd_in); if (c != '\n') goto extraargs; @@ -721,7 +730,8 @@ static void cmdloop(void) } - syslog(LOG_ERR, "IOERROR: received bad command: %s", cmd.s); + xsyslog(LOG_ERR, "IOERROR: received bad command", + "command=<%s>", cmd.s); prot_printf(backupd_out, "BAD IMAP_PROTOCOL_ERROR Unrecognized command\r\n"); eatline(backupd_in, c); continue; @@ -790,7 +800,7 @@ static void cmd_authenticate(char *mech, char *resp) syslog(LOG_NOTICE, "badlogin: %s %s [%s]", backupd_clienthost, mech, sasl_errdetail(backupd_saslconn)); - failedloginpause = config_getint(IMAPOPT_FAILEDLOGINPAUSE); + failedloginpause = config_getduration(IMAPOPT_FAILEDLOGINPAUSE, 's'); if (failedloginpause != 0) { sleep(failedloginpause); } @@ -822,7 +832,11 @@ static void cmd_authenticate(char *mech, char *resp) } backupd_userid = xstrdup((const char *) val); - proc_register(config_ident, backupd_clienthost, backupd_userid, NULL, NULL); + r = proc_register(&proc_handle, 0, + config_ident, backupd_clienthost, backupd_userid, + NULL, NULL); + if (r) fatal("unable to register process", EX_IOERR); + proc_settitle(config_ident, backupd_clienthost, backupd_userid, NULL, NULL); syslog(LOG_NOTICE, "login: %s %s %s%s %s", backupd_clienthost, backupd_userid, mech, backupd_starttls_done ? "+TLS" : "", "User logged in"); @@ -913,7 +927,7 @@ static int cmd_apply_message(struct dlist *dl) message_guid_generate(&computed_guid, msg_base, msg_len); if (!message_guid_equal(guid, &computed_guid)) { - syslog(LOG_ERR, "%s: guid mismatch: header %s, derived %s\n", + syslog(LOG_ERR, "%s: guid mismatch: header %s, derived %s", __func__, message_guid_encode(guid), message_guid_encode(&computed_guid)); r = IMAP_PROTOCOL_ERROR; @@ -923,7 +937,8 @@ static int cmd_apply_message(struct dlist *dl) close(fd); } else { - syslog(LOG_ERR, "IOERROR: %s open %s: %m", __func__, fname); + xsyslog(LOG_ERR, "IOERROR: open failed", + "filename=<%s>", fname); r = IMAP_IOERROR; } @@ -991,6 +1006,44 @@ static int cmd_apply_message(struct dlist *dl) return r; } +static int reserve_one(const mbname_t *mbname, + struct dlist *dl, struct dlist *gl, + struct sync_msgid_list *missing) +{ + struct open_backup *open = NULL; + struct dlist *di; + int r; + + r = backupd_open_backup(&open, mbname); + if (r) return r; + + r = backup_append(open->backup, dl, NULL, BACKUP_APPEND_FLUSH); + if (r) return r; + + for (di = gl->head; di; di = di->next) { + struct message_guid *guid = NULL; + const char *guid_str; + + if (!dlist_toguid(di, &guid)) continue; + guid_str = message_guid_encode(guid); + + int message_id = backup_get_message_id(open->backup, guid_str); + + if (message_id <= 0) { + syslog(LOG_DEBUG, "%s: %s wants message %s", + __func__, mbname_intname(mbname), guid_str); + + /* add it to the reserved guids list */ + sync_msgid_insert(open->reserved_guids, guid); + + /* add it to the missing list */ + sync_msgid_insert(missing, guid); + } + } + + return 0; +} + static int cmd_apply_reserve(struct dlist *dl) { const char *partition = NULL; @@ -998,7 +1051,8 @@ static int cmd_apply_reserve(struct dlist *dl) struct dlist *gl = NULL; struct dlist *di; strarray_t userids = STRARRAY_INITIALIZER; - int i, r; + mbname_t *shared_mbname = NULL; + int i, r = 0; if (!dlist_getatom(dl, "PARTITION", &partition)) return IMAP_PROTOCOL_ERROR; if (!dlist_getlist(dl, "MBOXNAME", &ml)) return IMAP_PROTOCOL_ERROR; @@ -1007,8 +1061,16 @@ static int cmd_apply_reserve(struct dlist *dl) /* find the list of users this reserve applies to */ for (di = ml->head; di; di = di->next) { mbname_t *mbname = mbname_from_intname(di->sval); - strarray_append(&userids, mbname_userid(mbname)); - mbname_free(&mbname); + if (mbname_userid(mbname)) { + strarray_append(&userids, mbname_userid(mbname)); + mbname_free(&mbname); + } + else if (!shared_mbname) { + shared_mbname = mbname; + } + else { + mbname_free(&mbname); + } } strarray_sort(&userids, cmpstringp_raw); strarray_uniq(&userids); @@ -1018,37 +1080,17 @@ static int cmd_apply_reserve(struct dlist *dl) /* log the entire reserve to all relevant backups, and accumulate missing list */ for (i = 0; i < strarray_size(&userids); i++) { - struct open_backup *open = NULL; mbname_t *mbname = mbname_from_userid(strarray_nth(&userids, i)); - r = backupd_open_backup(&open, mbname); + r = reserve_one(mbname, dl, gl, missing); mbname_free(&mbname); if (r) goto done; + } - r = backup_append(open->backup, dl, NULL, BACKUP_APPEND_FLUSH); + /* and the shared mailboxes backup, if there were any */ + if (shared_mbname) { + r = reserve_one(shared_mbname, dl, gl, missing); if (r) goto done; - - for (di = gl->head; di; di = di->next) { - struct message_guid *guid = NULL; - const char *guid_str; - - if (!dlist_toguid(di, &guid)) continue; - guid_str = message_guid_encode(guid); - - int message_id = backup_get_message_id(open->backup, guid_str); - - if (message_id <= 0) { - syslog(LOG_DEBUG, - "%s: %s wants message %s", - __func__, strarray_nth(&userids, i), guid_str); - - /* add it to the reserved guids list */ - sync_msgid_insert(open->reserved_guids, guid); - - /* add it to the missing list */ - sync_msgid_insert(missing, guid); - } - } } if (missing->head) { @@ -1066,6 +1108,7 @@ static int cmd_apply_reserve(struct dlist *dl) } done: + mbname_free(&shared_mbname); strarray_fini(&userids); sync_msgid_list_free(&missing); return r; @@ -1083,7 +1126,7 @@ static int cmd_apply_rename(struct dlist *dl) mbname_t *old = mbname_from_intname(old_mboxname); mbname_t *new = mbname_from_intname(old_mboxname); - if (strcmp(mbname_userid(old), mbname_userid(new)) == 0) { + if (strcmpnull(mbname_userid(old), mbname_userid(new)) == 0) { // same user, unremarkable folder rename *whew* struct open_backup *open = NULL; r = backupd_open_backup(&open, old); @@ -1435,9 +1478,9 @@ static int is_mailboxes_single_user(struct dlist *dl) mbname_t *mbname = mbname_from_intname(di->sval); if (!userid) { - userid = xstrdup(mbname_userid(mbname)); + userid = xstrdupnull(mbname_userid(mbname)); } - else if (strcmp(userid, mbname_userid(mbname)) != 0) { + else if (strcmpnull(userid, mbname_userid(mbname)) != 0) { mbname_free(&mbname); free(userid); return 0; @@ -1447,6 +1490,7 @@ static int is_mailboxes_single_user(struct dlist *dl) } free(userid); + /* also returns 1 if all mailboxes belong to no user (shared)*/ return 1; } @@ -1508,6 +1552,9 @@ static void cmd_get(struct dlist *dl) else if (strcmp(dl->name, "META") == 0) { r = cmd_get_meta(dl); } + else if (strcmp(dl->name, "UNIQUEIDS") == 0) { + r = 0; // we don't send anything back other than OK + } else { r = IMAP_PROTOCOL_ERROR; } diff --git a/backup/ctl_backups.c b/backup/ctl_backups.c index 5b1dfd92f7..27942d9075 100644 --- a/backup/ctl_backups.c +++ b/backup/ctl_backups.c @@ -49,6 +49,7 @@ #include #include +#include #include #include #include @@ -65,10 +66,15 @@ #include "backup/backup.h" +static struct namespace ctl_backups_namespace; + EXPORTED void fatal(const char *error, int code) { fprintf(stderr, "fatal error: %s\n", error); cyrus_done(); + + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + exit(code); } @@ -350,7 +356,35 @@ int main(int argc, char **argv) struct ctlbu_cmd_options options = {0}; options.wait = BACKUP_OPEN_NONBLOCK; - while ((opt = getopt(argc, argv, ":AC:DFPSVcfjmpst:x:uvw")) != EOF) { + /* keep in alphabetical order */ + static const char short_options[] = ":AC:DFPSVcfjmpst:uvwx:"; + + static const struct option long_options[] = { + { "all", no_argument, NULL, 'A' }, + /* n.b. no long-option for -C */ + { "domains", no_argument, NULL, 'D' }, + { "force", no_argument, NULL, 'F' }, + { "prefixes", no_argument, NULL, 'P' }, + { "stop-on-error", no_argument, NULL, 'S' }, + { "no-verify", no_argument, NULL, 'V' }, + { "create", no_argument, NULL, 'c' }, + { "filenames", no_argument, NULL, 'f' }, + { "json", no_argument, NULL, 'j' }, + { "mailboxes", no_argument, NULL, 'm' }, + { "pause", no_argument, NULL, 'p' }, + { "sqlite3", no_argument, NULL, 's' }, + { "stale", optional_argument, NULL, 't' }, + { "userids", no_argument, NULL, 'u' }, + { "verbose", no_argument, NULL, 'v' }, + { "wait-for-locks", no_argument, NULL, 'w' }, + { "execute", required_argument, NULL, 'x' }, + + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'A': if (options.mode != CTLBU_MODE_UNSPECIFIED) usage(); @@ -474,6 +508,11 @@ int main(int argc, char **argv) cyrus_init(alt_config, "ctl_backups", 0, 0); + if ((r = mboxname_init_namespace(&ctl_backups_namespace, NAMESPACE_OPTION_ADMIN))) { + fatal(error_message(r), EX_CONFIG); + } + mboxevent_setnamespace(&ctl_backups_namespace); + if (cmd == CTLBU_CMD_RECONSTRUCT) { /* special handling for reconstruct */ // FIXME @@ -543,7 +582,7 @@ int main(int argc, char **argv) if (options.mode == CTLBU_MODE_USERNAME) mbname = mbname_from_userid(argv[i]); else if (options.mode == CTLBU_MODE_MBOXNAME) - mbname = mbname_from_intname(argv[i]); + mbname = mbname_from_extname(argv[i], &ctl_backups_namespace, NULL); if (mbname) { r = backup_get_paths(mbname, &fname, NULL, BACKUP_OPEN_NOCREATE); @@ -555,7 +594,9 @@ int main(int argc, char **argv) mbname_free(&mbname); continue; } - buf_setcstr(&userid, mbname_userid(mbname)); + if (mbname_userid(mbname)) { + buf_setcstr(&userid, mbname_userid(mbname)); + } } else buf_setcstr(&fname, argv[i]); @@ -707,6 +748,8 @@ static int cmd_lock_one(void *rock, char *fname = NULL; int r = 0; + assert(data != NULL && data_len > 0); + /* input args might not be 0-terminated, so make a safe copy */ if (key_len) userid = xstrndup(key, key_len); @@ -817,12 +860,12 @@ static int cmd_stat_one(void *rock, if (r) goto done; } - const int retention_days = config_getint(IMAPOPT_BACKUP_RETENTION_DAYS); - if (retention_days > 0) { - since = time(0) - (retention_days * 24 * 60 * 60); + const int retention = config_getduration(IMAPOPT_BACKUP_RETENTION, 'd'); + if (retention > 0) { + since = time(0) - retention; } else { - /* zero or negative retention days means "keep forever" */ + /* zero or negative retention means "keep forever" */ since = -1; } diff --git a/backup/cyr_backup.c b/backup/cyr_backup.c index 7ad817ef41..f4f2672c4f 100644 --- a/backup/cyr_backup.c +++ b/backup/cyr_backup.c @@ -49,6 +49,7 @@ #include #include +#include #include #include #include @@ -68,10 +69,15 @@ #include "backup/backup.h" +static struct namespace cyr_backup_namespace; + EXPORTED void fatal(const char *error, int code) { fprintf(stderr, "fatal error: %s\n", error); cyrus_done(); + + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + exit(code); } @@ -261,7 +267,22 @@ int main(int argc, char **argv) mbname_t *mbname = NULL; int i, opt, r = 0; - while ((opt = getopt(argc, argv, "C:fmuv")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:fmuv"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "filename", no_argument, NULL, 'f' }, + { "mailbox", no_argument, NULL, 'm' }, + { "userid", no_argument, NULL, 'u' }, + { "verbose", no_argument, NULL, 'v' }, + + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': alt_config = optarg; @@ -346,6 +367,11 @@ int main(int argc, char **argv) cyrus_init(alt_config, "cyr_backup", 0, 0); + if ((r = mboxname_init_namespace(&cyr_backup_namespace, NAMESPACE_OPTION_ADMIN))) { + fatal(error_message(r), EX_CONFIG); + } + mboxevent_setnamespace(&cyr_backup_namespace); + /* use xmalloc rather than malloc for json internals */ json_set_alloc_funcs(xmalloc, free); @@ -356,7 +382,7 @@ int main(int argc, char **argv) BACKUP_OPEN_BLOCK, BACKUP_OPEN_NOCREATE); break; case CYRBU_MODE_MBOXNAME: - mbname = mbname_from_intname(backup_name); + mbname = mbname_from_extname(backup_name, &cyr_backup_namespace, NULL); if (!mbname) usage(); r = backup_open(&backup, mbname, BACKUP_OPEN_BLOCK, BACKUP_OPEN_NOCREATE); @@ -390,6 +416,7 @@ int main(int argc, char **argv) backup_close(&backup); /* clean up and exit */ + mbname_free(&mbname); backup_cleanup_staging_path(); cyrus_done(); @@ -538,7 +565,9 @@ static int cmd_show_mailboxes(struct backup *backup, /* or it could be an mboxname */ if (!mailbox) { - mbname_t *mbname = mbname_from_intname(arg); + mbname_t *mbname = mbname_from_extname(arg, + &cyr_backup_namespace, + NULL); if (!mbname) continue; mailbox = backup_get_mailbox_by_name(backup, mbname, BACKUP_MAILBOX_ALL_RECORDS); @@ -705,7 +734,6 @@ static int cmd_json_chunks(struct backup *backup, struct backup_chunk *chunk = NULL; json_t *jchunks = NULL; struct stat data_stat_buf; - double total_length = 0.0; int r; (void) options; @@ -734,8 +762,6 @@ static int cmd_json_chunks(struct backup *backup, ratio = 100.0 * (data_stat_buf.st_size - chunk->offset) / chunk->length; } - total_length += chunk->length; - /* XXX which fields do we want? */ json_object_set_new(jchunk, "id", json_integer(chunk->id)); json_object_set_new(jchunk, "offset", json_integer(chunk->offset)); @@ -766,7 +792,6 @@ static int json_mailbox_cb(const struct backup_mailbox *mailbox, void *rock) { json_t *jmailboxes = (json_t *) rock; json_t *jmailbox = json_object(); - json_t *jmessages = json_array(); char ts_last_appenddate[32] = "[unknown]"; strftime(ts_last_appenddate, sizeof(ts_last_appenddate), "%F %T", @@ -779,6 +804,7 @@ static int json_mailbox_cb(const struct backup_mailbox *mailbox, void *rock) if (mailbox->records && mailbox->records->count) { struct backup_mailbox_message *iter; + json_t *jmessages = json_array(); for (iter = mailbox->records->head; iter; iter = iter->next) { json_t *jrecord = json_object(); diff --git a/backup/lcb.c b/backup/lcb.c index 05c312d447..ed29dcce6e 100644 --- a/backup/lcb.c +++ b/backup/lcb.c @@ -60,6 +60,7 @@ #include "lib/xsha1.h" #include "lib/xstrlcat.h" #include "lib/xstrlcpy.h" +#include "lib/xunlink.h" #include "imap/dlist.h" #include "imap/global.h" @@ -71,6 +72,8 @@ #include "backup/lcb_internal.h" #include "backup/lcb_sqlconsts.h" +static const char *NOUSERID = "\%SHARED"; + /* remove this process's staging directory. * will warn about and clean up files that are hanging around - these should * be removed by dlist_unlink_files but may be missed if we're shutdown by a @@ -99,7 +102,7 @@ EXPORTED void backup_cleanup_staging_path(void) char *tmp = strconcat(name, "/", d->d_name, NULL); syslog(LOG_INFO, "%s: unlinking leftover stage file: %s", __func__, tmp); - unlink(tmp); + xunlink(tmp); free(tmp); } closedir(dirp); @@ -157,7 +160,8 @@ HIDDEN int backup_real_open(struct backup **backupp, r = IMAP_MAILBOX_NONEXISTENT; break; default: - syslog(LOG_ERR, "IOERROR: open %s: %m", backup->data_fname); + xsyslog(LOG_ERR, "IOERROR: open failed", + "filename=<%s>", backup->data_fname); r = IMAP_IOERROR; break; } @@ -171,7 +175,8 @@ HIDDEN int backup_real_open(struct backup **backupp, r = IMAP_MAILBOX_LOCKED; } else { - syslog(LOG_ERR, "IOERROR: lock_setlock: %s: %m", backup->data_fname); + xsyslog(LOG_ERR, "IOERROR: lock_setlock failed", + "filename=<%s>", backup->data_fname); r = IMAP_IOERROR; } goto error; @@ -180,7 +185,8 @@ HIDDEN int backup_real_open(struct backup **backupp, r = fstat(fd, &sbuf1); if (!r) r = stat(backup->data_fname, &sbuf2); if (r) { - syslog(LOG_ERR, "IOERROR: (f)stat %s: %m", backup->data_fname); + xsyslog(LOG_ERR, "IOERROR: stat failed", + "filename=<%s>", backup->data_fname); r = IMAP_IOERROR; close(fd); goto error; @@ -198,12 +204,14 @@ HIDDEN int backup_real_open(struct backup **backupp, // when reindexing, we want to move the old index out of the way // and create a new, empty one -- while holding the lock char oldindex_fname[PATH_MAX]; - snprintf(oldindex_fname, sizeof(oldindex_fname), "%s." INT64_FMT, + snprintf(oldindex_fname, sizeof(oldindex_fname), "%s.%" PRId64, backup->index_fname, (int64_t) time(NULL)); r = rename(backup->index_fname, oldindex_fname); if (r && errno != ENOENT) { - syslog(LOG_ERR, "IOERROR: rename %s %s: %m", backup->index_fname, oldindex_fname); + xsyslog(LOG_ERR, "IOERROR: rename failed", + "source=<%s> dest=<%s>", + backup->index_fname, oldindex_fname); r = IMAP_IOERROR; goto error; } @@ -216,7 +224,8 @@ HIDDEN int backup_real_open(struct backup **backupp, struct stat data_statbuf; r = fstat(backup->fd, &data_statbuf); if (r) { - syslog(LOG_ERR, "IOERROR: fstat %s: %m", backup->data_fname); + xsyslog(LOG_ERR, "IOERROR: fstat failed", + "filename=<%s>", backup->data_fname); r = IMAP_IOERROR; goto error; } @@ -224,13 +233,15 @@ HIDDEN int backup_real_open(struct backup **backupp, struct stat index_statbuf; r = stat(backup->index_fname, &index_statbuf); if (r && errno != ENOENT) { - syslog(LOG_ERR, "IOERROR: stat %s: %m", backup->index_fname); + xsyslog(LOG_ERR, "IOERROR: stat failed", + "filename=<%s>", backup->index_fname); r = IMAP_IOERROR; goto error; } if ((r && errno == ENOENT) || index_statbuf.st_size == 0) { - syslog(LOG_ERR, "reindex needed: %s", backup->index_fname); + xsyslog(LOG_ERR, "IOERROR: reindex needed", + "filename=<%s>", backup->index_fname); r = IMAP_MAILBOX_BADFORMAT; goto error; } @@ -299,6 +310,8 @@ static const char *_make_path(const mbname_t *mbname, int *out_fd) const char *partition = partlist_backup_select(); const char *ret = NULL; + if (!userid) userid = NOUSERID; + if (!partition) { syslog(LOG_ERR, "unable to make backup path for %s: " @@ -337,7 +350,7 @@ static const char *_make_path(const mbname_t *mbname, int *out_fd) syslog(LOG_ERR, "unable to make backup path for %s: path too long", userid); - unlink(template); + xunlink(template); goto error; } ret = pathresult; @@ -371,6 +384,8 @@ EXPORTED int backup_get_paths(const mbname_t *mbname, const char *backup_path = NULL; size_t path_len = 0; + if (!userid) userid = NOUSERID; + r = cyrusdb_fetch(backups_db, userid, strlen(userid), &backup_path, &path_len, @@ -396,7 +411,7 @@ EXPORTED int backup_get_paths(const mbname_t *mbname, /* if we didn't store it in the database successfully, trash the file, * it won't be used */ - if (r) unlink(backup_path); + if (r) xunlink(backup_path); } if (r) goto done; @@ -454,6 +469,8 @@ EXPORTED int backup_close(struct backup **backupp) gzFile gzfile = NULL; int r1 = 0, r2 = 0; + if (!backup) return 0; + if (backup->append_state) { if (backup->append_state->mode != BACKUP_APPEND_INACTIVE) r1 = backup_append_end(backup, NULL); @@ -473,7 +490,7 @@ EXPORTED int backup_close(struct backup **backupp) } else { if (!config_getswitch(IMAPOPT_BACKUP_KEEP_PREVIOUS)) { - unlink(backup->oldindex_fname); + xunlink(backup->oldindex_fname); } } } @@ -498,8 +515,8 @@ EXPORTED int backup_unlink(struct backup **backupp) { struct backup *backup = *backupp; - unlink(backup->index_fname); - unlink(backup->data_fname); + xunlink(backup->index_fname); + xunlink(backup->data_fname); return backup_close(backupp); } @@ -523,13 +540,15 @@ EXPORTED int backup_stat(const struct backup *backup, r = fstat(backup->fd, &data_statbuf); if (r) { - syslog(LOG_ERR, "IOERROR: fstat %s: %m", backup->data_fname); + xsyslog(LOG_ERR, "IOERROR: fstat failed", + "filename=<%s>", backup->data_fname); return IMAP_IOERROR; } r = stat(backup->index_fname, &index_statbuf); if (r) { - syslog(LOG_ERR, "IOERROR: stat %s: %m", backup->index_fname); + xsyslog(LOG_ERR, "IOERROR: stat failed", + "filename=<%s>", backup->index_fname); return IMAP_IOERROR; } @@ -547,7 +566,8 @@ static ssize_t _prot_fill_cb(unsigned char *buf, size_t len, void *rock) int r = gzuc_read(gzuc, buf, len); if (r < 0) - syslog(LOG_ERR, "IOERROR: gzuc_read returned %i", r); + xsyslog(LOG_ERR, "IOERROR: gzuc_read failed", + "return=<%d>", r); if (r < -1) errno = EIO; @@ -587,7 +607,6 @@ EXPORTED int backup_reindex(const char *name, fprintf(out, "\nfound chunk at offset " OFF_T_FMT "\n\n", member_offset); struct protstream *member = prot_readcb(_prot_fill_cb, gzuc); - prot_setisclient(member, 1); /* don't sync literals */ // FIXME stricter timestamp sequence checks time_t member_start_ts = -1; @@ -602,11 +621,11 @@ EXPORTED int backup_reindex(const char *name, const char *error = prot_error(member); if (error && 0 != strcmp(error, PROT_EOF_STRING)) { syslog(LOG_ERR, - "IOERROR: %s: error reading chunk at offset " OFF_T_FMT ", byte %i: %s\n", + "IOERROR: %s: error reading chunk at offset " OFF_T_FMT ", byte %" PRIu64 ": %s", name, member_offset, prot_bytes_in(member), error); if (out) - fprintf(out, "error reading chunk at offset " OFF_T_FMT ", byte %i: %s\n", + fprintf(out, "error reading chunk at offset " OFF_T_FMT ", byte %" PRIu64 ": %s\n", member_offset, prot_bytes_in(member), error); r = IMAP_IOERROR; @@ -639,7 +658,7 @@ EXPORTED int backup_reindex(const char *name, r = backup_append(backup, dl, &ts, BACKUP_APPEND_NOFLUSH); if (r) { // FIXME do something - syslog(LOG_ERR, "backup_append returned %d\n", r); + syslog(LOG_ERR, "backup_append returned %d", r); fprintf(out, "backup_append returned %d\n", r); } @@ -701,6 +720,9 @@ EXPORTED int backup_rename(const mbname_t *old_mbname, const mbname_t *new_mbnam size_t path_len; int r; + if (!old.userid) old.userid = NOUSERID; + if (!new.userid) new.userid = NOUSERID; + /* bail out if the names are the same */ if (strcmp(old.userid, new.userid) == 0) return 0; @@ -730,7 +752,8 @@ EXPORTED int backup_rename(const mbname_t *old_mbname, const mbname_t *new_mbnam O_RDWR | O_APPEND, /* no O_CREAT */ S_IRUSR | S_IWUSR); if (old.fd < 0) { - syslog(LOG_ERR, "IOERROR: open %s: %m", old.fname); + xsyslog(LOG_ERR, "IOERROR: open failed", + "filename=<%s>", old.fname); r = -1; goto error; } @@ -738,7 +761,8 @@ EXPORTED int backup_rename(const mbname_t *old_mbname, const mbname_t *new_mbnam /* non-blocking, to avoid deadlock */ r = lock_setlock(old.fd, /*excl*/ 1, /*nb*/ 1, old.fname); if (r) { - syslog(LOG_ERR, "IOERROR: lock_setlock: %s: %m", old.fname); + xsyslog(LOG_ERR, "IOERROR: lock_setlock failed", + "filename=<%s>", old.fname); goto error; } @@ -772,9 +796,9 @@ EXPORTED int backup_rename(const mbname_t *old_mbname, const mbname_t *new_mbnam if (r) goto error; // FIXME log /* database update succeeded. unlink old names */ - unlink(old.fname); + xunlink(old.fname); *old.ext_ptr = '.'; - unlink(old.fname); + xunlink(old.fname); *old.ext_ptr = '\0'; /* unlock and close backup files */ @@ -794,9 +818,9 @@ EXPORTED int backup_rename(const mbname_t *old_mbname, const mbname_t *new_mbnam error: /* we didn't finish, so unlink the new filenames if we got that far */ if (new.fname) { - unlink(new.fname); + xunlink(new.fname); *new.ext_ptr = '.'; - unlink(new.fname); + xunlink(new.fname); *new.ext_ptr = '\0'; } diff --git a/backup/lcb_append.c b/backup/lcb_append.c index 538cfa8edb..7df99eb33c 100644 --- a/backup/lcb_append.c +++ b/backup/lcb_append.c @@ -76,7 +76,9 @@ static int retry_gzwrite(gzFile gzfile, const char *str, size_t len, const char else { int r; const char *err = gzerror(gzfile, &r); - syslog(LOG_ERR, "IOERROR: %s gzwrite %s: %s", __func__, fname, err); + xsyslog(LOG_ERR, "IOERROR: gzwrite failed", + "filename=<%s> error=<%s>", + fname, err); if (r == Z_STREAM_ERROR) fatal("gzwrite: invalid stream", EX_IOERR); @@ -109,7 +111,7 @@ HIDDEN int backup_real_append_start(struct backup *backup, if (index_only) backup->append_state->mode |= BACKUP_APPEND_INDEXONLY; backup->append_state->wrote = 0; - SHA1_Init(&backup->append_state->sha_ctx); + SHA1Init(&backup->append_state->sha_ctx); char header[80]; snprintf(header, sizeof(header), "# cyrus backup: chunk start\r\n"); @@ -132,7 +134,7 @@ HIDDEN int backup_real_append_start(struct backup *backup, if (r) goto error; } - SHA1_Update(&backup->append_state->sha_ctx, header, strlen(header)); + SHA1Update(&backup->append_state->sha_ctx, header, strlen(header)); backup->append_state->wrote += strlen(header); struct sqldb_bindval bval[] = { @@ -147,7 +149,7 @@ HIDDEN int backup_real_append_start(struct backup *backup, r = sqldb_exec(backup->db, backup_index_start_sql, bval, NULL, NULL); if (r) { - syslog(LOG_ERR, "%s: something went wrong: %i\n", __func__, r); + syslog(LOG_ERR, "%s: something went wrong: %i", __func__, r); sqldb_rollback(backup->db, "backup_append"); goto error; } @@ -192,13 +194,13 @@ EXPORTED int backup_append(struct backup *backup, int r; /* preload buffer with timestamp preamble */ - buf_printf(&buf, INT64_FMT " APPLY ", (int64_t) ts); + buf_printf(&buf, "%" PRId64 " APPLY ", (int64_t) ts); /* iterate over the dlist */ iter = dlist_print_iter_new(dlist, 1); do { /* track the sha1sum */ - SHA1_Update(&backup->append_state->sha_ctx, buf_cstring(&buf), buf_len(&buf)); + SHA1Update(&backup->append_state->sha_ctx, buf_cstring(&buf), buf_len(&buf)); /* if we're not in index-only mode, write the data out */ if (!index_only) { @@ -216,7 +218,7 @@ EXPORTED int backup_append(struct backup *backup, /* finally, end with "\r\n" */ buf_setcstr(&buf, "\r\n"); - SHA1_Update(&backup->append_state->sha_ctx, buf_cstring(&buf), buf_len(&buf)); + SHA1Update(&backup->append_state->sha_ctx, buf_cstring(&buf), buf_len(&buf)); if (!index_only) { r = retry_gzwrite(backup->append_state->gzfile, buf_cstring(&buf), buf_len(&buf), @@ -242,6 +244,7 @@ EXPORTED int backup_append(struct backup *backup, error: buf_free(&buf); + if (iter) dlist_print_iter_free(&iter); return IMAP_INTERNAL; } @@ -257,7 +260,7 @@ HIDDEN int backup_real_append_end(struct backup *backup, time_t ts) if (!(backup->append_state->mode & BACKUP_APPEND_INDEXONLY)) { r = gzflush(backup->append_state->gzfile, Z_FINISH); if (r != Z_OK) { - syslog(LOG_ERR, "IOERROR: gzflush %s failed: %i\n", + syslog(LOG_ERR, "IOERROR: gzflush %s failed: %i", backup->data_fname, r); sqldb_rollback(backup->db, "backup_append"); goto done; @@ -266,7 +269,7 @@ HIDDEN int backup_real_append_end(struct backup *backup, time_t ts) unsigned char sha1_raw[SHA1_DIGEST_LENGTH]; char data_sha1[2 * SHA1_DIGEST_LENGTH + 1]; - SHA1_Final(sha1_raw, &backup->append_state->sha_ctx); + SHA1Final(sha1_raw, &backup->append_state->sha_ctx); r = bin_to_hex(sha1_raw, SHA1_DIGEST_LENGTH, data_sha1, BH_LOWER); assert(r == 2 * SHA1_DIGEST_LENGTH); @@ -280,7 +283,7 @@ HIDDEN int backup_real_append_end(struct backup *backup, time_t ts) r = sqldb_exec(backup->db, backup_index_end_sql, bval, NULL, NULL); if (r) { - syslog(LOG_ERR, "%s: something went wrong: %i\n", __func__, r); + syslog(LOG_ERR, "%s: something went wrong: %i", __func__, r); sqldb_rollback(backup->db, "backup_append"); } else { diff --git a/backup/lcb_compact.c b/backup/lcb_compact.c index f8fd11a9e6..ec23bafa2a 100644 --- a/backup/lcb_compact.c +++ b/backup/lcb_compact.c @@ -48,6 +48,7 @@ #include "lib/gzuncat.h" #include "lib/libconfig.h" +#include "lib/xunlink.h" #include "imap/imap_err.h" #include "imap/sync_support.h" @@ -67,12 +68,12 @@ static void compact_readconfig(void) /* read and normalise config values */ if (compact_minsize == 0) { compact_minsize = (size_t) - MAX(0, 1024 * config_getint(IMAPOPT_BACKUP_COMPACT_MINSIZE)); + MAX(0, config_getbytesize(IMAPOPT_BACKUP_COMPACT_MINSIZE, 'K')); } if (compact_maxsize == 0) { compact_maxsize = (size_t) - MAX(0, 1024 * config_getint(IMAPOPT_BACKUP_COMPACT_MAXSIZE)); + MAX(0, config_getbytesize(IMAPOPT_BACKUP_COMPACT_MAXSIZE, 'K')); } if (compact_work_threshold == 0) { @@ -142,8 +143,8 @@ static int compact_closerename(struct backup **originalp, struct buf ts_index_fname = BUF_INITIALIZER; int r; - buf_printf(&ts_data_fname, "%s.%ld", original->data_fname, now); - buf_printf(&ts_index_fname, "%s.%ld", original->index_fname, now); + buf_printf(&ts_data_fname, "%s." TIME_T_FMT, original->data_fname, now); + buf_printf(&ts_index_fname, "%s." TIME_T_FMT, original->index_fname, now); /* link original files into timestamped names */ r = link(original->data_fname, buf_cstring(&ts_data_fname)); @@ -151,8 +152,8 @@ static int compact_closerename(struct backup **originalp, if (r) { /* on error, trash the new links and bail out */ - unlink(buf_cstring(&ts_data_fname)); - unlink(buf_cstring(&ts_index_fname)); + xunlink(buf_cstring(&ts_data_fname)); + xunlink(buf_cstring(&ts_index_fname)); goto done; } @@ -162,19 +163,25 @@ static int compact_closerename(struct backup **originalp, if (r) { /* on error, put original files back */ - unlink(original->data_fname); - unlink(original->index_fname); + xunlink(original->data_fname); + xunlink(original->index_fname); if (link(buf_cstring(&ts_data_fname), original->data_fname)) - syslog(LOG_ERR, "IOERROR: failed to link file back (%s %s)!", buf_cstring(&ts_data_fname), original->data_fname); + xsyslog(LOG_ERR, "IOERROR: failed to link file back!", + "source=<%s> dest=<%s>", + buf_cstring(&ts_data_fname), + original->data_fname); if (link(buf_cstring(&ts_index_fname), original->index_fname)) - syslog(LOG_ERR, "IOERROR: failed to link file back (%s %s)!", buf_cstring(&ts_index_fname), original->index_fname); + xsyslog(LOG_ERR, "IOERROR: failed to link file back!", + "source=<%s> dest=<%s>", + buf_cstring(&ts_index_fname), + original->index_fname); goto done; } /* finally, clean up the timestamped ones */ if (!config_getswitch(IMAPOPT_BACKUP_KEEP_PREVIOUS)) { - unlink(buf_cstring(&ts_data_fname)); - unlink(buf_cstring(&ts_index_fname)); + xunlink(buf_cstring(&ts_data_fname)); + xunlink(buf_cstring(&ts_index_fname)); } /* release our locks */ @@ -419,7 +426,8 @@ static ssize_t _prot_fill_cb(unsigned char *buf, size_t len, void *rock) int r = gzuc_read(gzuc, buf, len); if (r < 0) - syslog(LOG_ERR, "IOERROR: gzuc_read returned %i", r); + xsyslog(LOG_ERR, "IOERROR: gzuc_read failed", + "return=<%d>", r); if (r < -1) errno = EIO; @@ -462,12 +470,12 @@ EXPORTED int backup_compact(const char *name, /* calculate current time after obtaining locks, in case of a wait */ const time_t now = time(NULL); - const int retention_days = config_getint(IMAPOPT_BACKUP_RETENTION_DAYS); - if (retention_days > 0) { - since = now - (retention_days * 24 * 60 * 60); + const int retention = config_getduration(IMAPOPT_BACKUP_RETENTION, 'd'); + if (retention > 0) { + since = now - retention; } else { - /* zero or negative retention days means "keep forever" */ + /* zero or negative retention means "keep forever" */ since = -1; } @@ -521,11 +529,11 @@ EXPORTED int backup_compact(const char *name, const char *error = prot_error(in); if (error && 0 != strcmp(error, PROT_EOF_STRING)) { syslog(LOG_ERR, - "IOERROR: %s: error reading chunk at offset " OFF_T_FMT ", byte %i: %s\n", + "IOERROR: %s: error reading chunk at offset " OFF_T_FMT ", byte %" PRIu64 ": %s", name, chunk->offset, prot_bytes_in(in), error); if (out) - fprintf(out, "error reading chunk at offset " OFF_T_FMT ", byte %i: %s\n", + fprintf(out, "error reading chunk at offset " OFF_T_FMT ", byte %" PRIu64 ": %s\n", chunk->offset, prot_bytes_in(in), error); /* chunk is corrupt, discard the rest of it and get on with diff --git a/backup/lcb_indexr.c b/backup/lcb_indexr.c index 8c00cb6b23..81a2a6d660 100644 --- a/backup/lcb_indexr.c +++ b/backup/lcb_indexr.c @@ -94,7 +94,7 @@ EXPORTED int backup_get_mailbox_id(struct backup *backup, const char *uniqueid) int r = sqldb_exec(backup->db, backup_index_mailbox_select_uniqueid_sql, bval, _get_mailbox_id_cb, &id); if (r) { - syslog(LOG_ERR, "%s: something went wrong: %i %s\n", + syslog(LOG_ERR, "%s: something went wrong: %i %s", __func__, r, uniqueid); } @@ -784,7 +784,7 @@ EXPORTED int backup_get_message_id(struct backup *backup, const char *guid) int r = sqldb_exec(backup->db, backup_index_message_select_guid_sql, bval, _get_message_id_cb, &id); if (r) { - syslog(LOG_ERR, "%s: something went wrong: %i %s\n", + syslog(LOG_ERR, "%s: something went wrong: %i %s", __func__, r, guid); return -1; } @@ -857,7 +857,7 @@ EXPORTED struct backup_message *backup_get_message(struct backup *backup, int r = sqldb_exec(backup->db, backup_index_message_select_guid_sql, bval, _message_row_cb, &mrock); if (r) { - syslog(LOG_ERR, "%s: something went wrong: %i %s\n", + syslog(LOG_ERR, "%s: something went wrong: %i %s", __func__, r, message_guid_encode(guid)); if (bm) backup_message_free(&bm); return NULL; diff --git a/backup/lcb_indexw.c b/backup/lcb_indexw.c index 002c824de9..e8d26a485b 100644 --- a/backup/lcb_indexw.c +++ b/backup/lcb_indexw.c @@ -101,7 +101,7 @@ HIDDEN int backup_index(struct backup *backup, struct dlist *dlist, else if (config_debug) { struct buf tmp = BUF_INITIALIZER; dlist_printbuf(dlist, 1, &tmp); - syslog(LOG_DEBUG, "ignoring unrecognised dlist: %s\n", buf_cstring(&tmp)); + syslog(LOG_DEBUG, "ignoring unrecognised dlist: %s", buf_cstring(&tmp)); buf_free(&tmp); } @@ -111,7 +111,7 @@ HIDDEN int backup_index(struct backup *backup, struct dlist *dlist, static int _index_expunge(struct backup *backup, struct dlist *dl, time_t ts, off_t dl_offset) { - syslog(LOG_DEBUG, "indexing EXPUNGE at " OFF_T_FMT "...\n", dl_offset); + syslog(LOG_DEBUG, "indexing EXPUNGE at " OFF_T_FMT "...", dl_offset); const char *mboxname; const char *uniqueid; @@ -184,7 +184,7 @@ static int _get_magic_flags(struct dlist *flags, int *is_expunged) static int _index_mailbox(struct backup *backup, struct dlist *dl, time_t ts, off_t dl_offset) { - syslog(LOG_DEBUG, "indexing MAILBOX at " OFF_T_FMT "...\n", dl_offset); + syslog(LOG_DEBUG, "indexing MAILBOX at " OFF_T_FMT "...", dl_offset); const char *uniqueid = NULL; const char *mboxname = NULL; @@ -285,12 +285,12 @@ static int _index_mailbox(struct backup *backup, struct dlist *dl, r = sqldb_exec(backup->db, backup_index_mailbox_insert_sql, mbox_bval, NULL, NULL); if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i insert %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i insert %s", __func__, r, mboxname); } } else if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i update %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i update %s", __func__, r, mboxname); } @@ -329,7 +329,7 @@ static int _index_mailbox(struct backup *backup, struct dlist *dl, /* XXX should this search for guid+size rather than just guid? */ message_id = backup_get_message_id(backup, guid); if (message_id == -1) { - syslog(LOG_DEBUG, "%s: something went wrong: %i %s %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i %s %s", __func__, r, mboxname, guid); goto error; } @@ -337,7 +337,7 @@ static int _index_mailbox(struct backup *backup, struct dlist *dl, /* can't link this record, we don't have a message */ /* possibly we're in compact, and have deleted it? */ /* XXX this should probably be an error too, but can't be until compact is smarter */ - syslog(LOG_DEBUG, "%s: skipping %s record for %s: message not found\n", + syslog(LOG_DEBUG, "%s: skipping %s record for %s: message not found", __func__, mboxname, guid); continue; } @@ -349,13 +349,13 @@ static int _index_mailbox(struct backup *backup, struct dlist *dl, _get_magic_flags(flags, &is_expunged); if (is_expunged) { - syslog(LOG_DEBUG, "%s: found expunge flag for message %s\n", + syslog(LOG_DEBUG, "%s: found expunge flag for message %s", __func__, guid); expunged = ts; } dlist_printbuf(flags, 0, &flags_buf); - syslog(LOG_DEBUG, "%s: found flags for message %s: %s\n", + syslog(LOG_DEBUG, "%s: found flags for message %s: %s", __func__, guid, buf_cstring(&flags_buf)); } @@ -393,12 +393,12 @@ static int _index_mailbox(struct backup *backup, struct dlist *dl, r = sqldb_exec(backup->db, backup_index_mailbox_message_insert_sql, record_bval, NULL, NULL); if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i insert %s %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i insert %s %s", __func__, r, mboxname, guid); } } else if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i update %s %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i update %s %s", __func__, r, mboxname, guid); } @@ -409,12 +409,12 @@ static int _index_mailbox(struct backup *backup, struct dlist *dl, } } - syslog(LOG_DEBUG, "%s: committing index change: %s\n", __func__, mboxname); + syslog(LOG_DEBUG, "%s: committing index change: %s", __func__, mboxname); sqldb_commit(backup->db, __func__); return 0; error: - syslog(LOG_DEBUG, "%s: rolling back index change: %s\n", __func__, mboxname); + syslog(LOG_DEBUG, "%s: rolling back index change: %s", __func__, mboxname); sqldb_rollback(backup->db, __func__); return IMAP_INTERNAL; @@ -423,7 +423,7 @@ static int _index_mailbox(struct backup *backup, struct dlist *dl, static int _index_unmailbox(struct backup *backup, struct dlist *dl, time_t ts, off_t dl_offset) { - syslog(LOG_DEBUG, "indexing UNMAILBOX at " OFF_T_FMT "...\n", dl_offset); + syslog(LOG_DEBUG, "indexing UNMAILBOX at " OFF_T_FMT "...", dl_offset); const char *mboxname = dl->sval; @@ -436,7 +436,7 @@ static int _index_unmailbox(struct backup *backup, struct dlist *dl, int r = sqldb_exec(backup->db, backup_index_mailbox_delete_sql, bval, NULL, NULL); if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i %s", __func__, r, mboxname); } @@ -446,7 +446,7 @@ static int _index_unmailbox(struct backup *backup, struct dlist *dl, static int _index_message(struct backup *backup, struct dlist *dl, time_t ts, off_t dl_offset, size_t dl_len) { - syslog(LOG_DEBUG, "indexing MESSAGE at " OFF_T_FMT " (" SIZE_T_FMT " bytes)...\n", dl_offset, dl_len); + syslog(LOG_DEBUG, "indexing MESSAGE at " OFF_T_FMT " (" SIZE_T_FMT " bytes)...", dl_offset, dl_len); (void) ts; struct dlist *di; @@ -477,12 +477,12 @@ static int _index_message(struct backup *backup, struct dlist *dl, r = sqldb_exec(backup->db, backup_index_message_insert_sql, bval, NULL, NULL); if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i insert message %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i insert message %s", __func__, r, message_guid_encode(guid)); } } else if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i update message %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i update message %s", __func__, r, message_guid_encode(guid)); } } @@ -493,7 +493,7 @@ static int _index_message(struct backup *backup, struct dlist *dl, static int _index_rename(struct backup *backup, struct dlist *dl, time_t ts, off_t dl_offset) { - syslog(LOG_DEBUG, "indexing RENAME at " OFF_T_FMT "\n", dl_offset); + syslog(LOG_DEBUG, "indexing RENAME at " OFF_T_FMT, dl_offset); (void) ts; const char *uniqueid = NULL; @@ -529,7 +529,7 @@ static int _index_rename(struct backup *backup, struct dlist *dl, mbox_bval, NULL, NULL); if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i rename %s => %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i rename %s => %s", __func__, r, oldmboxname, newmboxname); } @@ -539,7 +539,7 @@ static int _index_rename(struct backup *backup, struct dlist *dl, static int _index_seen(struct backup *backup, struct dlist *dl, time_t ts, off_t dl_offset) { - syslog(LOG_DEBUG, "indexing %s at " OFF_T_FMT "\n", dl->name, dl_offset); + syslog(LOG_DEBUG, "indexing %s at " OFF_T_FMT, dl->name, dl_offset); (void) ts; const char *uniqueid; @@ -577,7 +577,7 @@ static int _index_seen(struct backup *backup, struct dlist *dl, r = sqldb_exec(backup->db, backup_index_seen_insert_sql, bval, NULL, NULL); if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i insert seen %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i insert seen %s", __func__, r, uniqueid); } } @@ -592,7 +592,7 @@ static int _index_seen(struct backup *backup, struct dlist *dl, static int _index_sub(struct backup *backup, struct dlist *dl, time_t ts, off_t dl_offset) { - syslog(LOG_DEBUG, "indexing %s at " OFF_T_FMT "\n", dl->name, dl_offset); + syslog(LOG_DEBUG, "indexing %s at " OFF_T_FMT, dl->name, dl_offset); const char *mboxname = NULL; int r; @@ -609,7 +609,7 @@ static int _index_sub(struct backup *backup, struct dlist *dl, /* set the unsubscribed time if this is an UNSUB */ if (!strcmp(dl->name, "UNSUB")) { - syslog(LOG_DEBUG, "setting unsubscribed to %ld for %s", ts, mboxname); + syslog(LOG_DEBUG, "setting unsubscribed to " TIME_T_FMT " for %s", ts, mboxname); struct sqldb_bindval *unsubscribed_bval = &bval[2]; assert(strcmp(unsubscribed_bval->name, ":unsubscribed") == 0); unsubscribed_bval->type = SQLITE_INTEGER; @@ -623,7 +623,7 @@ static int _index_sub(struct backup *backup, struct dlist *dl, r = sqldb_exec(backup->db, backup_index_subscription_insert_sql, bval, NULL, NULL); if (r) { - syslog(LOG_DEBUG, "%s: something went wrong: %i insert subscription %s\n", + syslog(LOG_DEBUG, "%s: something went wrong: %i insert subscription %s", __func__, r, mboxname); } } @@ -638,7 +638,7 @@ static int _index_sub(struct backup *backup, struct dlist *dl, static int _index_sieve(struct backup *backup, struct dlist *dl, time_t ts, off_t dl_offset) { - syslog(LOG_DEBUG, "indexing %s at " OFF_T_FMT "\n", dl->name, dl_offset); + syslog(LOG_DEBUG, "indexing %s at " OFF_T_FMT, dl->name, dl_offset); const char *filename; int r; diff --git a/backup/lcb_internal.c b/backup/lcb_internal.c index eb32f6160c..9505ce9b68 100644 --- a/backup/lcb_internal.c +++ b/backup/lcb_internal.c @@ -77,7 +77,7 @@ HIDDEN int parse_backup_line(struct protstream *in, time_t *ts, if (c == EOF) goto fail; - c = dlist_parse(&dl, /*parsekeys*/ 1, 1, in); + c = dlist_parse(&dl, /*parsekeys*/ 1, /*isarchive*/ 0, 1, in); if (!dl) { fprintf(stderr, "\ndidn't parse dlist, error %i\n", c); diff --git a/backup/lcb_internal.h b/backup/lcb_internal.h index ca07dca45a..1581daf0cf 100644 --- a/backup/lcb_internal.h +++ b/backup/lcb_internal.h @@ -63,7 +63,7 @@ struct backup_append_state { gzFile gzfile; int chunk_id; size_t wrote; - SHA_CTX sha_ctx; + SHA1_CTX sha_ctx; }; struct backup { diff --git a/backup/lcb_read.c b/backup/lcb_read.c index e183c26823..f2342d69d0 100644 --- a/backup/lcb_read.c +++ b/backup/lcb_read.c @@ -113,7 +113,6 @@ EXPORTED int backup_read_message_data(struct backup *backup, if (r) return r; struct protstream *ps = prot_readcb(_prot_fill_cb, gzuc); - prot_setisclient(ps, 1); /* don't sync literals */ r = parse_backup_line(ps, NULL, NULL, &dl); prot_free(ps); @@ -135,7 +134,7 @@ EXPORTED int backup_read_message_data(struct backup *backup, if (fd != -1) { struct buf buf = BUF_INITIALIZER; - buf_init_mmap(&buf, 1, fd, fname, MAP_UNKNOWN_LEN, NULL); + buf_refresh_mmap(&buf, 1, fd, fname, MAP_UNKNOWN_LEN, NULL); close(fd); r = proc(&buf, rock); @@ -203,15 +202,15 @@ EXPORTED int backup_prepare_message_upload(struct backup *backup, if (!r) { struct protstream *ps = prot_readcb(_prot_fill_cb, gzuc); int c; - prot_setisclient(ps, 1); /* don't sync literals */ c = parse_backup_line(ps, NULL, NULL, &dl); prot_free(ps); ps = NULL; if (c == EOF) { - syslog(LOG_ERR, "IOERROR: couldn't parse message %s from chunk %d of backup %s", - message_guid_encode(&msgid->guid), - chunk->id, - backup->data_fname); + xsyslog(LOG_ERR, "IOERROR: parse_backup_line failed", + "guid=<%s> chunk=<%d> backup=<%s>", + message_guid_encode(&msgid->guid), + chunk->id, + backup->data_fname); r = IMAP_IOERROR; } } diff --git a/backup/lcb_verify.c b/backup/lcb_verify.c index 45a08bb66d..347b9d37dc 100644 --- a/backup/lcb_verify.c +++ b/backup/lcb_verify.c @@ -128,7 +128,7 @@ static int verify_chunk_checksums(struct backup *backup, struct backup_chunk *ch sha1_file(backup->fd, backup->data_fname, chunk->offset, file_sha1); r = strncmp(chunk->file_sha1, file_sha1, sizeof(file_sha1)); if (r) { - syslog(LOG_DEBUG, "%s: %s (chunk %d) file checksum mismatch: %s on disk, %s in index\n", + syslog(LOG_DEBUG, "%s: %s (chunk %d) file checksum mismatch: %s on disk, %s in index", __func__, backup->data_fname, chunk->id, file_sha1, chunk->file_sha1); if (out) fprintf(out, "file checksum mismatch for chunk %d: %s on disk, %s in index\n", @@ -143,13 +143,13 @@ static int verify_chunk_checksums(struct backup *backup, struct backup_chunk *ch fprintf(out, " checking data length\n"); char buf[8192]; /* FIXME whatever */ size_t len = 0; - SHA_CTX sha_ctx; - SHA1_Init(&sha_ctx); + SHA1_CTX sha_ctx; + SHA1Init(&sha_ctx); gzuc_member_start_from(gzuc, chunk->offset); while (!gzuc_member_eof(gzuc)) { ssize_t n = gzuc_read(gzuc, buf, sizeof(buf)); if (n >= 0) { - SHA1_Update(&sha_ctx, buf, n); + SHA1Update(&sha_ctx, buf, n); len += n; } } @@ -157,7 +157,7 @@ static int verify_chunk_checksums(struct backup *backup, struct backup_chunk *ch if (len != chunk->length) { syslog(LOG_DEBUG, "%s: %s (chunk %d) data length mismatch: " SIZE_T_FMT " on disk," - SIZE_T_FMT " in index\n", + SIZE_T_FMT " in index", __func__, backup->data_fname, chunk->id, len, chunk->length); if (out) fprintf(out, "data length mismatch for chunk %d: " @@ -172,12 +172,12 @@ static int verify_chunk_checksums(struct backup *backup, struct backup_chunk *ch fprintf(out, " checking data checksum...\n"); unsigned char sha1_raw[SHA1_DIGEST_LENGTH]; char data_sha1[2 * SHA1_DIGEST_LENGTH + 1]; - SHA1_Final(sha1_raw, &sha_ctx); + SHA1Final(sha1_raw, &sha_ctx); r = bin_to_hex(sha1_raw, SHA1_DIGEST_LENGTH, data_sha1, BH_LOWER); assert(r == 2 * SHA1_DIGEST_LENGTH); r = strncmp(chunk->data_sha1, data_sha1, sizeof(data_sha1)); if (r) { - syslog(LOG_DEBUG, "%s: %s (chunk %d) data checksum mismatch: %s on disk, %s in index\n", + syslog(LOG_DEBUG, "%s: %s (chunk %d) data checksum mismatch: %s on disk, %s in index", __func__, backup->data_fname, chunk->id, data_sha1, chunk->data_sha1); if (out) fprintf(out, "data checksum mismatch for chunk %d: %s on disk, %s in index\n", @@ -186,7 +186,7 @@ static int verify_chunk_checksums(struct backup *backup, struct backup_chunk *ch } done: - syslog(LOG_DEBUG, "%s: checksum %s!\n", __func__, r ? "failed" : "passed"); + syslog(LOG_DEBUG, "%s: checksum %s!", __func__, r ? "failed" : "passed"); if (out && verbose) fprintf(out, "%s\n", r ? "error" : "ok"); return r; @@ -228,17 +228,16 @@ static int _verify_message_cb(const struct backup_message *message, void *rock) if (r) return r; struct protstream *ps = prot_readcb(_prot_fill_cb, vmrock->gzuc); - prot_setisclient(ps, 1); /* don't sync literals */ r = parse_backup_line(ps, NULL, NULL, &dl); if (r == EOF) { const char *error = prot_error(ps); if (error && 0 != strcmp(error, PROT_EOF_STRING)) { syslog(LOG_ERR, - "%s: error reading message %i at offset " OFF_T_FMT ", byte %i: %s", + "%s: error reading message %i at offset " OFF_T_FMT ", byte %" PRIu64 ": %s", __func__, message->id, message->offset, prot_bytes_in(ps), error); if (out) - fprintf(out, "error reading message %i at offset " OFF_T_FMT ", byte %i: %s", + fprintf(out, "error reading message %i at offset " OFF_T_FMT ", byte %" PRIu64 ": %s", message->id, message->offset, prot_bytes_in(ps), error); } prot_free(ps); @@ -286,7 +285,8 @@ static int _verify_message_cb(const struct backup_message *message, void *rock) close(fd); } else { - syslog(LOG_ERR, "IOERROR: %s open %s: %m", __func__, fname); + xsyslog(LOG_ERR, "IOERROR: open failed", + "filename=<%s>", fname); if (out) fprintf(out, "error reading staging file for message %i\n", message->id); r = -1; @@ -330,7 +330,7 @@ static int verify_chunk_messages(struct backup *backup, struct backup_chunk *chu dlist_free(&vmrock.cached_dlist); } - syslog(LOG_DEBUG, "%s: chunk %d %s!\n", __func__, chunk->id, + syslog(LOG_DEBUG, "%s: chunk %d %s!", __func__, chunk->id, r ? "failed" : "passed"); if (out && verbose) fprintf(out, "%s\n", r ? "error" : "ok"); @@ -407,7 +407,7 @@ static int mailbox_matches(const struct backup_mailbox *mailbox, if (synccrcs.annot != mailbox->sync_crc_annot) return 0; - syslog(LOG_DEBUG, "%s: %s matches!\n", __func__, mailbox->uniqueid); + syslog(LOG_DEBUG, "%s: %s matches!", __func__, mailbox->uniqueid); return 1; } @@ -440,7 +440,7 @@ static int mailbox_message_matches(const struct backup_mailbox_message *mailbox_ || !message_guid_equal(guid, &mailbox_message->guid)) return 0; - syslog(LOG_DEBUG, "%s: %s:%u matches!\n", __func__, + syslog(LOG_DEBUG, "%s: %s:%u matches!", __func__, mailbox_message->mailbox_uniqueid, mailbox_message->uid); return 1; } @@ -527,7 +527,6 @@ static int verify_chunk_mailbox_links(struct backup *backup, struct backup_chunk goto done; } struct protstream *ps = prot_readcb(_prot_fill_cb, gzuc); - prot_setisclient(ps, 1); /* don't sync literals */ struct buf cmd = BUF_INITIALIZER; while (1) { @@ -541,10 +540,10 @@ static int verify_chunk_mailbox_links(struct backup *backup, struct backup_chunk const char *error = prot_error(ps); if (error && 0 != strcmp(error, PROT_EOF_STRING)) { syslog(LOG_ERR, - "%s: error reading chunk %i data at offset " OFF_T_FMT ", byte %i: %s", + "%s: error reading chunk %i data at offset " OFF_T_FMT ", byte %" PRIu64 ": %s", __func__, chunk->id, chunk->offset, prot_bytes_in(ps), error); if (out) - fprintf(out, "error reading chunk %i data at offset " OFF_T_FMT ", byte %i: %s", + fprintf(out, "error reading chunk %i data at offset " OFF_T_FMT ", byte %" PRIu64 ": %s", chunk->id, chunk->offset, prot_bytes_in(ps), error); r = EOF; } @@ -611,7 +610,7 @@ static int verify_chunk_mailbox_links(struct backup *backup, struct backup_chunk /* anything left in either of the lists is missing from the chunk data. bad! */ mailbox = mailbox_list->head; while (mailbox) { - syslog(LOG_DEBUG, "%s: chunk %d missing mailbox data for %s (%s)\n", + syslog(LOG_DEBUG, "%s: chunk %d missing mailbox data for %s (%s)", __func__, chunk->id, mailbox->uniqueid, mailbox->mboxname); if (out) fprintf(out, "chunk %d missing mailbox data for %s (%s)\n", @@ -621,7 +620,7 @@ static int verify_chunk_mailbox_links(struct backup *backup, struct backup_chunk mailbox_message = mailbox_message_list->head; while (mailbox_message) { - syslog(LOG_DEBUG, "%s: chunk %d missing mailbox_message data for %s uid %u\n", + syslog(LOG_DEBUG, "%s: chunk %d missing mailbox_message data for %s uid %u", __func__, chunk->id, mailbox_message->mailbox_uniqueid, mailbox_message->uid); if (out) @@ -643,7 +642,7 @@ static int verify_chunk_mailbox_links(struct backup *backup, struct backup_chunk backup_mailbox_message_list_empty(mailbox_message_list); free(mailbox_message_list); - syslog(LOG_DEBUG, "%s: chunk %d %s!\n", __func__, chunk->id, + syslog(LOG_DEBUG, "%s: chunk %d %s!", __func__, chunk->id, r ? "failed" : "passed"); if (out && verbose) fprintf(out, "%s\n", r ? "error" : "ok"); diff --git a/backup/restore.c b/backup/restore.c index 877f76b191..cdd40c3dd0 100644 --- a/backup/restore.c +++ b/backup/restore.c @@ -44,6 +44,7 @@ #include #include +#include #include #include #include @@ -61,10 +62,15 @@ #include "backup/backup.h" +static struct namespace restore_namespace; + EXPORTED void fatal(const char *s, int code) { fprintf(stderr, "Fatal error: %s\n", s); syslog(LOG_ERR, "Fatal error: %s", s); + + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + exit(code); } @@ -167,9 +173,6 @@ static int restore_add_message(const struct backup_message *message, static struct sync_folder_list *restore_make_reserve_folder_list( struct backup *backup); -static struct backend *restore_connect(const char *servername, - struct buf *tagbuf, - const struct restore_options *options); int main(int argc, char **argv) { @@ -194,12 +197,40 @@ int main(int argc, char **argv) struct backup_mailbox_list *mailbox_list = NULL; struct sync_folder_list *reserve_folder_list = NULL; struct sync_reserve_list *reserve_list = NULL; - struct buf tagbuf = BUF_INITIALIZER; - struct backend *backend = NULL; + struct sync_client_state sync_cs = SYNC_CLIENT_STATE_INITIALIZER; struct dlist *upload = NULL; - int opt, r; - - while ((opt = getopt(argc, argv, ":A:C:DF:LM:P:UXaf:m:nru:vw:xz")) != EOF) { + int opt, r = 0; + + /* keep this in alphabetical order */ + static const char short_options[] = ":A:C:DF:LM:P:UXaf:m:nru:vw:xz"; + + static const struct option long_options[] = { + { "override-acl", optional_argument, NULL, 'A' }, + /* n.b. no long option for -C */ + { "keep-deletedprefix", no_argument, NULL, 'D' }, + { "input-file", required_argument, NULL, 'F' }, + { "local-only", no_argument, NULL, 'L' }, + { "dest-mailbox", required_argument, NULL, 'M' }, + { "dest-partition", required_argument, NULL, 'P' }, + { "keep-uidvalidity", no_argument, NULL, 'U' }, + { "skip-expunged", no_argument, NULL, 'X' }, + { "all-mailboxes", no_argument, NULL, 'a' }, + { "file", required_argument, NULL, 'f' }, + { "mailbox", required_argument, NULL, 'm' }, + { "dry-run", no_argument, NULL, 'n' }, + { "recursive", no_argument, NULL, 'r' }, + { "userid", required_argument, NULL, 'u' }, + { "verbose", no_argument, NULL, 'v' }, + { "delayed-startup", required_argument, NULL, 'w' }, + { "only-expunged", no_argument, NULL, 'x' }, + { "require-compression", no_argument, NULL, 'z' }, + + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'A': if (options.keep_uidvalidity) usage(); @@ -304,6 +335,11 @@ int main(int argc, char **argv) /* okay, arguments seem sane, we are go */ cyrus_init(alt_config, "restore", 0, 0); + if ((r = mboxname_init_namespace(&restore_namespace, NAMESPACE_OPTION_ADMIN))) { + fatal(error_message(r), EX_CONFIG); + } + mboxevent_setnamespace(&restore_namespace); + /* load the SASL plugins */ global_sasl_init(1, 0, mysasl_cb); @@ -320,7 +356,7 @@ int main(int argc, char **argv) BACKUP_OPEN_NONBLOCK, BACKUP_OPEN_NOCREATE); break; case RESTORE_MODE_MBOXNAME: - mbname = mbname_from_intname(backup_name); + mbname = mbname_from_extname(backup_name, &restore_namespace, NULL); if (!mbname) usage(); r = backup_open(&backup, mbname, BACKUP_OPEN_NONBLOCK, BACKUP_OPEN_NOCREATE); @@ -413,9 +449,11 @@ int main(int argc, char **argv) } /* connect to destination */ - backend = restore_connect(servername, &tagbuf, &options); + sync_cs.servername = servername; + sync_cs.flags = options.verbose ? SYNC_FLAG_VERBOSE : 0; + sync_connect(&sync_cs); - if (!backend) { + if (!sync_cs.backend) { // FIXME r = -1; goto done; @@ -425,10 +463,9 @@ int main(int argc, char **argv) struct sync_reserve *reserve; for (reserve = reserve_list->head; reserve; reserve = reserve->next) { /* send APPLY RESERVE and parse missing lists */ - r = sync_reserve_partition(reserve->part, + r = sync_reserve_partition(&sync_cs, reserve->part, reserve_folder_list, - reserve->list, - backend); + reserve->list); if (r) goto done; /* send APPLY MESSAGEs */ @@ -441,8 +478,8 @@ int main(int argc, char **argv) /* upload in small(ish) blocks to avoid timeouts */ while (upload->head) { struct dlist *block = dlist_splice(upload, 1024); - sync_send_apply(block, backend->out); - r = sync_parse_response("MESSAGE", backend->in, NULL); + sync_send_apply(block, sync_cs.backend->out); + r = sync_parse_response("MESSAGE", sync_cs.backend->in, NULL); dlist_unlink_files(block); dlist_free(&block); if (r) goto done; @@ -474,8 +511,8 @@ int main(int argc, char **argv) dl->name = xstrdup("LOCAL_MAILBOX"); } - sync_send_restore(dl, backend->out); - r = sync_parse_response("MAILBOX", backend->in, NULL); + sync_send_restore(dl, sync_cs.backend->out); + r = sync_parse_response("MAILBOX", sync_cs.backend->in, NULL); dlist_free(&dl); if (r) goto done; } @@ -488,9 +525,6 @@ int main(int argc, char **argv) if (backup) backup_close(&backup); - if (backend) - backend_disconnect(backend); - if (upload) { dlist_unlink_files(upload); dlist_free(&upload); @@ -507,7 +541,8 @@ int main(int argc, char **argv) if (reserve_list) sync_reserve_list_free(&reserve_list); - buf_free(&tagbuf); + sync_disconnect(&sync_cs); + free(sync_cs.backend); backup_cleanup_staging_path(); cyrus_done(); @@ -515,97 +550,6 @@ int main(int argc, char **argv) exit(r ? EX_TEMPFAIL : EX_OK); } -static struct backend *restore_connect(const char *servername, - struct buf *tagbuf, - const struct restore_options *options) -{ - struct backend *backend = NULL; - sasl_callback_t *cb; - int timeout; - const char *auth_status = NULL; - - cb = mysasl_callbacks(NULL, - config_getstring(IMAPOPT_RESTORE_AUTHNAME), - config_getstring(IMAPOPT_RESTORE_REALM), - config_getstring(IMAPOPT_RESTORE_PASSWORD)); - - /* try to connect over IMAP */ - backend = backend_connect(backend, servername, - &imap_csync_protocol, "", cb, &auth_status, - (options->verbose > 1 ? fileno(stderr) : -1)); - - if (backend) { - if (backend->capability & CAPA_REPLICATION) { - /* attach our IMAP tag buffer to our protstreams as userdata */ - backend->in->userdata = backend->out->userdata = tagbuf; - } - else { - backend_disconnect(backend); - backend = NULL; - } - } - - /* if that didn't work, fall back to csync */ - if (!backend) { - backend = backend_connect(backend, servername, - &csync_protocol, "", cb, NULL, - (options->verbose > 1 ? fileno(stderr) : -1)); - } - - free_callbacks(cb); - cb = NULL; - - if (!backend) { - fprintf(stderr, "Can not connect to server '%s'\n", servername); - syslog(LOG_ERR, "Can not connect to server '%s'", servername); - return NULL; - } - - if (servername[0] != '/' && backend->sock >= 0) { - tcp_disable_nagle(backend->sock); - tcp_enable_keepalive(backend->sock); - } - -#ifdef HAVE_ZLIB - /* Does the backend support compression? */ - if (CAPA(backend, CAPA_COMPRESS)) { - prot_printf(backend->out, "%s\r\n", - backend->prot->u.std.compress_cmd.cmd); - prot_flush(backend->out); - - if (sync_parse_response("COMPRESS", backend->in, NULL)) { - if (options->require_compression) - fatal("Failed to enable compression, aborting", EX_SOFTWARE); - syslog(LOG_NOTICE, "Failed to enable compression, continuing uncompressed"); - } - else { - prot_setcompress(backend->in); - prot_setcompress(backend->out); - } - } - else if (options->require_compression) { - fatal("Backend does not support compression, aborting", EX_SOFTWARE); - } -#endif - - if (options->verbose > 1) { - /* XXX did we do this during backend_connect already? */ - prot_setlog(backend->in, fileno(stderr)); - prot_setlog(backend->out, fileno(stderr)); - } - - /* Set inactivity timer */ - timeout = config_getint(IMAPOPT_SYNC_TIMEOUT); - if (timeout < 3) timeout = 3; - prot_settimeout(backend->in, timeout); - - /* Force use of LITERAL+ so we don't need two way communications */ - prot_setisclient(backend->in, 1); - prot_setisclient(backend->out, 1); - - return backend; -} - static void my_mailbox_list_add(struct backup_mailbox_list *mailbox_list, struct backup_mailbox *mailbox) { @@ -661,7 +605,7 @@ static struct sync_folder_list *restore_make_reserve_folder_list( /* we only care about mboxname here */ sync_folder_list_add(folder_list, NULL, iter->mboxname, 0, NULL, NULL, 0, 0, 0, 0, synccrcs, - 0, 0, 0, 0, NULL, 0, 0, 0); + 0, 0, 0, 0, NULL, 0, 0, 0, NULL, 0); } backup_mailbox_list_empty(mailboxes); @@ -810,7 +754,7 @@ static int restore_add_mailbox(const struct backup_mailbox *mailbox, const struct synccrcs synccrcs = {0, 0}; sync_folder_list_add(reserve_folder_list, NULL, clone->mboxname, 0, NULL, NULL, 0, 0, 0, 0, synccrcs, - 0, 0, 0, 0, NULL, 0, 0, 0); + 0, 0, 0, 0, NULL, 0, 0, 0, NULL, 0); } /* populate mailbox list */ @@ -854,7 +798,7 @@ static int restore_add_message(const struct backup_message *message, const struct synccrcs synccrcs = {0, 0}; sync_folder_list_add(reserve_folder_list, NULL, mailbox->mboxname, 0, NULL, NULL, 0, 0, 0, 0, synccrcs, - 0, 0, 0, 0, NULL, 0, 0, 0); + 0, 0, 0, 0, NULL, 0, 0, 0, NULL, 0); /* add to mailbox list */ my_mailbox_list_add(mailbox_list, mailbox); @@ -933,14 +877,16 @@ static int restore_add_object(const char *object_name, } else if (strchr(object_name, '.')) { /* has a dot, might be an mboxname */ - mbname_t *mbname = mbname_from_intname(object_name); + mbname_t *mbname = mbname_from_extname(object_name, + &restore_namespace, NULL); mailbox = backup_get_mailbox_by_name(backup, mbname, BACKUP_MAILBOX_ALL_RECORDS); mbname_free(&mbname); } else { /* not sure what it is, guess mboxname? */ - mbname_t *mbname = mbname_from_intname(object_name); + mbname_t *mbname = mbname_from_extname(object_name, + &restore_namespace, NULL); mailbox = backup_get_mailbox_by_name(backup, mbname, BACKUP_MAILBOX_ALL_RECORDS); mbname_free(&mbname); diff --git a/bench/cyrdbbench.c b/bench/cyrdbbench.c index 9b79b3f862..5837f1401b 100644 --- a/bench/cyrdbbench.c +++ b/bench/cyrdbbench.c @@ -258,29 +258,23 @@ static void print_environment(void) fprintf(stderr, "Cyrus DB: %s\n", DBNAME); time_t now = time(NULL); - fprintf(stderr, "Date: %s", ctime(&now));; + fprintf(stderr, "Date: %s", ctime(&now)); FILE *cpuinfo = fopen("/proc/cpuinfo", "r"); if (cpuinfo != NULL) { char line[1000]; int num_cpus = 0; - struct buf cpu_type; - struct buf cache_size; - char *ctype, *csize; - - buf_init(&cpu_type); - buf_init(&cache_size); + struct buf cpu_type = BUF_INITIALIZER; + struct buf cache_size = BUF_INITIALIZER; while (fgets(line, sizeof(line), cpuinfo) != NULL) { const char *sep = strchr(line, ':'); - struct buf key, val; + struct buf key = BUF_INITIALIZER; + struct buf val = BUF_INITIALIZER; if (sep == NULL) continue; - buf_init(&key); - buf_init(&val); - buf_setmap(&key, line, (sep - 1 - line)); buf_trim(&key); @@ -302,14 +296,14 @@ static void print_environment(void) fclose(cpuinfo); - ctype = buf_release(&cpu_type); - csize = buf_release(&cache_size); + const char *ctype = buf_cstring(&cpu_type); + const char *csize = buf_cstring(&cache_size); fprintf(stderr, "CPU: %d * %s", num_cpus, ctype); fprintf(stderr, "CPUCache: %s", csize); - free(ctype); - free(csize); + buf_free(&cpu_type); + buf_free(&cache_size); } } diff --git a/cassandane/.github/dco.yml b/cassandane/.github/dco.yml new file mode 100644 index 0000000000..0c4b142e9a --- /dev/null +++ b/cassandane/.github/dco.yml @@ -0,0 +1,2 @@ +require: + members: false diff --git a/cassandane/.gitignore b/cassandane/.gitignore new file mode 100644 index 0000000000..65117b6295 --- /dev/null +++ b/cassandane/.gitignore @@ -0,0 +1,5 @@ +reports +reports.old +cass.errs +cassandane.ini +.cassandane.ini diff --git a/cassandane/.travis.yml b/cassandane/.travis.yml new file mode 100644 index 0000000000..aa74d3a0ba --- /dev/null +++ b/cassandane/.travis.yml @@ -0,0 +1,71 @@ +language: perl +sudo: required +dist: trusty +group: edge +addons: + apt: + packages: + libmagic-dev +cache: + apt: true +os: linux +compiler: gcc +install: +- cpanm --notest experimental +- cpanm --notest AnyEvent +- cpanm --notest BSD::Resource +- cpanm --notest Clone +- cpanm --notest Config::IniFiles +- cpanm --notest Convert::Base64 +- cpanm --notest Data::ICal +- cpanm --notest Data::ICal::TimeZone +- cpanm --notest Data::UUID +- cpanm --notest DateTime +- cpanm --notest DateTime::Format::ICal +- cpanm --notest DateTime::Format::ISO8601 +- cpanm --notest DBD::SQLite +- cpanm --notest Encode::IMAPUTF7 +- cpanm --notest File::LibMagic +- cpanm --notest File::Slurp +- cpanm --notest File::chdir +- cpanm --notest HTTP::Tiny +- cpanm --notest IO::Socket::INET6 +- cpanm --notest IO::Socket::SSL +- cpanm --notest IO::Stringy +- cpanm --notest JSON +- cpanm --notest JSON::XS +- cpanm --notest List::Pairwise +- cpanm --notest MIME::Types +- cpanm --notest Mail::IMAPTalk +- cpanm --notest Math::Int64 +- cpanm --notest Net::LDAP::Server +- cpanm --notest Net::Server +- cpanm --notest News::NNTPClient +- cpanm --notest String::CRC32 +- cpanm --notest Test::Unit +- cpanm --notest Text::LevenshteinXS +- cpanm --notest Text::VCardFast +- cpanm --notest Tie::DataUUID +- cpanm --notest Unix::Syslog +- cpanm --notest XML::DOM +- cpanm --notest XML::Fast +- cpanm --notest XML::Generator +- cpanm --notest XML::Spice +- cpanm --notest URI; +- cpanm --notest HTTP::Daemon; +- cpanm --notest https://github.com/cyrusimap/Mail-JMAPTalk.git +- cpanm --notest https://github.com/cyrusimap/Net-DAVTalk.git +- cpanm --notest https://github.com/cyrusimap/Net-CalDAVTalk.git +- cpanm --notest https://github.com/cyrusimap/Net-CardDAVTalk.git +script: +- make NOCYRUS=1 +branches: + only: + - master +notifications: + slack: + on_pull_requests: false + on_success: change + on_failure: always + rooms: + - secure: bh4PiwcHYwn2qjKgidSKX6Ibq/Gt8+q6IL7YDWlfpDPYCuzdzSHBpm8qMpmBIjTemnsragJeR4pO9XEX20nhE9Lr7915wiBmYWqcmvcJGpJ1/nJz2lJYtBKl/dKZguQn3g4A+JgjUuXgzllI4ZsbbRkzL8dBC+py34p4ANtMKycXeGCwysnPfHav5VxQQnOsJUbIKDJiJPON2cR7e8quE6WpS1mEzUD+kaRWMUImKktMX1hrQH/71tNNMTqv0eHewci1akaZecFtXQi8D9Yfh1YBm8yxdLI9EgnglonEgbBCGG6WRODcxu/gEJlvXFMN+c4ojoyq4lNGnEqzLjDDVI1LoCNUcWbMFFhIGAA1SE+71fwDlKjLxUzodgJPb/yrWy4uwx8eBM3W8PIhFgZyo0irlV/0U3zNFWjjNPTRXUNNIZQu2XDLAhpiRZbMn4zsvydq2ngWnTdJfgpycYiBfL5zNdwdPpAQomQLl1JakWqyMSBZtz3Hbv3vRmb4rIogh5AHuwxKQrK5JNI9eZ5yPI7eUEpTq1nYD7syZPDj3gddjdteBx8ShjHH6ddteQX2OSUXwtiF90cEgYq0z8j2HxaNRIVrkeUNyeRTZXZ0wxWM7Fcz0Z7fRzsv1CXZwjDPmxARiIhbVhXxTgbED9+i2aCH9aZNrMTqoRUQWjLzgXw= diff --git a/cassandane/COPYRIGHT b/cassandane/COPYRIGHT new file mode 100644 index 0000000000..831404aa43 --- /dev/null +++ b/cassandane/COPYRIGHT @@ -0,0 +1,50 @@ +The Cassandane test suite is covered by the following copyright and +licence, based on the Cyrus IMAPD licence. + + * Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights + * reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Opera Software Australia" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Opera Software Australia Pty. Ltd. + * Level 50, 120 Collins St + * Melbourne 3000 + * Victoria + * Australia + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Opera Software + * Australia Pty. Ltd." + * + * OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-- + +The source code for the Net::XmtpServer Perl module located at +Net/XmtpServer.pm is covered by the following copyright and license: + +Copyright (C) 2003-2017 by FastMail Pty Ltd + +This library is free software; you can redistribute it and/or modify +it under the same terms as Perl itself. diff --git a/cassandane/Cassandane/Address.pm b/cassandane/Cassandane/Address.pm new file mode 100644 index 0000000000..407c5a529a --- /dev/null +++ b/cassandane/Cassandane/Address.pm @@ -0,0 +1,101 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Address; +use strict; +use warnings; +use overload qw("") => \&as_string; + +sub new +{ + my $class = shift; + my %params = @_; + my $self = { + name => undef, + localpart => undef, + domain => undef, + }; + + $self->{name} = $params{name} + if defined $params{name}; + $self->{localpart} = $params{localpart} + if defined $params{localpart}; + $self->{domain} = $params{domain} + if defined $params{domain}; + + bless $self, $class; + return $self; +} + +sub name +{ + my ($self) = @_; + return $self->{name}; +} + +sub localpart +{ + my ($self) = @_; + return ($self->{localpart} || 'unknown-user'); +} + +sub domain +{ + my ($self) = @_; + return ($self->{domain} || 'unspecified-domain'); +} + +sub address +{ + my ($self) = @_; + return $self->localpart() . '@' . $self->domain(); +} + +sub as_string +{ + my ($self) = @_; + my $s = ''; + $s .= $self->{name} . ' ' + if defined $self->{name}; + $s .= '<' . $self->address() . '>'; + return $s; +} + + +1; diff --git a/cassandane/Cassandane/BuildInfo.pm b/cassandane/Cassandane/BuildInfo.pm new file mode 100644 index 0000000000..2d3cc747dd --- /dev/null +++ b/cassandane/Cassandane/BuildInfo.pm @@ -0,0 +1,100 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2018 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::BuildInfo; +use JSON; + +use lib '.'; +use Cassandane::Cassini; +use Cassandane::Util::Log; + +sub new { + my $class = shift; + my %params = @_; + my $self = {}; + + my $cassini = Cassandane::Cassini->instance(); + + my $prefix = $cassini->val("cyrus default", 'prefix', '/usr/cyrus'); + $prefix = $params{cyrus_prefix} + if defined $params{cyrus_prefix}; + + my $destdir = $cassini->val("cyrus default", 'destdir', ''); + $destdir = $params{cyrus_destdir} + if defined $params{cyrus_destdir}; + + $self->{data} = _read_buildinfo($destdir, $prefix); + + return bless $self, $class; +} + +sub _read_buildinfo +{ + my ($destdir, $prefix) = @_; + + my $cyr_buildinfo; + foreach my $bindir (qw(sbin cyrus/bin)) { + my $p = "$destdir$prefix/$bindir/cyr_buildinfo"; + if (-x $p) { + $cyr_buildinfo = $p; + last; + } + } + + if (not defined $cyr_buildinfo) { + xlog "Couldn't find cyr_buildinfo: ". + "don't know what features Cyrus supports"; + return; + } + + my $jsondata = qx($cyr_buildinfo); + return if not $jsondata; + + return JSON::decode_json($jsondata); +} + +sub get +{ + my ($self, $category, $key) = @_; + + return if not exists $self->{data}->{$category}->{$key}; + return $self->{data}->{$category}->{$key}; +} + +1; diff --git a/cassandane/Cassandane/Cassini.pm b/cassandane/Cassandane/Cassini.pm new file mode 100644 index 0000000000..b12c1dac32 --- /dev/null +++ b/cassandane/Cassandane/Cassini.pm @@ -0,0 +1,255 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +# Cassini is an in-memory copy of the Cassandane .INI file. +# It has nothing to do with the astronomer or spacecraft. +package Cassandane::Cassini; +use strict; +use warnings; +use Cwd qw(abs_path); +use Config::IniFiles; + +use lib '.'; +use Cassandane::Util::Log; + +my $instance; + +sub homedir { + my ($uid) = @_; + + return undef if not $uid; + + my @pw = getpwuid($uid); + return $pw[7]; # dir field +} + +sub new +{ + my ($class, %params) = @_; + + my $filename; + + if (defined $params{filename}) { + # explicitly requested filename: just use it + $filename = $params{filename}; + } + elsif (defined $ENV{CASSINI_FILENAME}) { + xlog "Using ini file from environment:" + . " filename=\"$ENV{CASSINI_FILENAME}\""; + $filename = $ENV{CASSINI_FILENAME}; + } + else { + # check some likely places, in order + foreach my $dir (q{.}, + q{..}, + homedir($>), + homedir($<), + homedir($ENV{SUDO_UID}) + ) { + next if not $dir; + + # might be called "cassandane.ini" + if (-e "$dir/cassandane.ini") { + $filename = "$dir/cassandane.ini"; + last; + } + + # might be called ".cassandane.ini" + if (-e "$dir/.cassandane.ini") { + $filename = "$dir/.cassandane.ini"; + last; + } + } + } + + $filename = abs_path($filename) if $filename; + + my $inifile = new Config::IniFiles(); + if ( -f $filename) + { + xlog "Reading $filename" if get_verbose; + $inifile->SetFileName($filename); + if (!$inifile->ReadConfig()) + { + # Config::IniFiles seems to include the filename in + # error messages, so we don't. However it tends to + # emit multiline-messages which confuses our logs. + set_verbose(1); + map { s/[\n\r]\s*/ /g; xlog $_; } @Config::IniFiles::errors; + die "Failed reading $filename"; + } + } + + my $self = { + filename => $filename, + inifile => $inifile + }; + + bless $self, $class; + + if ((not $filename or not -f $filename) + and not $self->bool_val('cassandane', 'allow_noinifile', 'no')) + { + die "couldn't find a cassandane.ini file"; + } + + # pre-validate cassandane.core_pattern early -- if the configured + # pattern is invalid the qr// will crash out + my $core_pattern = $self->val('cassandane', 'core_pattern'); + $core_pattern = qr{$core_pattern} if $core_pattern; + + $instance = $self + unless defined $instance; + return $self; +} + +sub instance +{ + my ($class) = @_; + + if (!defined $instance) + { + $instance = Cassandane::Cassini->new(); + die "Singleton broken in Cassini ctor!" + unless defined $instance; + } + return $instance; +} + +sub val +{ + my ($self, $section, $name, $default) = @_; + + # Allow overrides from specially-named environment variables. + # + # Examples: + # + # to override the "rootdir" option from the "[cassandane]" section, + # set: CASSINI_CASSANDANE_ROOTDIR=/some/different/value + # + # to override the "prefix" option from the "[cyrus default]" section, + # set: CASSINI_CYRUS_DEFAULT_PREFIX=/some/different/value + # + my $envname = "\UCASSINI $section $name\E"; + $envname =~ s{[^A-Z0-9]+}{_}g; + if (defined $ENV{$envname}) { + xlog "Using configuration from environment:" + . " \$$envname=\"$ENV{$envname}\""; + return $ENV{$envname}; + } + + # see the Config::IniFiles documentation for ->val() + return $self->{inifile}->val($section, $name, $default); +} + +sub bool_val +{ + # Args are: section, name, default + # returns a boolean 1 or 0 + my ($self, $section, $parameter, $default) = @_; + $default = 'no' if !defined $default; + my $v = $self->val($section, $parameter, $default); + + return 1 if ($v =~ m/^yes$/i); + return 1 if ($v =~ m/^true$/i); + return 1 if ($v =~ m/^on$/i); + return 1 if ($v =~ m/^1$/); + + return 0 if ($v =~ m/^no$/i); + return 0 if ($v =~ m/^false$/i); + return 0 if ($v =~ m/^off$/i); + return 0 if ($v =~ m/^0$/); + + die "Bad boolean \"$v\""; +} + +sub override +{ + my ($self, $section, $parameter, $value) = @_; + my $ii = $self->{inifile}; + + if (defined $ii->val($section, $parameter)) + { + $ii->setval($section, $parameter, $value); + } + else + { + $ii->newval($section, $parameter, $value); + } +} + +sub get_section +{ + my ($self, $section) = @_; + my $inifile = $self->{inifile}; + my %params; + my $filename = $self->{filename} || 'inifile'; + if ($inifile->SectionExists($section)) { + foreach my $key ($inifile->Parameters($section)) { + # n.b. if there are multiple values for this section.key, + # val() in scalar context returns them joined by $/, which is + # nasty. So call it in list context instead, even though we + # don't support multiple values, and use the last one... + my @values = $inifile->val($section, $key); + + if (scalar @values > 1) { + # ... and whinge if there were multiple! + xlog "$filename: multiple values for $section.$key," + . " using last ($values[-1])"; + if (get_verbose()) { + xlog "$filename: $section.$key=<$_>" for @values; + } + } + + $params{$key} = $values[-1]; + } + } + return \%params; +} + +sub get_core_pattern +{ + my ($self) = @_; + + my $core_pattern = $self->val('cassandane', 'core_pattern', + '^core.*?(?:\.(\d+))?$'); + return qr{$core_pattern}; +} + +1; diff --git a/cassandane/Cassandane/Config.pm b/cassandane/Cassandane/Config.pm new file mode 100644 index 0000000000..487343f292 --- /dev/null +++ b/cassandane/Cassandane/Config.pm @@ -0,0 +1,413 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Config; +use strict; +use warnings; + +use lib '.'; +use Cassandane::Cassini; +use Cassandane::Util::Log; + +my $default; + +# XXX Manually entered from lib/imapoptions in cyrus-imapd repo. +# XXX Once these repositories are merged, we'll be able to automate keeping +# XXX this synchronised... +my %bitfields = ( + 'calendar_component_set' => 'VEVENT VTODO VJOURNAL VFREEBUSY VAVAILABILITY VPOLL', + 'event_extra_params' => 'bodyStructure clientAddress diskUsed flagNames messageContent messageSize messages modseq service timestamp uidnext vnd.cmu.midset vnd.cmu.unseenMessages vnd.cmu.envelope vnd.cmu.sessionId vnd.cmu.mailboxACL vnd.cmu.mbtype vnd.cmu.davFilename vnd.cmu.davUid vnd.fastmail.clientId vnd.fastmail.sessionId vnd.fastmail.convExists vnd.fastmail.convUnseen vnd.fastmail.cid vnd.fastmail.counters vnd.fastmail.jmapEmail vnd.fastmail.jmapStates vnd.cmu.emailid vnd.cmu.threadid vnd.cmu.visibleUsers', + 'event_groups' => 'message quota flags access mailbox subscription calendar applepushservice jmap', + 'httpmodules' => 'admin caldav carddav cgi domainkey freebusy ischedule jmap prometheus rss tzdist webdav', + 'metapartition_files' => 'header index cache expunge squat annotations lock dav archivecache', + 'newsaddheaders' => 'to replyto', + 'sieve_extensions' => 'fileinto reject vacation vacation-seconds notify include envelope environment body relational regex subaddress copy date index imap4flags imapflags mailbox mboxmetadata servermetadata variables editheader extlists duplicate ihave fcc special-use redirect-dsn redirect-deliverby mailboxid vnd.cyrus.log x-cyrus-log vnd.cyrus.jmapquery x-cyrus-jmapquery vnd.cyrus.imip snooze vnd.cyrus.snooze x-cyrus-snooze vnd.cyrus.implicit_keep_target', +); +my $bitfields_fixed = 0; + +sub init_bitfields +{ + if (!$bitfields_fixed) { + while (my ($key, $allvalues) = each %bitfields) { + $bitfields{$key} = {}; + foreach my $v (split /\s/, $allvalues) { + $bitfields{$key}->{$v} = 1; + } + } + $bitfields_fixed = 1; + } +} + +sub new +{ + my $class = shift; + + init_bitfields(); + + my $self = { + parent => undef, + variables => {}, + params => {}, + }; + + bless $self, $class; + + # any arguments are initial params, process them properly + $self->set(@_); + + return $self; +} + +sub default +{ + if (!defined($default)) { + $default = Cassandane::Config->new( + admins => 'admin mailproxy mupduser repluser', + rfc3028_strict => 'no', + configdirectory => '@basedir@/conf', + syslog_prefix => '@name@', + sievedir => '@basedir@/conf/sieve', + defaultpartition => 'default', + defaultdomain => 'defdomain', + 'partition-default' => '@basedir@/data', + sasl_mech_list => 'PLAIN LOGIN', + allowplaintext => 'yes', + # for debugging - see cassandane.ini.example + debug_command => '@prefix@/utils/gdbtramp %s %d', + # everyone should be running this + improved_mboxlist_sort => 'yes', + # default changed, we want to be explicit about it + unixhierarchysep => 'no', + # let's hear all about it + auditlog => 'yes', + chatty => 'yes', + debug => 'yes', + httpprettytelemetry => 'yes', + + # smtpclient_open should fail by default! + # + # If your test fails and writes something like + # smptclient_open: can't connect to host: bogus:0/noauth + # in syslog, then Cyrus is calling smtpclient_open(), and you + # will need to arrange for fakesmtpd to be listening. To do + # this add :want_smtpdaemon to the test attributes, or enable + # smtpdaemon in the suite constructor. + smtp_backend => 'host', + smtp_host => 'bogus:0', + ); + my $defs = Cassandane::Cassini->instance()->get_section('config'); + $default->set(%$defs); + } + + return $default; +} + +sub clone +{ + my ($self) = @_; + + my $child = Cassandane::Config->new(); + $child->{parent} = $self; + return $child; +} + +sub _explode_bit_string +{ + my ($s) = @_; + return split / /, $s; +} + +sub set +{ + my ($self, %nv) = @_; + while (my ($n, $v) = each %nv) + { + if (exists $bitfields{$n}) { + # it's a bitfield, set exactly what's given (clearing others) + if (ref $v eq 'ARRAY') { + $self->clear_all_bits($n); + $self->set_bits($n, @{$v}); + } + elsif (ref $v eq q{}) { + $self->clear_all_bits($n); + $self->set_bits($n, _explode_bit_string($v)); + } + else { + die "don't know what to do with value '$v'"; + } + } + else { + $self->{params}->{$n} = $v; + } + } +} + +sub set_bits +{ + my ($self, $name, @bits) = @_; + + die "$name is not a bitfield option" if not exists $bitfields{$name}; + + # explode space-delimited list as only bit + if (scalar @bits == 1 && $bits[0] =~ m/ /) { + @bits = _explode_bit_string($bits[0]); + } + + foreach my $bit (@bits) { + die "$bit is not a $name value" + if not exists $bitfields{$name}->{$bit}; + + $self->{params}->{$name}->{$bit} = 1; + } +} + +sub clear_bits +{ + my ($self, $name, @bits) = @_; + + die "$name is not a bitfield option" if not exists $bitfields{$name}; + + # explode space-delimited list as only bit + if (scalar @bits == 1 && $bits[0] =~ m/ /) { + @bits = _explode_bit_string($bits[0]); + } + + foreach my $bit (@bits) { + die "$bit is not a $name value" + if not exists $bitfields{$name}->{$bit}; + + $self->{params}->{$name}->{$bit} = 0; + } +} + +sub clear_all_bits +{ + my ($self, $name) = @_; + + die "$name is not a bitfield option" if not exists $bitfields{$name}; + + $self->{params}->{$name}->{$_} = 0 for keys %{$bitfields{$name}}; +} + +sub get +{ + my ($self, $n) = @_; + if (exists $bitfields{$n}) { + my %bits; + while (defined $self) { + if (exists $self->{params}->{$n}) { + while (my ($bit, $val) = each %{$self->{params}->{$n}}) { + $bits{$bit} //= $val; + } + } + $self = $self->{parent}; + } + my @v = grep { $bits{$_} } sort keys %bits; + return wantarray ? @v : join q{ }, @v; + } + else { + while (defined $self) + { + return $self->{params}->{$n} + if exists $self->{params}->{$n}; + $self = $self->{parent}; + } + } + return undef; +} + +sub get_bit +{ + my ($self, $name, $bit) = @_; + + die "$name is not a bitfield option" if not exists $bitfields{$name}; + die "$bit is not a $name value" if not exists $bitfields{$name}->{$bit}; + + while (defined $self) { + return $self->{params}->{$name}->{$bit} + if exists $self->{params}->{$name}->{$bit}; + $self = $self->{parent}; + } + return undef; +} + +sub get_bool +{ + my ($self, $n, $def) = @_; + + die "bitfield $n cannot be boolean" if exists $bitfields{$n}; + + $def = 'no' if !defined $def; + my $v = $self->get($n); + $v = $def if !defined $v; + + return 1 if ($v =~ m/^yes$/i); + return 1 if ($v =~ m/^true$/i); + return 1 if ($v =~ m/^on$/i); + return 1 if ($v =~ m/^1$/); + + return 0 if ($v =~ m/^no$/i); + return 0 if ($v =~ m/^false$/i); + return 0 if ($v =~ m/^off$/i); + return 0 if ($v =~ m/^0$/); + + die "Bad boolean \"$v\""; +} + +sub set_variables +{ + my ($self, %nv) = @_; + while (my ($n, $v) = each %nv) + { + $self->{variables}->{$n} = $v; + } +} + +sub _get_variable +{ + my ($self, $n) = @_; + $n =~ s/@//g; + while (defined $self) + { + return $self->{variables}->{$n} + if exists $self->{variables}->{$n}; + $self = $self->{parent}; + } + die "Variable $n not defined"; +} + +sub substitute +{ + my ($self, $s) = @_; + + return unless defined $s; + my $r = ''; + while (defined $s) + { + my ($pre, $ref, $post) = ($s =~ m/(.*)(@[a-z]+@)(.*)/); + if (defined $ref) + { + $r .= $pre . $self->_get_variable($ref); + $s = $post; + } + else + { + $r .= $s; + last; + } + } + return $r; +} + +sub _flatten +{ + my ($self) = @_; + my %nv; + for (my $conf = $self ; defined $conf ; $conf = $conf->{parent}) + { + foreach my $n (keys %{$conf->{params}}) + { + if (exists $bitfields{$n}) { + # no variable substitution on bitfields + while (my ($bit, $val) = each %{$conf->{params}->{$n}}) { + $nv{$n}->{$bit} //= $val; + } + } + else { + $nv{$n} = $self->substitute($conf->{params}->{$n}) + unless exists $nv{$n}; + } + } + } + return \%nv; +} + +sub generate +{ + my ($self, $filename) = @_; + my $nv = $self->_flatten(); + + open CONF,'>',$filename + or die "Cannot open $filename for writing: $!"; + while (my ($n, $v) = each %$nv) + { + next unless defined $v; + if (exists $bitfields{$n}) { + my @bits = grep { $nv->{$n}->{$_} } sort keys %{$nv->{$n}}; + print CONF "$n: " . join(q{ }, @bits) . "\n"; + } + else { + print CONF "$n: $v\n"; + } + } + close CONF; +} + +sub is_bitfield +{ + my ($name) = @_; + + init_bitfields(); + + return defined $bitfields{$name}; +} + +sub is_bitfield_bit +{ + my ($name, $value) = @_; + + init_bitfields(); + + die "$name is not a bitfield option" if not exists $bitfields{$name}; + + return defined $bitfields{$name}->{$value}; +} + +sub get_bitfield_bits +{ + my ($name) = @_; + + init_bitfields(); + + die "$name is not a bitfield option" if not exists $bitfields{$name}; + + return sort keys %{$bitfields{$name}}; +} + +1; diff --git a/cassandane/Cassandane/Cyrus/ACL.pm b/cassandane/Cassandane/Cyrus/ACL.pm new file mode 100644 index 0000000000..3c0f096026 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/ACL.pm @@ -0,0 +1,369 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::ACL; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Generator; +use Cassandane::MessageStoreFactory; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({adminstore => 1}, @_); +} + +sub set_up +{ + my ($self) = @_; + + $self->SUPER::set_up(); + + my $admintalk = $self->{adminstore}->get_client(); + + # let's create ourselves an archive user + # sub folders of another user - one is subscribable + $self->{instance}->create_user("archive", + subdirs => [ 'cassandane', ['cassandane', 'sent'] ]); + $admintalk->setacl("user.archive.cassandane.sent", "cassandane", "lrswp"); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test regular delete +# +sub test_delete + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $self->{adminstore}->set_folder('user.archive.cassandane.sent'); + $self->make_message("Message A", store => $self->{adminstore}); + + $self->{store}->set_folder('user.archive.cassandane.sent'); + $self->{store}->_select(); + + my $res = $talk->store('1', '+flags', '(\\deleted)'); + $self->assert_null($res); # means it failed + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/permission denied/i); +} + +sub test_many_users + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + $self->make_message("Message A"); + + $talk->create("INBOX.multi"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + for (1..100) { + $admintalk->setacl("user.cassandane.multi", "test$_", "lrswipcd"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + } + + my $res = $talk->select("INBOX.multi"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); +} + +sub test_move + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $self->{adminstore}->set_folder('user.archive.cassandane.sent'); + $self->make_message("Message A", store => $self->{adminstore}); + + $self->{store}->set_folder('user.archive.cassandane.sent'); + $self->{store}->_select(); + + my $res = $talk->move('1', "INBOX"); + $self->assert_null($res); # means it failed + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/permission denied/i); +} + +sub test_setacl_badacl + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.badacl"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.badacl", "foo", "ylrswipcd"); + $self->assert_str_equals('bad', $admintalk->get_last_completion_response()); +} + +sub test_setacl_addacl + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.addacl"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.addacl", "foo", "lrswipkxtecdn"); + $admintalk->setacl("user.cassandane.addacl", "foo", "+a"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); +} + +sub test_setacl_rmacl + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.rmacl"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.rmacl", "foo", "lrswipkxtecdan"); + $admintalk->setacl("user.cassandane.rmacl", "foo", "-a"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); +} + +sub test_setacl_addacl_exists + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.exists"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.exists", "foo", "lrswipkxtecdan"); + $admintalk->setacl("user.cassandane.exists", "foo", "+a"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); +} + +sub test_setacl_rmacl_unexists + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.rmunexists"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.rmunexists", "foo", "lrswipkxtecdn"); + $admintalk->setacl("user.cassandane.rmunexists", "foo", "-a"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); +} + +sub test_reconstruct +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + my $oldacl = $admintalk->getacl("user.archive.cassandane.sent"); + + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct'); + + my $newacl = $admintalk->getacl("user.archive.cassandane.sent"); + $self->assert_deep_equals($oldacl, $newacl); +} + +sub test_setacl_emptyid +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.emptyid"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + # send an empty identifier for SETACL + $admintalk->setacl("user.cassandane.emptyid", "", "lrswipcd"); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); +} + +sub test_setacl_badrights + :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.badrights"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + my $origacl = $admintalk->getacl("user.cassandane.badrights"); + + $admintalk->setacl("user.cassandane.badrights", "cassandane", "b"); + $self->assert_str_equals('bad', $admintalk->get_last_completion_response()); + + my $newacl = $admintalk->getacl("user.cassandane.badrights"); + $self->assert_deep_equals($origacl, $newacl); +} + +#Magic word virtdomains in name sets config virtdomains = userid +sub test_virtdomains_noinherit1 + :NoAltNamespace :CrossDomains +{ + my ($self) = @_; + + my $defaultdomain = $self->{instance}->{config}->get('defaultdomain') + // 'internal'; + my $cass_defdom = "cassandane\@$defaultdomain"; + my $cass_dom = 'cassandane@uhoh.org'; + + # get stores that authenticate as each username + $self->{instance}->create_user($cass_dom); + my $imap = $self->{instance}->get_service('imap'); + my $defdom_store = $imap->create_store(username => $cass_defdom); + my $dom_store = $imap->create_store(username => $cass_dom); + + # set up a target folder and some permissions + $self->{instance}->create_user('banana'); + my $folder = 'user.banana'; + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->setacl($folder, cassandane => 'lrswip'); + $admintalk->setacl($folder, $cass_dom => 'lrs'); + $admintalk->getacl($folder); + + # make the stores all look at the same folder + $self->{store}->set_folder($folder); + $defdom_store->set_folder($folder); + $dom_store->set_folder($folder); + + # 'cassandane' should be able to make a message + $self->make_message("message from cassandane", store => $self->{store}); + + # 'cassandane@{defaultdomain}' should be able to make a message + $self->make_message("message from $cass_defdom", store => $defdom_store); + + # 'cassandane@somedomain' should NOT be able to make a message + eval { + $self->make_message("message from $cass_dom", store => $dom_store); + }; + my $err = q{} . $@; + $self->assert_matches(qr/permission denied/i, $err); +} + +#Magic word virtdomains in name sets config virtdomains = userid +sub test_virtdomains_noinherit2 + :NoAltNamespace :CrossDomains +{ + my ($self) = @_; + + my $defaultdomain = $self->{instance}->{config}->get('defaultdomain') + // 'internal'; + my $cass_defdom = "cassandane\@$defaultdomain"; + my $cass_dom = 'cassandane@uhoh.org'; + + # get stores that authenticate as each username + $self->{instance}->create_user($cass_dom); + my $imap = $self->{instance}->get_service('imap'); + my $defdom_store = $imap->create_store(username => $cass_defdom); + my $dom_store = $imap->create_store(username => $cass_dom); + + # set up a target folder and some permissions + $self->{instance}->create_user('banana'); + my $folder = 'user.banana'; + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->setacl($folder, cassandane => 'lrs'); + $admintalk->setacl($folder, $cass_dom => 'lrswip'); + $admintalk->getacl($folder); + + # make the stores all look at the same folder + $self->{store}->set_folder($folder); + $defdom_store->set_folder($folder); + $dom_store->set_folder($folder); + + # 'cassandane' should NOT be able to make a message + eval { + $self->make_message("message from cassandane", + store => $self->{store}); + }; + my $err = q{} . $@; + $self->assert_matches(qr/permission denied/i, $err); + + # 'cassandane@{defaultdomain}' should NOT be able to make a message + eval { + $self->make_message("message from $cass_defdom", + store => $defdom_store); + }; + $err = q{} . $@; + $self->assert_matches(qr/permission denied/i, $err); + + # 'cassandane@somedomain' should be able to make a message + $self->make_message("message from $cass_dom", store => $dom_store); +} + +# see also LDAP.pm for groupid tests + +1; diff --git a/cassandane/Cassandane/Cyrus/Admin.pm b/cassandane/Cassandane/Cyrus/Admin.pm new file mode 100644 index 0000000000..9d934edc54 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Admin.pm @@ -0,0 +1,159 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Admin; +use strict; +use warnings; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config::default()->clone(); + $config->set( imap_admins => 'admin imapadmin' ); + return $class->SUPER::new({ config => $config, adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + my $imap = $self->{instance}->get_service('imap'); + $self->{imapadminstore} = $imap->create_store(username => 'imapadmin'); +} + +sub tear_down +{ + my ($self) = @_; + + $self->{imapadminstore}->disconnect(); + delete $self->{imapadminstore}; + + $self->SUPER::tear_down(); +} + +sub test_imap_admins +{ + # test whether the imap_admins setting works correctly + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imapadmintalk = $self->{imapadminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + # we should be able to reconstruct as 'admin', because although + # imap_admins overrides admins, we have 'admin' in imap_admins too + # (it MUST be there for Cassandane itself to work) + my $res = $admintalk->_imap_cmd("reconstruct" , 0, {}, "user.cassandane"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # we should not be able to reconstruct as 'cassandane', because + # reconstruct is an admin-only command + $res = $talk->_imap_cmd("reconstruct", 0, {}, "user.cassandane"); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/permission denied/i, $talk->get_last_error()); + + # we should be able to reconstruct as 'imapadmin', because this user + # is in imap_admins + $res = $imapadmintalk->_imap_cmd("reconstruct", 0, {}, "user.cassandane"); + $self->assert_str_equals('ok', $imapadmintalk->get_last_completion_response()); +} + +#Magic word virtdomains in name sets config virtdomains = userid +sub test_imap_admins_virtdomains +{ + # test whether the imap_admins setting works correctly under virtdomains + my ($self) = @_; + + my $domainadmin = 'admin@uhoh.org'; + my $defaultdomain = $self->{instance}->{config}->get('defaultdomain') + // 'internal'; + my $defdomadmin = "admin\@$defaultdomain"; + + $self->{instance}->create_user($domainadmin); + my $imap = $self->{instance}->get_service('imap'); + my $domainadminstore = $imap->create_store(username => $domainadmin); + my $defdomadminstore = $imap->create_store(username => $defdomadmin); + + my $admintalk = $self->{adminstore}->get_client(); + my $imapadmintalk = $self->{imapadminstore}->get_client(); + my $domainadmintalk = $domainadminstore->get_client(); + my $defdomadmintalk = $defdomadminstore->get_client(); + my $talk = $self->{store}->get_client(); + + # we should be able to reconstruct as 'admin', because although + # imap_admins overrides admins, we have 'admin' in imap_admins too + # (it MUST be there for Cassandane itself to work) + my $res = $admintalk->_imap_cmd("reconstruct" , 0, {}, "user.cassandane"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # we should not be able to reconstruct as 'cassandane', because + # reconstruct is an admin-only command + $res = $talk->_imap_cmd("reconstruct", 0, {}, "user.cassandane"); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/permission denied/i, $talk->get_last_error()); + + # we should be able to reconstruct as 'imapadmin', because this user + # is in imap_admins + $res = $imapadmintalk->_imap_cmd("reconstruct", 0, {}, "user.cassandane"); + $self->assert_str_equals('ok', + $imapadmintalk->get_last_completion_response()); + + # we MUST NOT be able to reconstruct as 'admin@uhoh.org', because + # this user is not in imap_admins, even though bare 'admin' is + $res = $domainadmintalk->_imap_cmd("reconstruct", 0, {}, "user.cassandane"); + $self->assert_str_equals('no', + $domainadmintalk->get_last_completion_response()); + $self->assert_matches(qr/permission denied/i, + $domainadmintalk->get_last_error()); + + # we should be able to reconstruct as admin@$defaultdomain, because + # we treat bare username and username@defaultdomain as equivalent + $res = $defdomadmintalk->_imap_cmd("reconstruct", 0, {}, "user.cassandane"); + $self->assert_str_equals('ok', + $defdomadmintalk->get_last_completion_response()); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Annotator.pm b/cassandane/Cassandane/Cyrus/Annotator.pm new file mode 100644 index 0000000000..c8fd414542 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Annotator.pm @@ -0,0 +1,501 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Annotator; +use strict; +use warnings; +use Cwd qw(abs_path); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Util::Wait; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config->default()->clone(); + $config->set( + annotation_callout => '@basedir@/conf/socket/annotator.sock', + ); + return $class->SUPER::new({ + config => $config, + deliver => 1, + start_instances => 0, + adminstore => 1, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub start_my_instances +{ + my ($self) = @_; + + $self->{instance}->add_generic_listener( + name => 'annotator', + port => $self->{instance}->{config}->get('annotation_callout'), + argv => sub { + my ($listener) = @_; + return ( + abs_path('utils/annotator.pl'), + '--port', $listener->port(), + '--pidfile', '@basedir@/run/annotator.pid', + ); + }); + + $self->_start_instances(); +} + +sub test_add_annot_deliver +{ + my ($self) = @_; + + $self->start_my_instances(); + + my $entry = '/comment'; + my $attrib = 'value.shared'; + # Data thanks to http://hipsteripsum.me + my $value1 = 'you_probably_havent_heard_of_them'; + + my %exp; + $exp{A} = $self->{gen}->generate(subject => "Message A"); + $exp{A}->set_body("set_shared_annotation $entry $value1\r\n"); + $self->{instance}->deliver($exp{A}); + $exp{A}->set_annotation($entry, $attrib, $value1); + + # Local delivery adds headers we can't predict or control, + # which change the SHA1 of delivered messages, so we can't + # be checking the GUIDs here. + $self->{store}->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $self->check_messages(\%exp, check_guid => 0); +} + +sub test_add_annot_deliver_tomailbox + :NoAltNamespace +{ + my ($self) = @_; + + $self->start_my_instances(); + + xlog $self, "Testing adding an annotation from the Annotator"; + xlog $self, "when delivering to a non-INBOX mailbox [IRIS-955]"; + + my $entry = '/comment'; + my $attrib = 'value.shared'; + # Data thanks to http://hipsteripsum.me + my $value1 = 'before_they_sold_out'; + + my $subfolder = 'target'; + my $talk = $self->{store}->get_client(); + $talk->create("INBOX.$subfolder") + or die "Failed to create INBOX.$subfolder"; + + my %exp; + $exp{A} = $self->{gen}->generate(subject => "Message A"); + $exp{A}->set_body("set_shared_annotation $entry $value1\r\n"); + $self->{instance}->deliver($exp{A}, folder => $subfolder); + $exp{A}->set_annotation($entry, $attrib, $value1); + + # Local delivery adds headers we can't predict or control, + # which change the SHA1 of delivered messages, so we can't + # be checking the GUIDs here. + $self->{store}->set_folder("INBOX.$subfolder"); + $self->{store}->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $self->check_messages(\%exp, check_guid => 0); +} + +sub test_set_system_flag_deliver +{ + my ($self) = @_; + + $self->start_my_instances(); + + my $flag = '\\Flagged'; + + my %exp; + $exp{A} = $self->{gen}->generate(subject => "Message A"); + $exp{A}->set_body("set_flag $flag\r\n"); + $self->{instance}->deliver($exp{A}); + $exp{A}->set_attributes(flags => ['\\Recent', $flag]); + + # Local delivery adds headers we can't predict or control, + # which change the SHA1 of delivered messages, so we can't + # be checking the GUIDs here. + $self->{store}->set_fetch_attributes('uid', 'flags'); + $self->check_messages(\%exp, check_guid => 0); +} + +sub test_set_user_flag_deliver +{ + my ($self) = @_; + + $self->start_my_instances(); + + # Data thanks to http://hipsteripsum.me + my $flag = '$Artisanal'; + + my %exp; + $exp{A} = $self->{gen}->generate(subject => "Message A"); + $exp{A}->set_body("set_flag $flag\r\n"); + $self->{instance}->deliver($exp{A}); + $exp{A}->set_attributes(flags => ['\\Recent', $flag]); + + # Local delivery adds headers we can't predict or control, + # which change the SHA1 of delivered messages, so we can't + # be checking the GUIDs here. + $self->{store}->set_fetch_attributes('uid', 'flags'); + $self->check_messages(\%exp, check_guid => 0); +} + +sub test_reconstruct_after_delivery +{ + my ($self) = @_; + + $self->start_my_instances(); + + xlog $self, "Testing reconstruct after delivery"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $msgs{1}->set_body("set_shared_annotation /comment testvalue\r\n"); + $imaptalk->create("INBOX.subfolder"); + $self->{instance}->deliver($msgs{1}, user => "cassandane"); + + xlog $self, "Check that the message made it"; + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); + + # run a fresh reconstruct + my $out = "$self->{instance}->{basedir}/$self->{_name}-reconstruct.stdout"; + $self->{instance}->run_command( + { cyrus => 1, + redirects => { 'stdout' => $out }, + }, 'reconstruct', '-u', 'cassandane'); + + # check the output + $out = slurp_file($out); + xlog $self, $out; + + $self->assert_does_not_match(qr/ updating /, $out); +} + + +# Note: remove_annotation can't really be tested with local +# delivery, just with the APPEND command. + +sub test_fetch_after_annotate +{ + # This is a test for https://github.com/cyrusimap/cyrus-imapd/issues/2071 + my ($self) = @_; + + $self->start_my_instances(); + + my $flag = '$X-ME-Annot-2'; + my $imaptalk = $self->{store}->get_client(); + my $modseq; + my %msg; + + $imaptalk->select("INBOX"); + + xlog $self, "Create Message A"; + $msg{A} = $self->{gen}->generate(subject => "Message A"); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{A}->set_body("set_flag $flag\r\n"); + $self->{instance}->deliver($msg{A}); + + $msg{A}->set_attributes(flags => ['\\Recent', $flag]); + + $self->{store}->set_fetch_attributes('uid', 'flags', 'modseq'); + + xlog $self, "Fetch message A"; + my %handlers1; + { + $handlers1{fetch} = sub { + $self->assert_num_equals(scalar @{$_[1]{flags}}, 2); + $self->assert_str_equals($_[1]{flags}[0], "\\Recent"); + $self->assert_str_equals($_[1]{flags}[1], "\$X-ME-Annot-2"); + }; + } + $imaptalk->_imap_cmd("uid fetch", 1, \%handlers1, '1', '(flags modseq)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Clear the $flag from the message A."; + my %handlers2; + { + $handlers2{fetch} = sub { + $modseq = $_[1]{modseq}[0]; + $self->assert_num_equals(scalar @{$_[1]{flags}}, 1); + $self->assert_str_equals($_[1]{flags}[0], "\\Recent"); + }; + } + $imaptalk->store('1', '-flags', "($flag)"); + $imaptalk->_imap_cmd("uid fetch", 1, \%handlers2, '1', '(flags modseq)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Run xrunannotator"; + my %handlers3; + { + $handlers3{fetch} = sub { + $self->assert($_[1]{modseq}[0] > $modseq); + $self->assert_num_equals(scalar @{$_[1]{flags}}, 2); + $self->assert_str_equals($_[1]{flags}[0], "\\Recent"); + $self->assert_str_equals($_[1]{flags}[1], "\$X-ME-Annot-2"); + }; + } + $imaptalk->_imap_cmd("uid xrunannotator", 0, {}, '1'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->_imap_cmd("uid fetch", 1, \%handlers3, '1', '(flags modseq)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); +} + +sub test_annotator_callout_disabled + :min_version_3_1 +{ + my ($self) = @_; + $self->{instance}->{config}->set(annotation_callout_disable_append => 'yes'); + + $self->start_my_instances(); + + my $flag = '$X-ME-Annot-2'; + my $imaptalk = $self->{store}->get_client(); + my $modseq; + my %msg; + + $imaptalk->select("INBOX"); + + xlog $self, "Create Message A"; + $msg{A} = $self->{gen}->generate(subject => "Message A"); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{A}->set_body("set_flag $flag\r\n"); + $self->{instance}->deliver($msg{A}); + + $msg{A}->set_attributes(flags => ['\\Recent', $flag]); + + $self->{store}->set_fetch_attributes('uid', 'flags', 'modseq'); + + xlog $self, "Fetch message A"; + my %handlers1; + { + $handlers1{fetch} = sub { + $self->assert_num_equals(scalar @{$_[1]{flags}}, 2); + $self->assert_str_equals($_[1]{flags}[0], "\\Recent"); + $self->assert_str_equals($_[1]{flags}[1], "\$X-ME-Annot-2"); + }; + } + $imaptalk->_imap_cmd("uid fetch", 1, \%handlers1, '1', '(flags modseq)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Clear the $flag from the message A."; + my %handlers2; + { + $handlers2{fetch} = sub { + $modseq = $_[1]{modseq}[0]; + $self->assert_num_equals(scalar @{$_[1]{flags}}, 1); + $self->assert_str_equals($_[1]{flags}[0], "\\Recent"); + }; + } + $imaptalk->store('1', '-flags', "($flag)"); + $imaptalk->_imap_cmd("uid fetch", 1, \%handlers2, '1', '(flags modseq)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Run xrunannotator"; + my %handlers3; + { + $handlers3{fetch} = sub { + $self->assert($_[1]{modseq}[0] == $modseq); + $self->assert_num_equals(scalar @{$_[1]{flags}}, 1); + $self->assert_str_equals($_[1]{flags}[0], "\\Recent"); + }; + } + $imaptalk->_imap_cmd("uid xrunannotator", 0, {}, '1'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Nothing should have changed from the previous run of uid fetch."; + $imaptalk->_imap_cmd("uid fetch", 1, \%handlers3, '1', '(flags modseq)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); +} + +sub test_add_annot_splitconv + :min_version_3_1 :Conversations +{ + my ($self) = @_; + my %exp; + + $self->{instance}->{config}->set(conversations_max_thread => 5); + + $self->start_my_instances(); + + my $entry = '/comment'; + my $attrib = 'value.shared'; + # Data thanks to http://hipsteripsum.me + my $value1 = 'you_probably_havent_heard_of_them'; + + $self->{store}->set_fetch_attributes('uid', 'cid', 'basecid', "annotation ($entry $attrib)"); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating replies"; + for (1..4) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + $exp{"B"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"B"}->set_attributes(uid => 6, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + + $exp{C} = $self->{gen}->generate(subject => "Re: Message A", references => [ $exp{A} ]); + $exp{C}->set_body("set_shared_annotation $entry $value1\r\n"); + $self->{instance}->deliver($exp{C}); + $exp{C}->set_annotation($entry, $attrib, $value1); + $exp{C}->set_attributes(uid => 7, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + + # Local delivery adds headers we can't predict or control, + # which change the SHA1 of delivered messages, so we can't + # be checking the GUIDs here. + $self->check_messages(\%exp, keyed_on => 'uid', check_guid => 0); +} + +sub test_add_annot_splitconv_rerun + :min_version_3_1 :Conversations +{ + my ($self) = @_; + my %exp; + + $self->{instance}->{config}->set(conversations_max_thread => 5); + + $self->start_my_instances(); + + my $entry = '/comment'; + my $attrib = 'value.shared'; + # Data thanks to http://hipsteripsum.me + my $value1 = 'you_probably_havent_heard_of_them'; + + $self->{store}->set_fetch_attributes('uid', 'cid', 'basecid', 'flags', "annotation ($entry $attrib)"); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating replies"; + for (1..4) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + $exp{"B"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"B"}->set_attributes(uid => 6, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + + $exp{C} = $self->{gen}->generate(subject => "Re: Message A", references => [ $exp{A} ]); + $exp{C}->set_body("set_shared_annotation $entry $value1\r\nset_flag \$X-FUN"); + $self->{instance}->deliver($exp{C}); + $exp{C}->set_attributes(uid => 7, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + + # Local delivery adds headers we can't predict or control, + # which change the SHA1 of delivered messages, so we can't + # be checking the GUIDs here. + $self->check_messages(\%exp, keyed_on => 'uid', check_guid => 0); + + my $imaptalk = $self->{store}->get_client(); + $imaptalk->store('7', '-flags', '$X-FUN'); + $imaptalk->_imap_cmd("uid xrunannotator", 0, {}, '7'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $exp{C}->set_annotation($entry, $attrib, $value1); + + $self->check_messages(\%exp, keyed_on => 'uid', check_guid => 0); +} + +sub test_log_missing_acl +{ + my ($self) = @_; + + $self->start_my_instances(); + + my $imap = $self->{store}->get_client(); + my $admin = $self->{adminstore}->get_client(); + + $self->{instance}->create_user("other"); + + my @testCases = ({ + flag => '$Artisanal', + acl => 'lrsitne', + need_rights => 'w' + }, { + flag => '\Seen', + acl => 'lritne', + need_rights => 's' + }); + + foreach (@testCases) { + $admin->setacl("user.other", "cassandane", $_->{acl}) or die; + + $self->{instance}->getsyslog(); + $self->{store}->set_folder('Other Users.other'); + $self->make_message("Email", body => "set_flag $_->{flag}\r\n", + store => $self->{store}) or die; + my $wantLog = "could not write flag due missing ACL: " + . "flag=<\\$_->{flag}> need_rights=<$_->{need_rights}>"; + $self->assert_syslog_matches($self->{instance}, qr{$wantLog}); + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Archive.pm b/cassandane/Cassandane/Cyrus/Archive.pm new file mode 100644 index 0000000000..997fa1af9a --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Archive.pm @@ -0,0 +1,354 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Archive; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Words; + +sub new +{ + my ($class, @args) = @_; + return $class->SUPER::new({ adminstore => 1 }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test that +# - cyr_expire archives messages +# - once archived, messages are in the new path +# - the message is gone from the old path +# - XXX: hard to test - that there's no possible race in which the message +# isn't available to clients during the archive operation +# +sub test_archive_messages + :ArchivePartition :min_version_3_0 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Append 3 messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $msg{C} = $self->make_message('Message C'); + $msg{C}->set_attributes(id => 3, + uid => 3, + flags => []); + $self->check_messages(\%msg); + + my $data = $self->{instance}->run_mbpath("-u", 'cassandane'); + my $datadir = $data->{data}; + my $archivedir = $data->{archive}; + + $self->assert_file_test("$datadir/1.", "-f"); + $self->assert_file_test("$datadir/2.", "-f"); + $self->assert_file_test("$datadir/3.", "-f"); + + $self->assert_not_file_test("$archivedir/1.", "-f"); + $self->assert_not_file_test("$archivedir/2.", "-f"); + $self->assert_not_file_test("$archivedir/3.", "-f"); + + xlog $self, "Run cyr_expire but no messages should move"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '7d' ); + + $self->assert_file_test("$datadir/1.", "-f"); + $self->assert_file_test("$datadir/2.", "-f"); + $self->assert_file_test("$datadir/3.", "-f"); + + $self->assert_not_file_test("$archivedir/1.", "-f"); + $self->assert_not_file_test("$archivedir/2.", "-f"); + $self->assert_not_file_test("$archivedir/3.", "-f"); + + xlog $self, "Run cyr_expire to archive now"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '0' ); + + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); +} + +sub test_archivenow_messages + :ArchiveNow :min_version_3_0 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Append 3 messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $msg{C} = $self->make_message('Message C'); + $msg{C}->set_attributes(id => 3, + uid => 3, + flags => []); + $self->check_messages(\%msg); + + my $data = $self->{instance}->run_mbpath("-u", 'cassandane'); + my $datadir = $data->{data}; + my $archivedir = $data->{archive}; + + # already archived + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); + + xlog $self, "Run cyr_expire with old and messages stay archived"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '7d' ); + + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); + + xlog $self, "Run cyr_expire to archive now and messages stay archived"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '0' ); + + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); +} + +1; + +sub test_archive_messages_archive_annotation + :ArchivePartition :min_version_3_1 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Append 3 messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $msg{C} = $self->make_message('Message C'); + $msg{C}->set_attributes(id => 3, + uid => 3, + flags => []); + $self->check_messages(\%msg); + + my $data = $self->{instance}->run_mbpath("-u", 'cassandane'); + my $datadir = $data->{data}; + my $archivedir = $data->{archive}; + + $self->assert_file_test("$datadir/1.", "-f"); + $self->assert_file_test("$datadir/2.", "-f"); + $self->assert_file_test("$datadir/3.", "-f"); + + $self->assert_not_file_test("$archivedir/1.", "-f"); + $self->assert_not_file_test("$archivedir/2.", "-f"); + $self->assert_not_file_test("$archivedir/3.", "-f"); + + xlog $self, "Run cyr_expire but no messages should move"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '7d' ); + + $self->assert_file_test("$datadir/1.", "-f"); + $self->assert_file_test("$datadir/2.", "-f"); + $self->assert_file_test("$datadir/3.", "-f"); + + $self->assert_not_file_test("$archivedir/1.", "-f"); + $self->assert_not_file_test("$archivedir/2.", "-f"); + $self->assert_not_file_test("$archivedir/3.", "-f"); + + $admintalk->setmetadata('user.cassandane', + "/shared/vendor/cmu/cyrus-imapd/archive", + '3'); + + xlog $self, "Run cyr_expire asking to archive now, but it shouldn't"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '0' ); + + $self->assert_file_test("$datadir/1.", "-f"); + $self->assert_file_test("$datadir/2.", "-f"); + $self->assert_file_test("$datadir/3.", "-f"); + + $self->assert_not_file_test("$archivedir/1.", "-f"); + $self->assert_not_file_test("$archivedir/2.", "-f"); + $self->assert_not_file_test("$archivedir/3.", "-f"); + + xlog $self, "Run cyr_expire asking to archive now, with skip annotation"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '0' , '-a'); + + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); +} + +sub test_archivenow_reconstruct + :ArchiveNow :min_version_3_0 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Append 3 messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $msg{C} = $self->make_message('Message C'); + $msg{C}->set_attributes(id => 3, + uid => 3, + flags => []); + $self->check_messages(\%msg); + + my $data = $self->{instance}->run_mbpath("-u", 'cassandane'); + my $datadir = $data->{data}; + my $archivedir = $data->{archive}; + + # already archived + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); + + xlog $self, "Run cyr_expire with old and messages stay archived"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '7d' ); + + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); + + xlog $self, "Run cyr_expire to archive now and messages stay archived"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '0' ); + + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); + + xlog $self, "Reconstruct doesn't lose files"; + + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', '-s'); + + $self->assert_not_file_test("$datadir/1.", "-f"); + $self->assert_not_file_test("$datadir/2.", "-f"); + $self->assert_not_file_test("$datadir/3.", "-f"); + + $self->assert_file_test("$archivedir/1.", "-f"); + $self->assert_file_test("$archivedir/2.", "-f"); + $self->assert_file_test("$archivedir/3.", "-f"); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Autocreate.pm b/cassandane/Cassandane/Cyrus/Autocreate.pm new file mode 100644 index 0000000000..3b9ef98384 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Autocreate.pm @@ -0,0 +1,221 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Autocreate; +use strict; +use warnings; +use Cwd qw(getcwd); +use Data::Dumper; +use File::Temp qw(tempdir); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config->default()->clone(); + + $config->set( + autocreate_post => 'yes', + autocreate_quota => '500000', + autocreate_inbox_folders => 'Drafts|Sent|Trash|SPAM|plus', + autocreate_subscribe_folder => 'Drafts|Sent|Trash|SPAM|plus', + autocreate_sieve_script => '@basedir@/conf/foo_sieve.script', + autocreate_acl => 'plus anyone p', + 'xlist-drafts' => 'Drafts', + 'xlist-junk' => 'SPAM', + 'xlist-sent' => 'Sent', + 'xlist-trash' => 'Trash', + ); + return $class->SUPER::new({ + config => $config, + adminstore => 1, + deliver => 1, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_autocreate_specialuse + :min_version_3_0 :needs_component_autocreate :NoAltNameSpace +{ + my ($self) = @_; + + my $svc = $self->{instance}->get_service('imap'); + my $store = $svc->create_store(username => 'foo'); + my $talk = $store->get_client(); + my $list = $talk->list('', '*', 'return', ['special-use']); + + my %map = ( + drafts => 'Drafts', + junk => 'SPAM', + sent => 'Sent', + trash => 'Trash', + ); + foreach my $item (@$list) { + my $key; + foreach my $flag (@{$item->[0]}) { + next unless $flag =~ m/\\(.*)/; + $key = $1; + last if $map{$key}; + } + my $name = delete $map{$key}; + next unless $name; + $self->assert_str_equals("INBOX.$name", $item->[2]); + } + $self->assert_num_equals(0, scalar keys %map); +} + +sub test_autocreate_sieve_script_generation + :min_version_3_0 :needs_component_autocreate :needs_component_sieve +{ + my ($self) = @_; + + my $basedir = $self->{instance}->get_basedir(); + my $sieve_script_path = $basedir . "/conf/foo_sieve.script"; + my $hitfolder = "INBOX.NewFolder"; + my $testfolder = "INBOX.TestFolder"; + my $missfolder = "INBOX"; + + open(FH, '>', "$sieve_script_path") + or die "Cannot open $sieve_script_path for writing: $!"; + print FH "require \[\"fileinto\", \"mailbox\"\];"; + print FH "if mailboxexists \"$testfolder\" {"; + print FH "fileinto \"$hitfolder\";"; + print FH "}"; + close(FH); + + my $svc = $self->{instance}->get_service('imap'); + my $store = $svc->create_store(username => 'foo'); + my $talk = $store->get_client(); + + my $sievedir = $self->{instance}->get_sieve_script_dir('foo'); + $self->assert_file_test("$sievedir/foo_sieve.script.script", '-f'); + $self->assert_file_test("$sievedir/defaultbc", '-f'); + $self->assert_file_test("$sievedir/foo_sieve.script.bc", '-f'); +} + +sub test_autocreate_acl + :min_version_3_1 :needs_component_autocreate :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my %folder_acls = ( + 'INBOX' => [qw( foo lrswipkxtecdan )], + 'INBOX.Drafts' => [qw( foo lrswipkxtecdan )], + 'INBOX.Sent' => [qw( foo lrswipkxtecdan )], + 'INBOX.SPAM' => [qw( foo lrswipkxtecdan )], + 'INBOX.Trash' => [qw( foo lrswipkxtecdan )], + 'INBOX.plus' => [qw( foo lrswipkxtecdan anyone p )], + ); + + my $svc = $self->{instance}->get_service('imap'); + my $store = $svc->create_store(username => 'foo'); + my $talk = $store->get_client(); + + while (my ($folder, $acl) = each %folder_acls) { + my $res = $talk->getacl($folder); + $self->assert_deep_equals($folder_acls{$folder}, $res); + } +} + +sub test_legacymb_already_exists + :NoStartInstances :NoAltNamespace +{ + my ($self) = @_; + + # want a separate IMAP service with separate config containing + # the defaults (no autocreate!) plus mailbox_legacy_dirs: yes + my $leg_conf = Cassandane::Config->default()->clone(); + $leg_conf->set(mailbox_legacy_dirs => 'yes'); + + my $leg_svc = $self->{instance}->add_service( + name => 'imaplegacymb', + config => $leg_conf, + ); + + # now actually start everything + $self->_start_instances(); + + # create some mailboxes for user foo under legacy storage + my $leg_store = $leg_svc->create_store(username => 'admin', + type => 'imap'); + my $leg_talk = $leg_store->get_client(); + + $leg_talk->create('user.foo') or die; + $leg_talk->setacl('user.foo', foo => 'lrswipkxtecdn') or die; + $leg_talk->create('user.foo.bar') or die; + $leg_talk->setacl('user.foo.bar', foo => 'lrswipkxtecdn') or die; + + $leg_talk->logout(); + + # those mailboxes had better be under legacy storage + foreach my $mailbox (qw(user.foo user.foo.bar)) { + my $mbpath = $self->{instance}->run_mbpath($mailbox); + $self->assert_does_not_match(qr{/uuid/}, $mbpath->{data}); + } + + # now log in as user foo -- better not get the default + # autocreate set! + + my $svc = $self->{instance}->get_service('imap'); + my $store = $svc->create_store(username => 'foo'); + my $talk = $store->get_client(); + + my $data = $talk->list("", "*"); + + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => '\\HasChildren', + 'INBOX.bar' => '\\HasNoChildren', + }); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Backups.pm b/cassandane/Cassandane/Cyrus/Backups.pm new file mode 100644 index 0000000000..89f924c088 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Backups.pm @@ -0,0 +1,638 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Backups; +use strict; +use warnings; +use Data::Dumper; +use JSON::XS; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Instance; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ backups => 1, adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub do_backup +{ + my ($self, $params) = @_; + + die "params not a hashref" + if defined $params and ref $params ne 'HASH'; + + my $users = $params->{users}; + my $mailboxes = $params->{mailboxes}; + + if (defined $users) { + $users = [ $users ] if not ref $users; + die "users is not an array reference" if ref $users ne 'ARRAY'; + xlog $self, "backing up users: @{$users}"; + $self->{instance}->run_command( + { cyrus => 1}, + qw(sync_client -vv -n backup -u), @{$users}); + } + + if (defined $mailboxes) { + $mailboxes = [ $mailboxes ] if not ref $mailboxes; + die "mailboxes is not an array reference" if ref $mailboxes ne 'ARRAY'; + xlog $self, "backing up mailboxes: @{$mailboxes}"; + $self->{instance}->run_command( + { cyrus => 1 }, + qw(sync_client -vv -n backup -m), @{$mailboxes}); + } + + if (not defined $users and not defined $mailboxes) { + xlog $self, "backing up all users"; + # n.b. this does not include shared mailboxes! see sync_client(8) + $self->{instance}->run_command( + { cyrus => 1 }, + qw(sync_client -vv -n backup -A)); + } +} + +sub do_xbackup +{ + my ($self, $pattern, $channel) = @_; + + die "do_xbackup needs a pattern" if not $pattern; + $channel //= 'backup'; + + my $admintalk = $self->{adminstore}->get_client(); + + my %untagged; + my $handler = sub { + my ($response, $args, undef) = @_; + return if scalar @{$args} != 2; + my ($type, $val) = @{$args}; + push @{$untagged{uc $response}->{$type}}, $val; + }; + my %callbacks = ( + 'ok' => $handler, + 'no' => $handler, + ); + + $admintalk->_imap_cmd('xbackup', 0, \%callbacks, + $pattern, $channel); + if (wantarray) { + return ($admintalk->get_last_completion_response(), \%untagged); + } + else { + return $admintalk->get_last_completion_response(); + } +} + +sub cyr_backup_json +{ + my ($self, $params, $subcommand, @args) = @_; + + die "params not a hashref" + if defined $params and ref $params ne 'HASH'; + die "invalid subcommand: $subcommand" + if not grep { $_ eq $subcommand } qw(chunks mailboxes messages headers); + + my $instance = $params->{instance} // $self->{backups}; + my $user = $params->{user} // 'cassandane'; + my $mailbox = $params->{mailbox}; + + my $out = "$instance->{basedir}/$self->{_name}" + . "-cyr_backup-$user-json-$subcommand.stdout"; + my $err = "$instance->{basedir}/$self->{_name}" + . "-cyr_backup-$user-json-$subcommand.stderr"; + + my ($mode, $backup); + if (defined $mailbox) { + $mode = '-m'; + $backup = $mailbox; + } + else { + $mode = '-u'; + $backup = $user; + } + + $instance->run_command( + { cyrus => 1, + redirects => { 'stdout' => $out, + 'stderr' => $err } }, + 'cyr_backup', $mode, $backup, 'json', $subcommand, @args + ); + + return JSON::decode_json(slurp_file($out)); +} + +sub backup_exists +{ + my ($self, $mode, $backup) = @_; + + my $rc = $self->{backups}->run_command( + { + cyrus => 1, + handlers => { + exited_abnormally => sub { + my (undef, $code) = @_; + return $code + }, + }, + }, + 'ctl_backups', 'list', $mode, $backup + ); + + return $rc == 0; +} + +sub assert_backups_exist +{ + my ($self, $params) = @_; + + my @users = exists $params->{users} ? @{$params->{users}} : (); + my @mailboxes = exists $params->{mailboxes} ? @{$params->{mailboxes}} : (); + my @filenames = exists $params->{filenames} ? @{$params->{filenames}} : (); + + foreach my $u (@users) { + my $x = $self->backup_exists('-u', $u); + $self->assert($x, "no backup found for user $u"); + } + + foreach my $m (@mailboxes) { + my $x = $self->backup_exists('-m', $m); + $self->assert($x, "no backup found for mailbox $m"); + } + + foreach my $f (@filenames) { + my $x = $self->backup_exists('-f', $f); + $self->assert($x, "no backup found for filename $f"); + } +} + +sub assert_backups_not_exist +{ + my ($self, $params) = @_; + + my @users = exists $params->{users} ? @{$params->{users}} : (); + my @mailboxes = exists $params->{mailboxes} ? @{$params->{mailboxes}} : (); + my @filenames = exists $params->{filenames} ? @{$params->{filenames}} : (); + + foreach my $u (@users) { + my $x = $self->backup_exists('-u', $u); + $self->assert(!$x, "unexpected backup found for user $u"); + } + + foreach my $m (@mailboxes) { + my $x = $self->backup_exists('-m', $m); + $self->assert(!$x, "unexpected backup found for mailbox $m"); + } + + foreach my $f (@filenames) { + my $x = $self->backup_exists('-f', $f); + $self->assert(!$x, "unexpected backup found for filename $f"); + } +} + +sub test_aaasetup + :min_version_3_0 :needs_component_backup +{ + my ($self) = @_; + + # does everything set up and tear down cleanly? + $self->assert(1); +} + +sub test_basic + :min_version_3_0 :needs_component_backup +{ + my ($self) = @_; + + $self->do_backup({ users => 'cassandane' }); + + my $chunks = $self->cyr_backup_json({}, 'chunks'); + + $self->assert_equals(1, scalar @{$chunks}); + $self->assert_equals(0, $chunks->[0]->{offset}); + $self->assert_equals(1, $chunks->[0]->{id}); + # an empty chunk has a 29 byte prefix + # make sure the chunk isn't empty -- it should at least send through + # the state of an empty inbox + $self->assert($chunks->[0]->{length} > 29); +} + +sub test_messages + :min_version_3_0 :needs_component_backup +{ + my ($self) = @_; + + my %exp; + + $exp{A} = $self->make_message("Message A"); + $exp{B} = $self->make_message("Message B"); + $exp{C} = $self->make_message("Message C"); + $exp{D} = $self->make_message("Message D"); + + $self->do_backup({ users => 'cassandane' }); + + my $messages = $self->cyr_backup_json({}, 'messages'); + + # backup should contain four messages + $self->assert_equals(4, scalar @{$messages}); + + my $headers = $self->cyr_backup_json({}, 'headers', map { $_->{guid} } @{$messages}); + + # transform out enough data for comparison purposes + my %expected = map { + $_->get_guid() => $_->get_header('X-Cassandane-Unique') + } values %exp; + + my %actual = map { + $_ => $headers->{$_}->{'X-Cassandane-Unique'}->[0] + } keys %{$headers}; + + $self->assert_deep_equals(\%expected, \%actual); +} + +sub test_shared_mailbox + :min_version_3_0 :needs_component_backup :NoAltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + # should definitely not be able to create a user that would conflict + # with where shared mailbox backups are stored! + $admintalk->create('user.%SHARED'); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + + $admintalk->create('shared.folder'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl('shared.folder', 'cassandane' => 'lrswipkxtecdn'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $self->{store}->set_folder('shared.folder'); + my %exp; + $exp{A} = $self->make_message("Message A"); + $exp{B} = $self->make_message("Message B"); + $exp{C} = $self->make_message("Message C"); + $exp{D} = $self->make_message("Message D"); + + $self->do_backup({ mailboxes => 'shared.folder' }); + + my $messages = $self->cyr_backup_json({ mailbox => 'shared.folder'}, + 'messages'); + + # backup should contain four messages + $self->assert_equals(4, scalar @{$messages}); + + my $headers = $self->cyr_backup_json({ mailbox => 'shared.folder' }, + 'headers', + map { $_->{guid} } @{$messages}); + + # transform out enough data for comparison purposes + my %expected = map { + $_->get_guid() => $_->get_header('X-Cassandane-Unique') + } values %exp; + + my %actual = map { + $_ => $headers->{$_}->{'X-Cassandane-Unique'}->[0] + } keys %{$headers}; + + $self->assert_deep_equals(\%expected, \%actual); + + # XXX probably don't do this like this + $self->{backups}->run_command( + { cyrus => 1 }, + qw(ctl_backups -S -vvv verify -m shared.folder) + ); +} + +sub test_deleted_mailbox + :min_version_3_0 :needs_component_backup :NoAltNamespace +{ + my ($self) = @_; + + my $usertalk = $self->{store}->get_client(); + $usertalk->create('INBOX.foo'); + $self->assert_str_equals('ok', $usertalk->get_last_completion_response()); + + $self->{store}->set_folder('INBOX.foo'); + + my %exp; + $exp{A} = $self->make_message("Message A"); + $exp{B} = $self->make_message("Message B"); + $exp{C} = $self->make_message("Message C"); + $exp{D} = $self->make_message("Message D"); + + $self->do_backup({ users => 'cassandane' }); + + # backup should contain four messages + my $messages = $self->cyr_backup_json({}, 'messages'); + $self->assert_equals(4, scalar @{$messages}); + + my $mailboxes = $self->cyr_backup_json({}, 'mailboxes'); + $self->assert_equals(2, scalar @{$mailboxes}); + $self->assert_deep_equals([qw(user.cassandane user.cassandane.foo)], + [ map { $_->{mboxname} } @{$mailboxes} ]); + + # delete the mailbox + $usertalk->delete('INBOX.foo'); + $self->assert_str_equals('ok', $usertalk->get_last_completion_response()); + + $self->do_backup({ users => 'cassandane' }); + + $messages = $self->cyr_backup_json({}, 'messages'); + $self->assert_equals(4, scalar @{$messages}); + + $mailboxes = $self->cyr_backup_json({}, 'mailboxes'); + $self->assert_equals(2, scalar @{$mailboxes}); + $self->assert_deep_equals([qw(user.cassandane DELETED.user.cassandane.foo)], + [ map { $_->{mboxname} =~ s/\.[A-F0-9]{8}$//r } + @{$mailboxes} ]); + + my $deleted_mboxname = $mailboxes->[1]->{mboxname}; + + # should be able to find the correct backup by the deleted name + # and see the four messages in it + $messages = $self->cyr_backup_json({ mailbox => $deleted_mboxname }, + 'messages'); + $self->assert_equals(4, scalar @{$messages}); +} + +sub test_locks + :min_version_3_0 :needs_component_backup +{ + my ($self) = @_; + + # make sure there's a backup file + $self->do_backup({ users => 'cassandane' }); + + # lock it for a while + my $wait = 10; # seconds + my $sleeper = $self->{backups}->run_command( + { cyrus => 1, background => 1 }, + qw(ctl_backups -w -vvv lock -u cassandane -x ), "/bin/sleep $wait", + ); + + # give the sleeper a moment to start up so it can definitely get the + # lock without racing against the next bit... + sleep 2; + + # meanwhile, try to get another lock on the same backup + my $errfile = $self->{backups}->get_basedir() . "/ctl_backups_lock.stderr"; + my ($code, $output); + $self->{backups}->run_command( + { + cyrus => 1, + handlers => { + exited_abnormally => sub { (undef, $code) = @_ }, + }, + redirects => { + stderr => $errfile, + }, + }, + qw(ctl_backups -vvv lock -u cassandane -x ), "/bin/echo locked", + ); + + $output = slurp_file($errfile); + + # clean up after the sleeper + $self->{backups}->reap_command($sleeper); + + # expect the second lock failed, specifically due to being locked + $self->assert_num_equals(75, $code); # EX_TEMPFAIL + $self->assert_matches(qr{Mailbox is locked}, $output); +} + +sub test_xbackup + :min_version_3_0 :UnixHierarchySep :VirtDomains :needs_component_backup +{ + my ($self) = @_; + my $id = 1; + + my @users = qw( + user@example.com + foo.bar@example.com + ); + + my @folders = qw( Drafts Sent Trash ); + + # create the new users + my $admintalk = $self->{adminstore}->get_client(); + foreach my $u (@users) { + $self->{instance}->create_user($u, subdirs => \@folders); + } + + # we also want to test the cassandane user (which was already created) + unshift @users, 'cassandane'; + foreach my $f (@folders) { + $admintalk->create("user/cassandane/$f"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } + + # shouldn't be backup files for these users yet + $self->assert_backups_not_exist({ users => \@users }); + + # kick off a backup with xbackup and a pattern + my ($status, $details) = $self->do_xbackup('user/*'); + $self->assert_str_equals('ok', $status); + $self->assert_deep_equals([sort @users], [sort @{$details->{OK}->{USER}}]); + + # backups should exist now, but with no messages + $self->assert_backups_exist({ users => \@users }); + foreach my $u (@users) { + my $messages = $self->cyr_backup_json({ user => $u }, 'messages'); + $self->assert_num_equals(0, scalar @{$messages}); + } + + # add some content -- four messages per folder per user + my %exp; + foreach my $u (@users) { + foreach my $f (@folders) { + my ($l, $d) = split '@', $u; + my $p = "user/$l/$f"; + $p .= "\@$d" if $d; + $self->{adminstore}->set_folder($p); + for (1..4) { + $exp{$u}->{$f}->{$id} = + $self->make_message("Message $id", + store => $self->{adminstore}); + $id++; + } + } + } + + # let's xbackup and check each user individually + foreach my $u (@users) { + # run xbackup + my ($status, $details) = $self->do_xbackup("user/$u"); + $self->assert_str_equals('ok', $status); + $self->assert_deep_equals([$u], $details->{OK}->{USER}); + + # backup should contain four messages per folder + my $messages = $self->cyr_backup_json({ user => $u }, 'messages'); + $self->assert_num_equals(4 * scalar(@folders), scalar @{$messages}); + + # check they're the right messages + my $headers = $self->cyr_backup_json({ user => $u }, 'headers', + map { $_->{guid} } @{$messages}); + + my %expected = map { + $_->get_guid() => $_->get_header('X-Cassandane-Unique') + } map { values %{$_} } values %{$exp{$u}}; + + my %actual = map { + $_ => $headers->{$_}->{'X-Cassandane-Unique'}->[0] + } keys %{$headers}; + + $self->assert_deep_equals(\%expected, \%actual); + } + + # let's also xbackup all users with a pattern + ($status, $details) = $self->do_xbackup("user/*"); + $self->assert_str_equals('ok', $status); + + # each user should only be processed once, even though "user/*" pattern + # also matches all their subfolders + $self->assert_deep_equals([sort @users], [sort @{$details->{OK}->{USER}}]); +} + +sub test_xbackup_shared + :min_version_3_0 :UnixHierarchySep :VirtDomains :needs_component_backup +{ + my ($self) = @_; + my $id = 1; + + my @folders = qw( sh1 sh2 ); + my @subfolders = qw( foo bar baz ); + + # create the shared folders + my $admintalk = $self->{adminstore}->get_client(); + foreach my $top (@folders) { + $admintalk->create($top); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl($top, 'admin' => 'lrswipkxtecdan'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + foreach my $sub (@subfolders) { + $admintalk->create("$top/$sub"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl("$top/$sub", 'admin' => 'lrswipkxtecdan'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } + } + + # shouldn't be backup files for these mailboxes yet + $self->assert_backups_not_exist({ mailboxes => \@folders }); + + # kick off a backup with xbackup and a pattern + my ($status, $details) = $self->do_xbackup('sh*'); + $self->assert_str_equals('ok', $status); + $self->assert_num_equals(scalar(@folders) * (1 + scalar(@subfolders)), + scalar @{$details->{OK}->{MAILBOX}}); + + # backups should exist now, but with no messages + $self->assert_backups_exist({ mailboxes => \@folders }); + foreach my $f (@folders) { + my $messages = $self->cyr_backup_json({ mailbox => $f }, 'messages'); + $self->assert_num_equals(0, scalar @{$messages}); + } + + # add some content -- four messages per folder + my %exp; + foreach my $top (@folders) { + foreach my $sub (@subfolders) { + my $p = "$top/$sub"; + $self->{adminstore}->set_folder($p); + for (1..4) { + $exp{$id} = + $self->make_message("Message $id", + store => $self->{adminstore}); + $id++; + } + } + } + + # xbackup again + ($status, $details) = $self->do_xbackup('sh*'); + $self->assert_str_equals('ok', $status); + $self->assert_num_equals(scalar(@folders) * (1 + scalar(@subfolders)), + scalar @{$details->{OK}->{MAILBOX}}); + + # backup should contain four messages per subfolder per folder + my $messages = $self->cyr_backup_json({ mailbox => $folders[0] }, + 'messages'); + $self->assert_num_equals(4 * scalar(@folders) * scalar(@subfolders), + scalar @{$messages}); + + # check they're the right messages + my $headers = $self->cyr_backup_json({ mailbox => $folders[0] }, 'headers', + map { $_->{guid} } @{$messages}); + + my %expected = map { + $_->get_guid() => $_->get_header('X-Cassandane-Unique') + } values %exp; + + my %actual = map { + $_ => $headers->{$_}->{'X-Cassandane-Unique'}->[0] + } keys %{$headers}; + + $self->assert_deep_equals(\%expected, \%actual); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Bug3072.pm b/cassandane/Cassandane/Cyrus/Bug3072.pm new file mode 100644 index 0000000000..0340bc1fcb --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Bug3072.pm @@ -0,0 +1,87 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Bug3072; +use strict; +use warnings; +use DateTime; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test COPY behaviour with a very long sequence set +# +sub test_copy_longset_slow +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.dest"); + for (1..2000) { + $self->make_message("Message $_"); + } + my $list = join(',', map { $_ * 2 } 1..1000); + + $imaptalk->copy($list, "INBOX.dest"); + + # XXX this doesn't even verify that the messages were copied! +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Bug3470.pm b/cassandane/Cassandane/Cyrus/Bug3470.pm new file mode 100644 index 0000000000..39d24e6d78 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Bug3470.pm @@ -0,0 +1,169 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Bug3470; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(virtdomains => 'userid'); + $config->set(unixhierarchysep => 'on'); + $config->set(altnamespace => 'yes'); + + return $class->SUPER::new({ config => $config }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + my $imaptalk = $self->{store}->get_client(); + + # Bug #3470 folders + # sub folders only + $imaptalk->create("Drafts") || die; + $imaptalk->create("2001/05/wk18") || die; + $imaptalk->create("2001/05/wk19") || die; + $imaptalk->create("2001/05/wk20") || die; + $imaptalk->subscribe("2001/05/wk20") || die; +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test LSUB behaviour +# +sub test_list_percent +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my @inbox_flags = qw( \\HasNoChildren ); + my @inter_flags = qw( \\HasChildren ); + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj < 3) { + unshift @inbox_flags, qw( \\Noinferiors ); + unshift @inter_flags, qw( \\Noselect ); + } + elsif ($maj == 3 && $min < 5) { + unshift @inter_flags, qw( \\Noselect ); + } + + my $alldata = $imaptalk->list("", "%"); + $self->assert_deep_equals($alldata, [ + [ + \@inbox_flags, + '/', + 'INBOX' + ], + [ + \@inter_flags, + '/', + '2001' + ], + [ + [ + '\\HasNoChildren' + ], + '/', + 'Drafts' + ] + ], "LIST data mismatch: " . Dumper($alldata, \@inbox_flags)); +} + +# +# Test LSUB behaviour +# +sub test_list_2011 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my @inter_flags = qw( \\HasChildren ); + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj < 3 || ($maj == 3 && $min < 5)) { + unshift @inter_flags, qw( \\Noselect ); + } + + my $alldata = $imaptalk->list("", "2001"); + $self->assert_deep_equals($alldata, [ + [ + \@inter_flags, + '/', + '2001' + ] + ], "LIST data mismatch: " . Dumper($alldata)); +} + +sub test_lsub +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $alldata = $imaptalk->lsub("", "2001"); + $self->assert_deep_equals($alldata, [ + [ + [ + '\\Noselect', + '\\HasChildren' + ], + '/', + '2001' + ] + ], "LSUB data mismatch: " . Dumper($alldata)); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Bug3649.pm b/cassandane/Cassandane/Cyrus/Bug3649.pm new file mode 100644 index 0000000000..0e27cafd3d --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Bug3649.pm @@ -0,0 +1,86 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Bug3649; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_delete_subuser +{ + my ($self) = @_; + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + xlog $self, "Test Cyrus extension which renames a user to a different partition"; + + # create and prepare the user + $self->{instance}->create_user('admin1'); + $adminstore->set_folder('user.admin1'); + for ('A'..'Z') { + $self->make_message("Message $_", store => $adminstore); + } + $admintalk->unselect(); + + $admintalk->delete('user.admin1'); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Bug3903.pm b/cassandane/Cassandane/Cyrus/Bug3903.pm new file mode 100644 index 0000000000..05834e618f --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Bug3903.pm @@ -0,0 +1,141 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Bug3903; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config->default()->clone(); + $config->set(autocreate_quota => 101200); + return $class->SUPER::new({ + config => $config, + adminstore => 1, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + $self->{instance}->create_user("foo", + subdirs => [ 'cassandane', ['cassandane', 'sent'] ]); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->setacl("user.foo.cassandane.sent", "cassandane", "lrswp"); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_create_under_wrong_user + :NoAltNameSpace +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $res = $talk->create('user.foo.cassandane.sent.Test1'); + $self->assert_null($res); # means it failed + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/permission denied/i); + + $res = $talk->create('user.foo.cassandane.Test2'); + $self->assert_null($res); # means it failed + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/permission denied/i); + + $res = $talk->create('user.foo.Test3'); + $self->assert_null($res); # means it failed + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/permission denied/i); +} + +sub test_create_under_user + :NoAltNameSpace +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $res = $talk->create('user.Test4'); + $self->assert_null($res); # means it failed + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/permission denied/i); + +} + +sub test_create_under_shared + :NoAltNameSpace +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $res = $talk->create('shared.Test5'); + $self->assert_null($res); # means it failed + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/permission denied/i); + +} + +sub test_create_at_top_level + :NoAltNameSpace +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $res = $talk->create('Test6'); + $self->assert_null($res); # means it failed + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/permission denied/i); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Caldav.pm b/cassandane/Cassandane/Cyrus/Caldav.pm new file mode 100644 index 0000000000..4f7781c0ef --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Caldav.pm @@ -0,0 +1,327 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Caldav; +use v5.26.0; # strict + indented here-docs +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.12; +use Net::DAVTalk::XMLParser; +use File::Basename; +use Data::Dumper; +use Text::VCardFast; +use Cwd qw(abs_path); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use utf8; + +sub MELBOURNE { + return <<~'EOF'; + BEGIN:VCALENDAR + BEGIN:VTIMEZONE + TZID:Australia/Melbourne + BEGIN:STANDARD + TZOFFSETFROM:+1100 + RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU + DTSTART:20080406T030000 + TZNAME:AEST + TZOFFSETTO:+1000 + END:STANDARD + BEGIN:DAYLIGHT + TZOFFSETFROM:+1000 + RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU + DTSTART:20081005T020000 + TZNAME:AEDT + TZOFFSETTO:+1100 + END:DAYLIGHT + END:VTIMEZONE + END:VCALENDAR + EOF +} + +sub NEW_YORK { + return <<~'EOF'; + BEGIN:VCALENDAR + BEGIN:VTIMEZONE + TZID:America/New_York + BEGIN:DAYLIGHT + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + DTSTART:20070311T020000 + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU + END:DAYLIGHT + BEGIN:STANDARD + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + DTSTART:20071104T020000 + RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU + END:STANDARD + END:VTIMEZONE + END:VCALENDAR + EOF +} + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane'); + $config->set(httpmodules => 'caldav'); + $config->set(calendar_user_address_set => 'example.com'); + $config->set(httpallowcompress => 'no'); + $config->set(caldav_historical_age => -1); + $config->set(icalendar_max_size => 100000); + $config->set(event_extra_params => 'vnd.cmu.davFilename vnd.cmu.davUid'); + $config->set(event_groups => 'calendar'); + $config->set(imipnotifier => 'imip'); + return $class->SUPER::new({ + config => $config, + adminstore => 1, + services => ['imap', 'http'], + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $ENV{DEBUGDAV} = 1; + $ENV{JMAP_ALWAYS_FULL} = 1; +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub _all_keys_match +{ + my $a = shift; + my $b = shift; + my $errors = shift; + + my $ref = ref($a); + unless ($ref eq ref($b)) { + push @$errors, "mismatched refs $ref / " . ref($b); + return 0; + } + + unless ($ref) { + unless (defined $a) { + return 1 unless defined $b; + return 0; + } + return 0 unless defined $b; + if (lc $a ne lc $b) { + push @$errors, "not equal $a / $b"; + return 0; + } + return 1; + } + + if ($ref eq 'ARRAY') { + my @payloads = @$b; + my @nomatch; + foreach my $item (@$a) { + my $match; + my @rest; + foreach my $payload (@payloads) { + if (not $match and _all_keys_match($item, $payload, [])) { + $match = $payload; + } + else { + push @rest, $payload; + } + } + push @nomatch, $item unless $match; + @payloads = @rest; + } + if (@payloads or @nomatch) { + push @$errors, "failed to match\n" . Dumper(\@nomatch, \@payloads); + return 0; + } + return 1; + } + + if ($ref eq 'HASH') { + foreach my $key (keys %$a) { + unless (exists $b->{$key}) { + push @$errors, "no key $key"; + return 0; + } + my @err; + unless (_all_keys_match($a->{$key}, $b->{$key}, \@err)) { + push @$errors, "mismatch for $key: @err"; + return 0; + } + } + return 1; + } + + if ($ref eq 'JSON::PP::Boolean' or $ref eq 'JSON::XS::Boolean') { + if ($a != $b) { + push @$errors, "mismatched boolean " . (!!$a) . " / " . (!!$b); + return 0; + } + return 1; + } + + die "WEIRD REF $ref for $a"; +} + +sub assert_caldav_notified +{ + my $self = shift; + my @expected = @_; + + my $newdata = $self->{instance}->getnotify(); + my @imip = grep { $_->{METHOD} eq 'imip' } @$newdata; + my @payloads = map { decode_json($_->{MESSAGE}) } @imip; + foreach my $payload (@payloads) { + ($payload->{event}) = $self->{caldav}->vcalendarToEvents($payload->{ical}); + $payload->{method} = delete $payload->{event}{method}; + } + + my @err; + unless (_all_keys_match(\@expected, \@payloads, \@err)) { + $self->fail("@err"); + } +} + +sub _put_event { + my $self = shift; + my $CalendarId = shift; + my %props = @_; + my $uuid = delete $props{uuid} || $self->{caldav}->genuuid(); + my $href = "$CalendarId/$uuid.ics"; + + my $card = <{caldav}->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); +} + +sub bogus_test_rfc6638_3_2_1_setpartstat_agentserver +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "attempt to set the partstat to something other than NEEDS-ACTION"; + # XXX - the server should reject this + $self->_put_event($CalendarId, lines => <assert_caldav_notified( + { recipient => "test2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); +} + +############ REPLIES ############# + +sub slurp { + my $testdir = shift; + my $name = shift; + my $ext = shift; + + return slurp_file("$testdir/$name.$ext"); +} + +sub _safeeq { + my ($a, $b) = @_; + my $json = JSON::XS->new->canonical; + return $json->encode([$a]) eq $json->encode([$b]); +} + +use Cassandane::Tiny::Loader 'tiny-tests/Caldav'; + +1; diff --git a/cassandane/Cassandane/Cyrus/CaldavAlarm.pm b/cassandane/Cassandane/Cyrus/CaldavAlarm.pm new file mode 100644 index 0000000000..526011e184 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/CaldavAlarm.pm @@ -0,0 +1,169 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::CaldavAlarm; +use strict; +use warnings; +use DateTime; +use DateTime::Format::ISO8601; +use JSON::XS; +use Net::CalDAVTalk 0.05; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use POSIX; +use Carp; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane'); + $config->set(conversations => 'yes'); + $config->set(httpmodules => 'caldav jmap tzdist'); + $config->set(httpallowcompress => 'no'); + $config->set(caldav_historical_age => -1); + $config->set(calendar_minimum_alarm_interval => '61s'); + $config->set(jmap_nonstandard_extensions => 'yes'); + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => ['imap', 'http'], + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + $self->{caldav} = Net::CalDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'urn:ietf:params:jmap:calendars:preferences', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/debug', + ]); +} + +sub _can_match { + my $event = shift; + my $want = shift; + + # I wrote a really good one of these for Caldav, but this will do for now + foreach my $key (keys %$want) { + return 0 if not exists $event->{$key}; + return 0 if $event->{$key} ne $want->{$key}; + } + + return 1; +} + +sub assert_alarms { + my $self = shift; + my @want = @_; + # pick first calendar alarm from notifications + my $data = $self->{instance}->getnotify(); + if ($self->{replica}) { + my $more = $self->{replica}->getnotify(); + push @$data, @$more; + } + my @events; + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @events, $e; + } + } + } + + my @left; + while (my $event = shift @events) { + my $found = 0; + my @newwant; + foreach my $data (@want) { + if (not $found and _can_match($event, $data)) { + $found = 1; + } + else { + push @newwant, $data; + } + } + if (not $found) { + push @left, $event; + } + @want = @newwant; + } + + if (@want or @left) { + my $dump = Data::Dumper->Dump([\@want, \@left], [qw(want left)]); + $self->assert_equals(0, scalar @want, + "expected events were not received:\n$dump"); + $self->assert_equals(0, scalar @left, + "unexpected extra events were received:\n$dump"); + } +} + +sub tear_down +{ + my ($self) = @_; + + $self->SUPER::tear_down(); +} + +use Cassandane::Tiny::Loader 'tiny-tests/CaldavAlarm'; + +1; diff --git a/cassandane/Cassandane/Cyrus/Carddav.pm b/cassandane/Cassandane/Cyrus/Carddav.pm new file mode 100644 index 0000000000..35e43988e5 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Carddav.pm @@ -0,0 +1,87 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Carddav; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::DAVTalk 0.14; +use Net::CardDAVTalk 0.05; +use Net::CardDAVTalk::VCard; +use Data::Dumper; +use XML::Spice; +use XML::Simple; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane'); + $config->set(httpmodules => 'carddav caldav'); + $config->set(httpallowcompress => 'no'); + $config->set(vcard_max_size => 100000); + return $class->SUPER::new({ + adminstore => 1, + config => $config, + services => ['imap', 'http'], + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $ENV{DEBUGDAV} = 1; +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +use Cassandane::Tiny::Loader 'tiny-tests/Carddav'; + +1; diff --git a/cassandane/Cassandane/Cyrus/ClamAV.pm b/cassandane/Cassandane/Cyrus/ClamAV.pm new file mode 100644 index 0000000000..4af504b969 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/ClamAV.pm @@ -0,0 +1,325 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::ClamAV; +use strict; +use warnings; +use Cwd qw(abs_path); +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Instance; + +$Data::Dumper::Sortkeys = 1; + +my %eicar_attached = ( + mime_type => "multipart/mixed", + mime_boundary => "boundary", + body => "" + . "--boundary\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body" + . "\r\n" + . "--boundary\r\n" + . "Content-Disposition: attachment; filename=eicar.txt;\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + # This is the EICAR AV test file: + # http://www.eicar.org/83-0-Anti-Malware-Testfile.html + . 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' + . "\r\n" + . "--boundary\r\n", +); + +my %custom_header = ( + 'extra_headers' => [ + [ 'x-delete-me' => 'please' ], + ], +); + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_aaasetup + :needs_dependency_clamav +{ + my ($self) = @_; + + # does everything set up and tear down cleanly? + $self->assert(1); +} + +# This test uses the AV engine, which can be very slow to initialise. +sub test_remove_infected_slow + :needs_dependency_clamav :NoAltNamespace +{ + my ($self) = @_; + + # set up a shared folder that's easy to write to + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('shared.folder'); + $admintalk->setacl('shared.folder', 'cassandane' => 'lrswipkxtecd'); + + $self->{store}->set_fetch_attributes(qw(uid flags)); + + my $talk = $self->{store}->get_client(); + $talk->select("INBOX"); + $self->assert_num_equals(1, $talk->uid()); + $talk->select("shared.folder"); + $self->assert_num_equals(1, $talk->uid()); + + # put some test messages in INBOX (and verify) + $self->{store}->set_folder("INBOX"); + my %cass_exp; + $cass_exp{1} = $self->make_message("eicar attached", uid => 1, %eicar_attached); + $cass_exp{2} = $self->make_message("clean", uid => 2); + $self->check_messages(\%cass_exp, ( keyed_on => 'uid' )); + + # put some test messages in shared.folder (and verify) + $self->{store}->set_folder("shared.folder"); + my %shared_exp; + $shared_exp{1} = $self->make_message("eicar attached", uid => 1, %eicar_attached); + $shared_exp{2} = $self->make_message("clean", uid => 2); + $self->check_messages(\%shared_exp, ( keyed_on => 'uid' )); + + # run cyr_virusscan + my $out = "$self->{instance}->{basedir}/$self->{_name}-cyr_virusscan.stdout"; + $self->{instance}->run_command( + { cyrus => 1, + redirects => { 'stdout' => $out }, + }, 'cyr_virusscan', '-r'); + + # check the output + # user.cassandane 1 UNREAD Eicar-Test-Signature + # shared.folder 1 UNREAD Eicar-Test-Signature + $out = slurp_file($out); + xlog $self, $out; + + # XXX is there a better way than hard coding UID:1 ? + my ($v) = Cassandane::Instance->get_version(); + if ($v >= 3) { + $self->assert_matches( + qr/user\.cassandane\s+1\s+UNREAD\s+Eicar(?:-Test){0,1}-Signature/, + $out); + $self->assert_matches( + qr/shared\.folder\s+1\s+UNREAD\s+Eicar(?:-Test){0,1}-Signature/, + $out); + } + else { + # pre-3.0 a different output format was used + $self->assert_matches( + qr/Working\son\sshared\.folder\.\.\.\nVirus\sdetected\sin\smessage\s1:\sEicar(?:-Test){0,1}-Signature/, + $out); + $self->assert_matches( + qr/Working\son\suser\.cassandane\.\.\.\nVirus\sdetected\sin\smessage\s1:\sEicar(?:-Test){0,1}-Signature/, + $out); + } + + # make sure the infected ones were expunged, but the clean ones weren't + $self->{store}->set_folder("INBOX"); + delete $cass_exp{1}; + $self->check_messages(\%cass_exp, ( keyed_on => 'uid' )); + + $self->{store}->set_folder("shared.folder"); + delete $shared_exp{1}; + $self->check_messages(\%shared_exp, ( keyed_on => 'uid' )); +} + +# This test uses the '-s search-string' invocation, which is much faster +# than waiting for the AV engine to load when we just care about whether +# the notification gets sent +sub test_notify_deleted + :needs_dependency_clamav +{ + my ($self) = @_; + + $self->{store}->set_fetch_attributes(qw(uid flags)); + + # put some test messages in INBOX (and verify) + $self->{store}->set_folder("INBOX"); + my %cass_exp; + $cass_exp{1} = $self->make_message("custom header 1", uid => 1, %custom_header); + $cass_exp{2} = $self->make_message("custom header 2", uid => 2, %custom_header); + $self->check_messages(\%cass_exp, ( keyed_on => 'uid' )); + + # run cyr_virusscan + $self->{instance}->run_command({ cyrus => 1, }, + 'cyr_virusscan', '-r', '-n', + '-s', 'header "x-delete-me" "please"'); + + # let's see what's in there now + my $found_notifications = 0; + $self->{store}->read_begin(); + while (my $msg = $self->{store}->read_message()) { + # should not be any of our test messages remaining + $self->assert_null($msg->get_header('x-cassandane-unique')); + + # if we find something that looks like a notification, check it + if ($msg->get_header('message-id') =~ m{^get_body(); +# xlog $self, "body:\n>>>>>>\n$body<<<<<<"; + + # make sure report body includes all our infected tests + foreach my $exp (values %cass_exp) { + my $message_id = $exp->get_header('message-id'); + $self->assert_matches(qr/Message-ID: $message_id/, $body); + + my $subject = $exp->get_header('subject'); + $self->assert_matches(qr/Subject: $subject/, $body); + + my $uid = $exp->get_attribute('uid'); + $self->assert_matches(qr/IMAP UID: $uid/, $body); + } + + # make sure the message was removed for the reason we expect + $self->assert_matches(qr/Cyrus Administrator Targeted Removal/, + $body); + } + } + $self->{store}->read_end(); + + # finally, there should've been exactly one notification email sent + $self->assert_num_equals(1, $found_notifications); +} + +# This test uses the '-s search-string' invocation, which is much faster +# than waiting for the AV engine to load when we just care about whether +# the custom notification gets sent +# XXX https://github.com/cyrusimap/cyrus-imapd/issues/2516 might be +# XXX backported to 3.0 if anyone volunteers to test it +sub test_custom_notify_deleted + :needs_dependency_clamav :NoStartInstances + :min_version_3_1 +{ + my ($self) = @_; + + # set up a custom notification template + $self->{instance}->{config}->set( + virusscan_notification_subject => 'custom ½ subject', + virusscan_notification_template => + abs_path('data/custom-notification-template'), + ); + $self->_start_instances(); + + $self->{store}->set_fetch_attributes(qw(uid flags)); + + # put some test messages in INBOX (and verify) + $self->{store}->set_folder("INBOX"); + my %cass_exp; + $cass_exp{1} = $self->make_message("custom header 1", uid => 1, %custom_header); + $cass_exp{2} = $self->make_message("custom header 2", uid => 2, %custom_header); + $self->check_messages(\%cass_exp, ( keyed_on => 'uid' )); + + # run cyr_virusscan + $self->{instance}->run_command({ cyrus => 1, }, + 'cyr_virusscan', '-r', '-n', + '-s', 'header "x-delete-me" "please"'); + + # let's see what's in there now + my $found_notifications = 0; + $self->{store}->read_begin(); + while (my $msg = $self->{store}->read_message()) { + # should not be any of our test messages remaining + $self->assert_null($msg->get_header('x-cassandane-unique')); + + # if we find something that looks like a notification, check it + if ($msg->get_header('message-id') =~ m{^get_header('subject'); +# xlog $self, "subject: $subject"; + + # make sure our custom subject was used (and correctly encoded) + $self->assert_str_equals('=?UTF-8?Q?custom_=C2=BD_subject?=', + $subject); + + my $body = $msg->get_body(); +# xlog $self, "body:\n>>>>>>\n$body<<<<<<"; + + # make sure report body includes all our infected tests + foreach my $exp (values %cass_exp) { + my $message_id = $exp->get_header('message-id'); + $self->assert_matches(qr/Message-ID: $message_id/, $body); + + my $subject = $exp->get_header('subject'); + $self->assert_matches(qr/Subject: $subject/, $body); + + my $uid = $exp->get_attribute('uid'); + $self->assert_matches(qr/IMAP UID: $uid/, $body); + } + + # make sure the message was removed for the reason we expect + $self->assert_matches(qr/Cyrus Administrator Targeted Removal/, + $body); + + # make sure our custom notification template was used + $self->assert_matches(qr/^custom notification!/, $body); + + # make sure message was qp-encoded + $self->assert_matches(qr/with =C2=BD as much 8bit/, $body); + } + } + $self->{store}->read_end(); + + # finally, there should've been exactly one notification email sent + $self->assert_num_equals(1, $found_notifications); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Conversations.pm b/cassandane/Cassandane/Cyrus/Conversations.pm new file mode 100644 index 0000000000..232f6270c8 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Conversations.pm @@ -0,0 +1,1175 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Conversations; +use strict; +use warnings; +use DateTime; +use URI::Escape; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::ThreadedGenerator; +use Cassandane::Util::Log; +use Cassandane::Util::DateTime qw(to_iso8601 from_iso8601 + from_rfc822 + to_rfc3501 from_rfc3501); + +use lib '../perl/imap'; +use Cyrus::IndexFile; + +sub new +{ + my ($class, @args) = @_; + my $config = Cassandane::Config->default()->clone(); + + my $buildinfo = Cassandane::BuildInfo->new(); + + # if we're gonna try and run jmap tests, set up config for it + if ($buildinfo->get('component', 'jmap')) { + $config->set(caldav_realm => 'Cassandane', + conversations => 'yes', + conversations_counted_flags => "\\Draft \\Flagged \$IsMailingList \$IsNotification \$HasAttachment", + jmapsubmission_deleteonsend => 'no', + ctl_conversationsdb_conversations_max_thread => 5, + httpmodules => 'carddav caldav jmap', + httpallowcompress => 'no'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => [ 'imap', 'http' ] + }, @args); + } + else { + $config->set(conversations => 'yes', + conversations_counted_flags => "\\Draft \\Flagged \$IsMailingList \$IsNotification \$HasAttachment", + ctl_conversationsdb_conversations_max_thread => 5); + + return $class->SUPER::new({ config => $config }, @args); + } +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + my ($maj, $min) = Cassandane::Instance->get_version(); + + # basecid was added after 3.0 + if ($maj > 3 or ($maj == 3 and $min > 0)) { + $self->{store}->set_fetch_attributes('uid', 'cid', 'basecid'); + } + else { + $self->{store}->set_fetch_attributes('uid', 'cid'); + } +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# The resulting CID when a clash happens is supposed to be +# the MAXIMUM of all the CIDs. Here we use the fact that +# CIDs are expressed in a form where lexical order is the +# same as numeric order. +sub choose_cid +{ + my (@cids) = @_; + @cids = sort { $b cmp $a } @cids; + return $cids[0]; +} + +# +# Test APPEND of messages to IMAP +# +sub test_append + :min_version_3_0 +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + $exp{B} = $self->make_message("Message B"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message C"; + $exp{C} = $self->make_message("Message C"); + $exp{C}->set_attributes(uid => 3, cid => $exp{C}->make_cid()); + my $actual = $self->check_messages(\%exp); + + xlog $self, "generating message D"; + $exp{D} = $self->make_message("Message D"); + $exp{D}->set_attributes(uid => 4, cid => $exp{D}->make_cid()); + $self->check_messages(\%exp); +} + +# +# Test APPEND of messages to IMAP +# +sub test_append_reply + :min_version_3_0 +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + $exp{B} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{B}->set_attributes(uid => 2, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); +} + +# +# Test APPEND of messages to IMAP +# +sub test_append_reply_200 + :min_version_3_1 +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating replies"; + for (1..99) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + $exp{"B"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"B"}->set_attributes(uid => 101, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + for (1..99) { + $exp{"B$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"B$_"}->set_attributes(uid => 101+$_, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + } + $exp{"C"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"C"}->set_attributes(uid => 201, cid => $exp{C}->make_cid(), basecid => $exp{A}->make_cid()); + + $self->check_messages(\%exp, keyed_on => 'uid'); +} + +# +# Test MOVE of messages after conversation split +# +sub test_move_200 + :min_version_3_1 +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating replies"; + for (1..99) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + $exp{"B"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"B"}->set_attributes(uid => 101, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + for (1..99) { + $exp{"B$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"B$_"}->set_attributes(uid => 101+$_, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + } + $exp{"C"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"C"}->set_attributes(uid => 201, cid => $exp{C}->make_cid(), basecid => $exp{A}->make_cid()); + + $self->check_messages(\%exp, keyed_on => 'uid'); + + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.foo"); + $talk->select("INBOX"); + # NOTE: 110 here becomes 109 after '9' is already moved + $talk->fetch('9,110', '(emailid threadid)'); + $talk->move('9', "INBOX.foo"); + $talk->move('109', "INBOX.foo"); + $talk->select("INBOX.foo"); + my $res = $talk->fetch('1:2', '(emailid threadid)'); + my $emailid1 = $res->{1}{emailid}[0]; + my $threadid1 = $res->{1}{threadid}[0]; + my $emailid2 = $res->{2}{emailid}[0]; + my $threadid2 = $res->{2}{threadid}[0]; + $self->assert_str_equals($threadid1, 'T' . $exp{A}->make_cid()); + $self->assert_str_equals($threadid2, 'T' . $exp{B}->make_cid()); + + # XXX probably should split the jmap stuff below into a separate + # XXX test, so we can just mark it :needs_component_jmap instead + # XXX of hacking it up like this... :) + my $buildinfo = Cassandane::BuildInfo->new(); + if (not $buildinfo->get('component', 'jmap')) { + return; + } + + my $jmap = $self->{jmap}; + xlog $self, "create bar mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "bar", + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $bar = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['Email/set', { update => { + $emailid1 => { mailboxIds => { $bar => $JSON::true } }, + $emailid2 => { mailboxIds => { $bar => $JSON::true } }, + }}, "R1"] + ]); + + $self->assert_str_equals('Email/set', $res->[0][0]); + $self->assert(exists $res->[0][1]{updated}{$emailid1}); + $self->assert(exists $res->[0][1]{updated}{$emailid2}); + $self->assert_str_equals('R1', $res->[0][2]); + + $res = $jmap->CallMethods([ + ['Email/get', { ids => [$emailid1,$emailid2], properties => ['threadId'] + }, "R1"] + ]); + + $self->assert_str_equals('Email/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my %map = map { $_->{id} => $_->{threadId} } @{$res->[0][1]{list}}; + $self->assert_str_equals($map{$emailid1}, $threadid1); + $self->assert_str_equals($map{$emailid2}, $threadid2); +} + +# +# Test normalisation of Subjects containing nonascii whitespace +# +# At present, non-breaking space is the only nonascii whitespace +# our normalisation supports +# +# The normalisation function is properly tested in the cunit tests, +# but we need a test out here too to verify that it works when +# decoded from the real world! +# +sub test_normalise_nonascii_whitespace + :min_version_3_0 +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + # we saw in the wild a message with an encoded nbsp in the subject... + $exp{A} = $self->make_message("=?UTF-8?Q?hello=C2=A0there?="); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + # ... but the reply had replaced this with a normal space + $exp{B} = $self->make_message("Re: hello there", references => [ $exp{A} ]); + $exp{B}->set_attributes(uid => 2, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); +} + +# +# test reconstruct of larger conversation +# +sub test_reconstruct_splitconv + :min_version_3_1 +{ + my ($self) = @_; + my %exp; + + my $talk = $self->{store}->get_client(); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($talk->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating replies"; + for (1..20) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + + $talk->create('foo'); + $talk->copy('1:*', 'foo'); + + $self->check_messages(\%exp, keyed_on => 'uid'); + + # first run WITHOUT splitting + $self->{instance}->run_command({ cyrus => 1 }, 'ctl_conversationsdb', '-R', '-r'); + + $self->check_messages(\%exp, keyed_on => 'uid'); + $talk->select("foo"); + $self->check_messages(\%exp, keyed_on => 'uid'); + $talk->select("INBOX"); + + # then run WITH splitting, and see the changed CIDs + $self->{instance}->run_command({ cyrus => 1 }, 'ctl_conversationsdb', '-R', '-r', '-S'); + + for (5..9) { + $exp{"A$_"}->set_attributes(cid => $exp{"A5"}->make_cid(), basecid => $exp{A}->make_cid()); + } + for (10..14) { + $exp{"A$_"}->set_attributes(cid => $exp{"A10"}->make_cid(), basecid => $exp{A}->make_cid()); + } + for (15..19) { + $exp{"A$_"}->set_attributes(cid => $exp{"A15"}->make_cid(), basecid => $exp{A}->make_cid()); + } + $exp{"A20"}->set_attributes(cid => $exp{"A20"}->make_cid(), basecid => $exp{A}->make_cid()); + + $self->check_messages(\%exp, keyed_on => 'uid'); + $talk->select("foo"); + $self->check_messages(\%exp, keyed_on => 'uid'); + $talk->select("INBOX"); + + # zero everything out + $self->{instance}->run_command({ cyrus => 1 }, 'ctl_conversationsdb', '-z', 'cassandane'); + + # rebuild + $self->{instance}->run_command({ cyrus => 1 }, 'ctl_conversationsdb', '-b', 'cassandane'); + + $self->check_messages(\%exp, keyed_on => 'uid'); + $talk->select("foo"); + $self->check_messages(\%exp, keyed_on => 'uid'); + $talk->select("INBOX"); + + # support for -Z was added after 3.8 + my ($maj, $min) = Cassandane::Instance->get_version(); + return if ($maj < 3 or ($maj == 3 and $min < 8)); + + # zero out ONLY two CIDs + $self->{instance}->run_command({ cyrus => 1 }, 'ctl_conversationsdb', + '-Z' => $exp{"A15"}->make_cid(), + '-Z' => $exp{"A10"}->make_cid(), + 'cassandane'); + for (10..19) { + $exp{"A$_"}->set_attributes(cid => undef, basecid => undef); + } + + $self->check_messages(\%exp, keyed_on => 'uid'); + $talk->select("foo"); + $self->check_messages(\%exp, keyed_on => 'uid'); + $talk->select("INBOX"); +} + +# +# Test APPEND of messages to IMAP +# +sub _munge_annot_crc +{ + my ($instance, $file, $value) = @_; + + # this needs a bit of magic to know where to write... so + # we do some hard-coded cyrus.index handling + my $fh = IO::File->new($file, "+<"); + die "NO SUCH FILE $file" unless $fh; + my $index = Cyrus::IndexFile->new($fh); + + my $header = $index->header(); + $header->{SyncCRCsAnnot} = $value; + $index->rewrite_header($header); + + $fh->close(); +} +sub test_replication_reply_200 + :min_version_3_1 :needs_component_replication +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + $master_store->set_fetch_attributes('uid', 'cid', 'basecid'); + $replica_store->set_fetch_attributes('uid', 'cid', 'basecid'); + + $self->assert($master_store->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + xlog $self, "checking message A on master"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "running replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + xlog $self, "checking message A on replica"; + $self->check_messages(\%exp, store => $replica_store); + + xlog $self, "generating replies"; + for (1..99) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ], store => $master_store); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + $exp{"B"} = $self->make_message("Re: Message A", references => [ $exp{A} ], store => $master_store); + $exp{"B"}->set_attributes(uid => 101, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + for (1..99) { + $exp{"B$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ], store => $master_store); + $exp{"B$_"}->set_attributes(uid => 101+$_, cid => $exp{B}->make_cid(), basecid => $exp{A}->make_cid()); + } + $exp{"C"} = $self->make_message("Re: Message A", references => [ $exp{A} ], store => $master_store); + $exp{"C"}->set_attributes(uid => 201, cid => $exp{C}->make_cid(), basecid => $exp{A}->make_cid()); + + # this shouldn't make any difference, but it doesn when you're not logging annotation + # usage for split conversations properly, so just leaving it here to break this unrelated-ish test and gain + # the benefits of check_replication's annotsize check + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', '-u' => 'cassandane'); + + $self->check_messages(\%exp, keyed_on => 'uid', store => $master_store); + $self->run_replication(); + $self->check_replication('cassandane'); + $self->check_messages(\%exp, keyed_on => 'uid', store => $replica_store); + + # corrupt the sync_annot_crc at both ends and check that we can fix it without syncback + xlog $self, "Damaging annotations CRCs"; + my $mpath = $self->{instance}->folder_to_directory('user.cassandane'); + my $rpath = $self->{replica}->folder_to_directory('user.cassandane'); + _munge_annot_crc($self->{instance}, "$mpath/cyrus.index", 1); + _munge_annot_crc($self->{replica}, "$rpath/cyrus.index", 2); + + $self->run_replication(nosyncback => 1); + $self->check_replication('cassandane'); + $self->check_messages(\%exp, keyed_on => 'uid', store => $replica_store); + + xlog $self, "Creating a message on the replica now to make sure it gets the right CID"; + $exp{"D"} = $self->make_message("Re: Message A", references => [ $exp{A} ], store => $replica_store); + $exp{"D"}->set_attributes(uid => 202, cid => $exp{C}->make_cid(), basecid => $exp{A}->make_cid()); + $self->check_messages(\%exp, keyed_on => 'uid', store => $replica_store); +} + +# +# Test APPEND of messages to IMAP +# +sub test_replication_reconstruct + :min_version_3_1 :needs_component_replication +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + $master_store->set_fetch_attributes('uid', 'cid', 'basecid'); + $replica_store->set_fetch_attributes('uid', 'cid', 'basecid'); + + $self->assert($master_store->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + xlog $self, "checking message A on master"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "running replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + xlog $self, "checking message A on replica"; + $self->check_messages(\%exp, store => $replica_store); + + xlog $self, "generating replies"; + for (1..20) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ], store => $master_store); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + + $self->check_messages(\%exp, keyed_on => 'uid', store => $master_store); + $self->run_replication(); + $self->check_replication('cassandane'); + $self->check_messages(\%exp, keyed_on => 'uid', store => $replica_store); + + $self->{instance}->run_command({ cyrus => 1 }, 'ctl_conversationsdb', '-R', '-r', '-S'); + + for (5..9) { + $exp{"A$_"}->set_attributes(cid => $exp{"A5"}->make_cid(), basecid => $exp{A}->make_cid()); + } + for (10..14) { + $exp{"A$_"}->set_attributes(cid => $exp{"A10"}->make_cid(), basecid => $exp{A}->make_cid()); + } + for (15..19) { + $exp{"A$_"}->set_attributes(cid => $exp{"A15"}->make_cid(), basecid => $exp{A}->make_cid()); + } + $exp{"A20"}->set_attributes(cid => $exp{"A20"}->make_cid(), basecid => $exp{A}->make_cid()); + + $self->check_messages(\%exp, keyed_on => 'uid', store => $master_store); + $self->run_replication(); + $self->check_replication('cassandane'); + $self->check_messages(\%exp, keyed_on => 'uid', store => $replica_store); + + xlog $self, "Creating a message on the replica now to make sure it gets the right CID"; + $exp{"D"} = $self->make_message("Re: Message A", references => [ $exp{A} ], store => $replica_store); + $exp{"D"}->set_attributes(uid => 22, cid => $exp{"A20"}->make_cid(), basecid => $exp{A}->make_cid()); + $self->check_messages(\%exp, keyed_on => 'uid', store => $replica_store); +} + + +# +# Test APPEND of messages to IMAP which results in a CID clash. +# +sub bogus_test_append_clash + :min_version_3_0 +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + $exp{B} = $self->make_message("Message B"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + my $actual = $self->check_messages(\%exp); + + xlog $self, "generating message C"; + my $ElCid = choose_cid($exp{A}->get_attribute('cid'), + $exp{B}->get_attribute('cid')); + $exp{C} = $self->make_message("Message C", + references => [ $exp{A}, $exp{B} ], + ); + $exp{C}->set_attributes(uid => 3, cid => $ElCid); + + # Since IRIS-293, inserting this message will have the side effect + # of renumbering some of the existing messages. Predict and test + # which messages get renumbered. + my $nextuid = 4; + foreach my $s (qw(A B)) + { + if ($actual->{"Message $s"}->make_cid() ne $ElCid) + { + $exp{$s}->set_attributes(uid => $nextuid, cid => $ElCid); + $nextuid++; + } + } + + $self->check_messages(\%exp); +} + +# +# Test APPEND of messages to IMAP which results in multiple CID clashes. +# +sub bogus_test_double_clash + :min_version_3_0 +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + $exp{B} = $self->make_message("Message B"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message C"; + $exp{C} = $self->make_message("Message C"); + $exp{C}->set_attributes(uid => 3, cid => $exp{C}->make_cid()); + my $actual = $self->check_messages(\%exp); + + xlog $self, "generating message D"; + my $ElCid = choose_cid($exp{A}->get_attribute('cid'), + $exp{B}->get_attribute('cid'), + $exp{C}->get_attribute('cid')); + $exp{D} = $self->make_message("Message D", + references => [ $exp{A}, $exp{B}, $exp{C} ], + ); + $exp{D}->set_attributes(uid => 4, cid => $ElCid); + + # Since IRIS-293, inserting this message will have the side effect + # of renumbering some of the existing messages. Predict and test + # which messages get renumbered. + my $nextuid = 5; + foreach my $s (qw(A B C)) + { + if ($actual->{"Message $s"}->make_cid() ne $ElCid) + { + $exp{$s}->set_attributes(uid => $nextuid, cid => $ElCid); + $nextuid++; + } + } + + $self->check_messages(\%exp); +} + +# +# Test that a CID clash resolved on the master is replicated +# +sub bogus_test_replication_clash + :min_version_3_0 :needs_component_replication +{ + my ($self) = @_; + my %exp; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + $master_store->set_fetch_attributes('uid', 'cid'); + $replica_store->set_fetch_attributes('uid', 'cid'); + + # Double check that we're connected to the servers + # we wanted to be connected to. + $self->assert($master_store->{host} eq $replica_store->{host}); + $self->assert($master_store->{port} != $replica_store->{port}); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($master_store->get_client()->capability()->{xconversations}); + $self->assert($replica_store->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->run_replication(); + $self->check_replication('cassandane'); + $self->check_messages(\%exp, store => $master_store); + $self->check_messages(\%exp, store => $replica_store); + + xlog $self, "generating message B"; + $exp{B} = $self->make_message("Message B", store => $master_store); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + $self->run_replication(); + $self->check_replication('cassandane'); + $self->check_messages(\%exp, store => $master_store); + $self->check_messages(\%exp, store => $replica_store); + + xlog $self, "generating message C"; + $exp{C} = $self->make_message("Message C", store => $master_store); + $exp{C}->set_attributes(uid => 3, cid => $exp{C}->make_cid()); + $self->run_replication(); + $self->check_replication('cassandane'); + my $actual = $self->check_messages(\%exp, store => $master_store); + $self->check_messages(\%exp, store => $replica_store); + + xlog $self, "generating message D"; + my $ElCid = choose_cid($exp{A}->get_attribute('cid'), + $exp{B}->get_attribute('cid'), + $exp{C}->get_attribute('cid')); + $exp{D} = $self->make_message("Message D", + store => $master_store, + references => [ $exp{A}, $exp{B}, $exp{C} ], + ); + $exp{D}->set_attributes(uid => 4, cid => $ElCid); + + # Since IRIS-293, inserting this message will have the side effect + # of renumbering some of the existing messages. Predict and test + # which messages get renumbered. + my $nextuid = 5; + foreach my $s (qw(A B C)) + { + if ($actual->{"Message $s"}->make_cid() ne $ElCid) + { + $exp{$s}->set_attributes(uid => $nextuid, cid => $ElCid); + $nextuid++; + } + } + + $self->run_replication(); + $self->check_replication('cassandane'); + $self->check_messages(\%exp, store => $master_store); + $self->check_messages(\%exp, store => $replica_store); +} + +# +# Test APPEND of a new composed draft message to the Drafts folder by +# the Fastmail webui, which sets the X-ME-Message-ID header to thread +# conversations but not any of Message-ID, References, or In-Reply-To. +# +sub bogus_test_fm_webui_draft + :min_version_3_0 +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->{gen}->generate(subject => 'Draft message A'); + $exp{A}->remove_headers('Message-ID'); +# $exp{A}->add_header('X-ME-Message-ID', ''); + $exp{A}->add_header('X-ME-Message-ID', ''); + $exp{A}->set_attribute(cid => $exp{A}->make_cid()); + + $self->{store}->write_begin(); + $self->{store}->write_message($exp{A}); + $self->{store}->write_end(); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + $exp{B} = $exp{A}->clone(); + $exp{B}->set_headers('Subject', 'Draft message B'); + $exp{B}->set_body("Completely different text here\r\n"); + + $self->{store}->write_begin(); + $self->{store}->write_message($exp{B}); + $self->{store}->write_end(); + $self->check_messages(\%exp); +} + +# +# Test a COPY between folders owned by different users +# +sub bogus_test_cross_user_copy + :min_version_3_0 +{ + my ($self) = @_; + my $bobuser = "bob"; + my $bobfolder = "user.$bobuser"; + + xlog $self, "Testing COPY between folders owned by different users [IRIS-893]"; + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + my $srv = $self->{instance}->get_service('imap'); + + $self->{instance}->create_user($bobuser); + + my $adminstore = $srv->create_store(username => 'admin'); + my $adminclient = $adminstore->get_client(); + $adminclient->setacl('user.cassandane', $bobuser => 'lrswipkxtecda') + or die "Cannot setacl on user.cassandane: $@"; + + xlog $self, "generating two messages"; + my %exp; + $exp{A} = $self->{gen}->generate(subject => 'Message A'); + my $cid = $exp{A}->make_cid(); + $exp{A}->set_attribute(cid => $cid); + $exp{B} = $self->{gen}->generate(subject => 'Message B', + references => [ $exp{A} ]); + $exp{B}->set_attribute(cid => $cid); + + xlog $self, "Writing messaged to user.cassandane"; + $self->{store}->write_begin(); + $self->{store}->write_message($exp{A}); + $self->{store}->write_message($exp{B}); + $self->{store}->write_end(); + xlog $self, "Check that the messages made it"; + $self->check_messages(\%exp); + + my $bobstore = $srv->create_store(username => $bobuser); + $bobstore->set_fetch_attributes('uid', 'cid'); + my $bobclient = $bobstore->get_client(); + $bobstore->set_folder('user.cassandane'); + $bobstore->_select(); + $bobclient->copy(2, $bobfolder) + or die "Cannot COPY message to $bobfolder"; + + xlog $self, "Check that the message made it to $bobfolder"; + my %bobexp; + $bobexp{B} = $exp{B}->clone(); + $bobexp{B}->set_attributes(uid => 1, cid => $exp{B}->make_cid()); + $bobstore->set_folder($bobfolder); + $self->check_messages(\%bobexp, store => $bobstore); +} + +# +# Test APPEND of messages to IMAP +# +sub test_replication_trashseen + :min_version_3_1 :needs_component_replication +{ + my ($self) = @_; + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + $master_store->set_fetch_attributes('uid', 'cid'); + $replica_store->set_fetch_attributes('uid', 'cid'); + + my $mtalk = $master_store->get_client(); + + $self->assert($mtalk->capability()->{xconversations}); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + xlog $self, "checking message A on master"; + $self->check_messages(\%exp, store => $master_store); + + $mtalk->create("INBOX.Trash"); + $mtalk->select("INBOX"); + $mtalk->store('1', '+flags', '\\Seen'); + $mtalk->move('1', 'INBOX.Trash'); + $mtalk->select('INBOX.Trash'); + $mtalk->store('1', '-flags', '\\Seen'); + + xlog $self, "running replication"; + $self->run_replication(); + $self->check_replication('cassandane'); +} + +# +# Test limits on GUID duplicates +# +sub test_guid_duplicate_same_folder + :min_version_3_3 :LowEmailLimits +{ + my ($self) = @_; + my %exp; + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.dest"); + + $talk->select("INBOX"); + my $r1 = $talk->copy("1", "INBOX.dest"); + my $r2 = $talk->copy("1", "INBOX.dest"); + my $r3 = $talk->copy("1", "INBOX.dest"); + $self->assert_not_null($r1); + $self->assert_not_null($r2); + $self->assert_null($r3); + $self->assert_matches(qr/Too many identical emails/, $talk->get_last_error()); + + $self->assert_syslog_matches($self->{instance}, + qr{IOERROR: conversations GUID limit}); + + $talk->select("INBOX.dest"); + my $data = $talk->fetch("1:*", "(emailid threadid uid)"); + $self->assert_not_null($data->{1}); + $self->assert_not_null($data->{2}); + $self->assert_null($data->{3}); +} + +sub test_guid_duplicate_total_count + :min_version_3_3 :LowEmailLimits +{ + my ($self) = @_; + my %exp; + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.M1"); + $talk->create("INBOX.M2"); + $talk->create("INBOX.M3"); + $talk->create("INBOX.M4"); + $talk->create("INBOX.M5"); + + $talk->select("INBOX"); + + my $r1 = $talk->copy("1", "INBOX.M1"); + my $r2 = $talk->copy("1", "INBOX.M2"); + my $r3 = $talk->copy("1", "INBOX.M3"); + my $r4 = $talk->copy("1", "INBOX.M4"); + my $r5 = $talk->copy("1", "INBOX.M5"); + + $self->assert_not_null($r1); + $self->assert_not_null($r2); + $self->assert_not_null($r3); + $self->assert_not_null($r4); + $self->assert_null($r5); + $self->assert_matches(qr/Too many identical emails/, $talk->get_last_error()); + $self->assert_syslog_matches($self->{instance}, + qr{IOERROR: conversations GUID limit}); +} + +# +# Test limits on GUID duplicates +# +sub test_guid_duplicate_expunges + :min_version_3_3 :LowEmailLimits :DelayedExpunge +{ + my ($self) = @_; + my %exp; + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.dest"); + + for (1..9) { + $talk->select("INBOX"); + my $r = $talk->copy("1", "INBOX.dest"); + $self->assert_not_null($r); + $talk->select("INBOX.dest"); + $talk->store('1:*', '+flags', '(\\Deleted)'); + $talk->expunge(); + } + + $talk->select("INBOX"); + my $r = $talk->copy("1", "INBOX.dest"); + $self->assert_null($r); + $self->assert_matches(qr/Too many identical emails/, $talk->get_last_error()); + + $self->assert_syslog_matches($self->{instance}, + qr{IOERROR: conversations GUID limit}); +} + +# Test APPEND of two messages, the second of which has a different subject, +# and would otherwise have threaded, but also has an X-ME-Message-ID header +# and make sure they don't thread +# +sub test_x_me_message_id_nomatch_threading + :min_version_3_0 +{ + my ($self) = @_; + my %exp; + + xlog $self, "generating message A"; + $exp{A} = $self->{gen}->generate(subject => 'Message A'); + $exp{A}->set_headers('Message-ID', ''); + $exp{A}->set_attribute(cid => $exp{A}->make_cid()); + + $self->{store}->write_begin(); + $self->{store}->write_message($exp{A}); + $self->{store}->write_end(); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + $exp{B} = $exp{A}->clone(); + $exp{B}->set_headers('Message-ID', ''); + $exp{B}->set_headers('In-Reply-To', ''); + $exp{B}->set_headers('X-ME-Message-ID', ''); + $exp{B}->set_headers('Subject', 'Message B'); + $exp{B}->set_body("Completely different text here\r\n"); + $exp{B}->set_attribute(cid => $exp{B}->make_cid()); + + $self->{store}->write_begin(); + $self->{store}->write_message($exp{B}); + $self->{store}->write_end(); + $self->check_messages(\%exp); +} + +sub test_rename_between_users + :NoAltNameSpace +{ + my ($self) = @_; + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", cassandane => 'lrswipkxtecdn'); + + my $talk = $self->{store}->get_client(); + + $self->{store}->set_folder("INBOX"); + $self->make_message("Inbox Msg"); + + $talk->create("INBOX.foo"); + $self->{store}->set_folder("INBOX.foo"); + $self->make_message("Foo Msg"); + + $talk->create("INBOX.bar"); + $self->{store}->set_folder("INBOX.bar"); + $self->make_message("Bar Msg"); + + $self->{store}->set_folder("user.manifold"); + $self->make_message("Man Msg"); + + my $dirs = $self->{instance}->run_mbpath(-u => 'cassandane'); + my $mdirs = $self->{instance}->run_mbpath(-u => 'manifold'); + + # folder IDs should be "INBOX", "foo", "bar" + + my $res = $talk->status('INBOX.foo', ['mailboxid']); + my $fooid = $res->{'mailboxid'}->[0]; + + my %data = $self->{instance}->run_dbcommand($dirs->{user}{conversations}, 'twoskip', ['SHOW']); + my %mdata = $self->{instance}->run_dbcommand($mdirs->{user}{conversations}, 'twoskip', ['SHOW']); + + my $folders = Cyrus::DList->parse_string($data{'$FOLDER_IDS'})->as_perl; + my $mfolders = Cyrus::DList->parse_string($mdata{'$FOLDER_IDS'})->as_perl; + + $self->assert_num_equals(3, scalar @$folders); + $self->assert_num_equals(1, scalar @$mfolders); + $self->assert_str_equals($fooid, $folders->[1]); + + xlog $self, "Rename folder to other user"; + $talk->rename("INBOX.foo", "user.manifold.foo"); + + $admintalk->create('user.manifold.extra'); + $self->{store}->set_folder("user.manifold.extra"); + $self->make_message("Extra Msg"); + + %data = $self->{instance}->run_dbcommand($dirs->{user}{conversations}, 'twoskip', ['SHOW']); + %mdata = $self->{instance}->run_dbcommand($mdirs->{user}{conversations}, 'twoskip', ['SHOW']); + + $folders = Cyrus::DList->parse_string($data{'$FOLDER_IDS'})->as_perl; + $mfolders = Cyrus::DList->parse_string($mdata{'$FOLDER_IDS'})->as_perl; + $self->assert_num_equals(3, scalar @$folders); + $self->assert_num_equals(3, scalar @$mfolders); + $self->assert_str_equals('-', $folders->[1]); + $self->assert_str_equals($fooid, $mfolders->[1]); + + $talk->create("INBOX.again"); + $self->{store}->set_folder("INBOX.again"); + $self->make_message("Again Msg"); + + $res = $talk->status('INBOX.again', ['mailboxid']); + my $againid = $res->{'mailboxid'}->[0]; + + $talk->rename("user.manifold.foo", "INBOX.foo"); + + %data = $self->{instance}->run_dbcommand($dirs->{user}{conversations}, 'twoskip', ['SHOW']); + %mdata = $self->{instance}->run_dbcommand($mdirs->{user}{conversations}, 'twoskip', ['SHOW']); + $folders = Cyrus::DList->parse_string($data{'$FOLDER_IDS'})->as_perl; + $mfolders = Cyrus::DList->parse_string($mdata{'$FOLDER_IDS'})->as_perl; + $self->assert_num_equals(4, scalar @$folders); + $self->assert_num_equals(3, scalar @$mfolders); + $self->assert_str_equals($againid, $folders->[1]); + $self->assert_str_equals($fooid, $folders->[3]); + $self->assert_str_equals('-', $mfolders->[1]); +} + +# +# Test user rename without splitting conversations +# +sub test_rename_user_nosplitconv + :AllowMoves :Replication +{ + my ($self) = @_; + + xlog $self, "Test user rename without splitting conversations"; + + my %exp; + + # check IMAP server has the XCONVERSATIONS capability + my $master_store = $self->{master_store}; + $self->assert($master_store->get_client()->capability()->{xconversations}); + + $master_store->set_fetch_attributes('uid', 'cid', 'basecid'); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating replies"; + for (1..20) { + $exp{"A$_"} = $self->make_message("Re: Message A", + references => [ $exp{A} ], + store => $master_store); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + + my $talk = $master_store->get_client(); + $talk->create('foo'); + $talk->copy('1:*', 'foo'); + + $self->check_messages(\%exp, keyed_on => 'uid', store => $master_store); + $self->check_conversations(); + + $self->run_replication(); + $self->check_replication('cassandane'); + + # Reduce the conversation thread size + my $config = $self->{instance}->{config}; + $config->set(conversations_max_thread => 5); + $config->generate($self->{instance}->_imapd_conf()); + + $config = $self->{replica}->{config}; + $config->set(conversations_max_thread => 5); + $config->generate($self->{replica}->_imapd_conf()); + + # Rename the user + my $admintalk = $self->{adminstore}->get_client(); + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert(not $admintalk->get_last_error()); + + $self->{adminstore}->set_folder("user.newuser"); + $self->{adminstore}->set_fetch_attributes('uid', 'cid', 'basecid'); + $self->check_messages(\%exp, keyed_on => 'uid', store => $self->{adminstore}); + $self->check_conversations(); + + $self->run_replication(user => 'newuser'); + $self->check_replication('newuser'); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Create.pm b/cassandane/Cassandane/Cyrus/Create.pm new file mode 100644 index 0000000000..117182540d --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Create.pm @@ -0,0 +1,209 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2019 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Create; +use strict; +use warnings; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_bad_userids +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my @bad_userids = ( + 'user', + 'user.anyone', + 'user.anonymous', + 'user.%SHARED', + #'user..foo', # silently fixed by namespace conversion + ); + + foreach my $u (@bad_userids) { + $admintalk->create($u); + $self->assert_str_equals('no', + $admintalk->get_last_completion_response()); + } +} + +sub test_bad_userids_unixhs + :UnixHierarchySep +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my @bad_userids = ( + 'user', + 'user/anyone', + 'user/anonymous', + 'user/%SHARED', + #'user//foo', # silently fixed by namespace conversion + ); + + foreach my $u (@bad_userids) { + $admintalk->create($u); + $self->assert_str_equals('no', + $admintalk->get_last_completion_response()); + } +} + +sub test_good_userids +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my @good_userids = ( + 'user.$RACL', + ); + + foreach my $u (@good_userids) { + $admintalk->create($u); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } +} + +sub test_good_userids_unixhs + :UnixHierarchySep +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my @good_userids = ( + 'user/$RACL', + 'user/.foo', # with unixhs, this is not a double-sep! + ); + + foreach my $u (@good_userids) { + $admintalk->create($u); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } +} + +sub test_bad_mailboxes +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my @bad_mailboxes = ( + '$RACL', + '$RACL$U$anyone$user.foo', + 'domain.com!user.foo', # virtdomains=off + #'user.cassandane..blah', # silently fixed by namespace conversion + ); + + foreach my $m (@bad_mailboxes) { + $admintalk->create($m); + $self->assert_str_equals('no', + $admintalk->get_last_completion_response()); + } +} + +sub test_good_mailboxes_unixhs + :UnixHierarchySep +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my @good_mailboxes = ( + 'user/cassandane/$RACL', + 'user/cassandane/.foo', # with unixhs, this is not a double-sep! + 'user/foo.', + 'user/foo./bar', # with unixhs, this is not a double-sep! + ); + + foreach my $m (@good_mailboxes) { + $admintalk->create($m); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } +} + +sub test_good_mailboxes_virtdomains + :VirtDomains +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my @good_mailboxes = ( + 'user.cassandane.$RACL', + 'user.foo@domain.com', + ); + + foreach my $m (@good_mailboxes) { + $admintalk->create($m); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/CtlMboxlist.pm b/cassandane/Cassandane/Cyrus/CtlMboxlist.pm new file mode 100644 index 0000000000..9cd514043c --- /dev/null +++ b/cassandane/Cassandane/Cyrus/CtlMboxlist.pm @@ -0,0 +1,149 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2022 Fastmail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::CtlMboxlist; +use strict; +use warnings; + +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub fudge_mtimes +{ + my ($hash) = @_; + + foreach my $v (values %{$hash}) { + if (exists $v->{mtime}) { + $v->{mtime} = 1; + } + } +} + +sub test_dump_undump + :AltNamespace :UnixHierarchySep +{ + my ($self) = @_; + + # set up some mailboxes for the cassandane user + my $expected = $self->populate_user( + $self->{instance}, + $self->{store}, + [qw(INBOX Drafts Big Big/Red Big/Red/Dog)] + ); + + # sanity check + $self->check_user($self->{instance}, $self->{store}, $expected); + + # stop the instance + $self->{store}->disconnect(); + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + + # will refer to this a lot + my $basedir = $self->{instance}->get_basedir(); + + # get a dump + my $dump1file = "$basedir/$$-dump.out1"; + my $dump1content = $self->{instance}->read_mailboxes_db({ + outfile => $dump1file + }); + + # move aside original mailboxes.db + my $mboxlist_db_path = $self->{instance}->{config}->get('mboxlist_db_path'); + $mboxlist_db_path //= "$basedir/conf/mailboxes.db"; + rename $mboxlist_db_path, "$mboxlist_db_path.orig" + or die "rename $mboxlist_db_path $mboxlist_db_path.orig: $!"; + + # undump the dump into a new mailboxes.db + my $errfile = $basedir . "/$$-undump.err"; + $self->{instance}->run_command({ + cyrus => 1, + redirects => { + stdin => $dump1file, + stderr => $errfile, + }, + }, 'ctl_mboxlist', '-u'); + + my $errors = slurp_file($errfile); + + # should be no errors reported by the undump + $self->assert_str_equals(q{}, $errors); + + # start the instance back up and reconnect the store + $self->{instance}->start(); + $self->{store}->connect(); + + # user's mailboxes should be as they were + $self->check_user($self->{instance}, $self->{store}, $expected); + + # a second dump should produce the same output + # ... though mtimes will differ, so fudge those first + my $dump2file = "$basedir/$$-dump.out2"; + my $dump2content = $self->{instance}->read_mailboxes_db({ + outfile => $dump2file + }); + fudge_mtimes($dump1content); + fudge_mtimes($dump2content); + $self->assert_deep_equals($dump1content, $dump2content); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/CyrusDB.pm b/cassandane/Cassandane/Cyrus/CyrusDB.pm new file mode 100644 index 0000000000..9880428c78 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/CyrusDB.pm @@ -0,0 +1,448 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::CyrusDB; +use strict; +use warnings; +use Data::Dumper; +use File::Copy; +use IO::File; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +use lib '../perl/imap'; +use Cyrus::DList; +use Cyrus::HeaderFile; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ start_instances => 0 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# Some databases aren't created automatically during a minimal +# startup on a new install, so run some commands such that they +# become extant. +sub _force_db_creations +{ + my ($self) = @_; + + # create a backups.db -- but only if backups are compiled in! + eval { + $self->{instance}->_find_binary('ctl_backups'); + + xlog $self, "autovivify a backups.db"; + $self->{instance}->run_command({ + cyrus => 1, + }, 'ctl_backups', 'list'); + }; +} + +sub test_alternate_quotadb_path +{ + my ($self) = @_; + + my $quota_db_path = $self->{instance}->get_basedir() + . '/conf/non-default-quotas.db'; + + $self->{instance}->{config}->set(quota_db => 'twoskip'); + $self->{instance}->{config}->set(quota_db_path => $quota_db_path); + $self->{instance}->start(); + + $self->_force_db_creations(); + + # Check that ctl_cyrusdb -c (checkpoint) uses correct db filename. + # If it mistakenly tries to use the default filename, it will error + # out due to it not existing. + eval { + $self->{instance}->run_command({ + cyrus => 1, + }, 'ctl_cyrusdb', '-c'); + }; + $self->assert(not $@); + + # TODO more/better checks +} + +sub test_mboxlistdb_skiplist +{ + my ($self) = @_; + + $self->{instance}->{config}->set(mboxlist_db => 'skiplist'); + $self->{instance}->start(); + + # 'ctl_cyrusdb -r' will run on startup, and it should not crash! +} + +sub test_recover_uniqueid_from_header_legacymb + :min_version_3_6 :MailboxLegacyDirs +{ + my ($self) = @_; + my $entry = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + + # first start will set up cassandane user + $self->_start_instances(); + my $basedir = $self->{instance}->get_basedir(); + my $mailboxes_db = "$basedir/conf/mailboxes.db"; + $self->assert(-f $mailboxes_db, "$mailboxes_db not present"); + + # find out the uniqueid of the inbox + my $imaptalk = $self->{store}->get_client(); + my $res = $imaptalk->getmetadata("INBOX", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $uniqueid = $res->{INBOX}{$entry}; + xlog "XXX got uniqueid: " . Dumper \$uniqueid; + $self->assert_not_null($uniqueid); + $imaptalk->logout(); + undef $imaptalk; + + # stop service while tinkering + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + + # lose that uniqueid from mailboxes.db + my $I = "I$uniqueid"; + my $N = "Nuser\x1fcassandane"; + $self->{instance}->run_dbcommand($mailboxes_db, "twoskip", + [ 'DELETE', $I ]); + my (undef, $mbentry) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $N]); + my $dlist = Cyrus::DList->parse_string($mbentry); + my $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + $hash->{I} = undef; + $dlist = Cyrus::DList->new_perl('', $hash); + $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + [ 'SET', $N, $dlist->as_string() ]); + + my %updated = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", ['SHOW']); + xlog "updated mailboxes.db: " . Dumper \%updated; + + # bring service back up + # ctl_cyrusdb -r should find and fix the missing uniqueid + $self->{instance}->getsyslog(); + $self->{instance}->start(); + if ($self->{instance}->{have_syslog_replacement}) { + my $syslog = join(q{}, $self->{instance}->getsyslog()); + + # should have still existed in cyrus.header + $self->assert_does_not_match( + qr{mailbox header had no uniqueid, creating one}, $syslog); + + # expect to find the log line + $self->assert_matches(qr{mbentry had no uniqueid, setting from header}, + $syslog); + } + + # header should have the same uniqueid as before + my $cyrus_header = $self->{instance}->folder_to_directory('INBOX') + . '/cyrus.header'; + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + my $hf = Cyrus::HeaderFile->new_file($cyrus_header); + $self->assert_str_equals($uniqueid, $hf->{header}->{UniqueId}); + + # mbentry should have the same uniqueid as before + (undef, $mbentry) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $N]); + $dlist = Cyrus::DList->parse_string($mbentry); + $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + + # $I entry should be back + my ($key, $value) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $I]); + $self->assert_str_equals($I, $key); + $dlist = Cyrus::DList->parse_string($value); + $hash = $dlist->as_perl(); + $self->assert_str_equals("user\x1fcassandane", $hash->{N}); +} + +sub test_recover_create_missing_uniqueid_legacymb + :min_version_3_6 :MailboxLegacyDirs +{ + my ($self) = @_; + my $entry = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + + # first start will set up cassandane user + $self->_start_instances(); + my $basedir = $self->{instance}->get_basedir(); + my $mailboxes_db = "$basedir/conf/mailboxes.db"; + $self->assert(-f $mailboxes_db, "$mailboxes_db not present"); + + # find out the uniqueid of the inbox + my $imaptalk = $self->{store}->get_client(); + my $res = $imaptalk->getmetadata("INBOX", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $uniqueid = $res->{INBOX}{$entry}; + $self->assert_not_null($uniqueid); + $imaptalk->logout(); + undef $imaptalk; + + # stop service while tinkering + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + + # lose that uniqueid from mailboxes.db + my $I = "I$uniqueid"; + my $N = "Nuser\x1fcassandane"; + $self->{instance}->run_dbcommand($mailboxes_db, "twoskip", + [ 'DELETE', $I ]); + my (undef, $mbentry) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $N]); + my $dlist = Cyrus::DList->parse_string($mbentry); + my $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + $hash->{I} = undef; + $dlist = Cyrus::DList->new_perl('', $hash); + $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + [ 'SET', $N, $dlist->as_string() ]); + + my %updated = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", ['SHOW']); + xlog "updated mailboxes.db: " . Dumper \%updated; + + # lose it from cyrus.header too + my $cyrus_header = $self->{instance}->folder_to_directory('INBOX') + . '/cyrus.header'; + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + copy($cyrus_header, "$cyrus_header.OLD"); + my $hf = Cyrus::HeaderFile->new_file("$cyrus_header.OLD"); + $self->assert_str_equals($uniqueid, $hf->{header}->{UniqueId}); + $hf->{header}->{UniqueId} = undef; + my $out = IO::File->new($cyrus_header, 'w'); + $hf->write_header($out, $hf->{header}); + + # bring service back up + # ctl_cyrusdb -r should find and fix the missing uniqueid + $self->{instance}->getsyslog(); + $self->{instance}->start(); + if ($self->{instance}->{have_syslog_replacement}) { + my $syslog = join(q{}, $self->{instance}->getsyslog()); + + # expect to find it was missing in the header + $self->assert_matches(qr{mailbox header had no uniqueid, creating one}, + $syslog); + + # expect to find it was missing from mbentry + $self->assert_matches(qr{mbentry had no uniqueid, setting from header}, + $syslog); + } + + # should not be the same uniqueid as before + $imaptalk = $self->{store}->get_client(); + $res = $imaptalk->getmetadata("INBOX", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_not_null($res->{INBOX}{$entry}); + my $newuniqueid = $res->{INBOX}{$entry}; + $self->assert_str_not_equals($uniqueid, $newuniqueid); + + # header file should have the new uniqueid + $cyrus_header = $self->{instance}->folder_to_directory('INBOX') + . '/cyrus.header'; + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + $hf = Cyrus::HeaderFile->new_file($cyrus_header); + $self->assert_str_equals($newuniqueid, $hf->{header}->{UniqueId}); + + # mbentry should have the new uniqueid + (undef, $mbentry) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $N]); + $dlist = Cyrus::DList->parse_string($mbentry); + $hash = $dlist->as_perl(); + $self->assert_str_equals($newuniqueid, $hash->{I}); + + # new runq entry should exist + my $newI = "I$newuniqueid"; + my ($key, $value) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $newI]); + $self->assert_str_equals($newI, $key); + $dlist = Cyrus::DList->parse_string($value); + $hash = $dlist->as_perl(); + $self->assert_str_equals("user\x1fcassandane", $hash->{N}); +} + +sub test_recover_skipstamp +{ + my ($self) = @_; + + my $dbdir = $self->{instance}->get_basedir() . "/conf/db"; + + # no 'ctl_cyrusdb -r' on startup + $self->{instance}->remove_start('recover'); + $self->{instance}->start(); + + # expect skipstamp file to be missing + $self->assert_not_file_test("$dbdir/skipstamp", '-e'); + + # cyrus processes will whinge about missing skipstamp file + if ($self->{instance}->{have_syslog_replacement}) { + my $syslog = join "\n", $self->{instance}->getsyslog(); + $self->assert_matches(qr/skipstamp is missing/, $syslog); + $self->assert_matches(qr/DBERROR: skipstamp/, $syslog); + } + + # shut down, enable recover, and restart + $self->{instance}->stop(); + # n.b. no "re_use_dir" here, because we need cyrus.conf regenerated + $self->{instance}->add_recover(); + $self->{instance}->start(); + + # skipstamp file should be present now + $self->assert_file_test("$dbdir/skipstamp", '-e'); + + if ($self->{instance}->{have_syslog_replacement}) { + my $syslog = join "\n", $self->{instance}->getsyslog(); + + # recover should have logged itself updating skipstamp + $self->assert_matches(qr/updating recovery stamp/, $syslog); + + # cyrus processes should not whinge about missing skipstamp file + $self->assert_does_not_match(qr/skipstamp is missing/, $syslog); + $self->assert_does_not_match(qr/DBERROR: skipstamp/, $syslog); + } +} + +sub create_empty_file +{ + my ($fname) = @_; + + open my $fh, '>', $fname + or die "create_empty_file($fname): $!"; + close $fh; +} + +sub test_recover_skipcleanshutdown +{ + my ($self) = @_; + + my $dbdir = $self->{instance}->get_basedir() . "/conf/db"; + + # need to start up once to create a reusable basedir + $self->{instance}->start(); + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + + # act like we were previously shut down cleanly by some rc script, + # but without a skipstamp somehow + create_empty_file("$dbdir/skipcleanshutdown"); + unlink "$dbdir/skipstamp"; + $self->assert_not_file_test("$dbdir/skipstamp", '-e'); + + # start 'er up + $self->{instance}->start(); + + # recover should have created a skipstamp file, despite skipcleanshutdown + $self->assert_file_test("$dbdir/skipstamp", '-e'); + my $prev_skipstamp_mtime = (stat "$dbdir/skipstamp")[9]; + + # and skipcleanshutdown should have been removed + $self->assert_not_file_test("$dbdir/skipcleanshutdown", '-e'); + + if ($self->{instance}->{have_syslog_replacement}) { + my $syslog = join "\n", $self->{instance}->getsyslog(); + + # recover should not claim this was a normal start + $self->assert_does_not_match(qr/starting normally/, $syslog); + + # recover should have logged itself updating skipstamp + $self->assert_matches(qr/updating recovery stamp/, $syslog); + + # cyrus processes should not whinge about missing skipstamp file + $self->assert_does_not_match(qr/skipstamp is missing/, $syslog); + $self->assert_does_not_match(qr/DBERROR: skipstamp/, $syslog); + } + + # shut down "cleanly" again, but this time leaving skipstamp alone + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + create_empty_file("$dbdir/skipcleanshutdown"); + + # restart + $self->{instance}->start(); + + # skipstamp file should be present and unmodified since previous run + $self->assert_file_test("$dbdir/skipstamp", '-e'); + my $skipstamp_mtime = (stat "$dbdir/skipstamp")[9]; + $self->assert_num_equals($prev_skipstamp_mtime, $skipstamp_mtime); + + # and skipcleanshutdown should have been removed + $self->assert_not_file_test("$dbdir/skipcleanshutdown", '-e'); + + if ($self->{instance}->{have_syslog_replacement}) { + my $syslog = join "\n", $self->{instance}->getsyslog(); + + # recover should claim this was a normal start + $self->assert_matches(qr/starting normally/, $syslog); + + # recover should not have updated skipstamp + $self->assert_does_not_match(qr/updating recovery stamp/, $syslog); + + # cyrus processes should not whinge about missing skipstamp file + $self->assert_does_not_match(qr/skipstamp is missing/, $syslog); + $self->assert_does_not_match(qr/DBERROR: skipstamp/, $syslog); + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/DBLookup.pm b/cassandane/Cassandane/Cyrus/DBLookup.pm new file mode 100644 index 0000000000..d6927d3a24 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/DBLookup.pm @@ -0,0 +1,163 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::DBLookup; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::DAVTalk 0.14; +use Net::CardDAVTalk 0.05; +use Net::CardDAVTalk::VCard; +use Data::Dumper; +use XML::Spice; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane'); + $config->set(httpmodules => 'carddav caldav'); + $config->set(httpallowcompress => 'no'); + return $class->SUPER::new({ + adminstore => 1, + config => $config, + services => ['imap', 'http'], + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + $self->{carddav} = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_email2uids + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + + my $Str = <new_fromstring($Str); + + my $uid = $CardDAV->NewContact($Id, $VCard); + + my $res = $CardDAV->Request('GET', '/dblookup/email2uids', '', + User => 'cassandane', + Key => "user\@example.com", + Mailbox => 'foo', + ); + + # XXX: actually compare to the UID +} + +sub test_email2details + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + + my $Str = <new_fromstring($Str); + + my $uid = $CardDAV->NewContact($Id, $VCard); + + my $res = $CardDAV->Request('GET', '/dblookup/email2details', '', + User => 'cassandane', + Key => "user\@example.com", + Mailbox => 'foo', + ); + + # XXX: actually compare to the UID +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Delete.pm b/cassandane/Cassandane/Cyrus/Delete.pm new file mode 100644 index 0000000000..10f9fb6030 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Delete.pm @@ -0,0 +1,1088 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Delete; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use File::Basename; + +sub new +{ + my ($class, @args) = @_; + + my $buildinfo = Cassandane::BuildInfo->new(); + + if ($buildinfo->get('component', 'httpd')) { + my $config = Cassandane::Config->default()->clone(); + + $config->set(conversations => 'yes', + httpmodules => 'carddav caldav'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => [ 'imap', 'http', 'sieve' ] + }, @args); + } + else { + return $class->SUPER::new({ adminstore => 1 }, @args); + } +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub check_folder_ondisk +{ + my ($self, $folder, %params) = @_; + + my $instance = delete $params{instance} || $self->{instance}; + my $deleted = delete $params{deleted} || 0; + my $exp = delete $params{expected}; + die "Bad params: " . join(' ', keys %params) + if scalar %params; + + my $display_folder = ($deleted ? "DELETED " : "") . $folder; + xlog $self, "Checking that $display_folder exists on disk"; + + my $dir; + if ($deleted) + { + my @dirs = $instance->folder_to_deleted_directories($folder); + $self->assert_equals(1, scalar(@dirs), + "too many directories for $display_folder"); + $dir = shift @dirs; + } + else + { + $dir = $instance->folder_to_directory($folder); + } + + $self->assert_not_null($dir, + "directory missing for $display_folder"); + $self->assert( -f "$dir/cyrus.header", + "cyrus.header missing for $display_folder"); + $self->assert( -f "$dir/cyrus.index", + "cyrus.index missing for $display_folder"); + + if (defined $exp) + { + map + { + my $uid = $_->uid(); + $self->assert( -f "$dir/$uid.", + "message $uid missing for $display_folder"); + } values %$exp; + } +} + +sub check_folder_not_ondisk +{ + my ($self, $folder, %params) = @_; + + my $instance = delete $params{instance} || $self->{instance}; + my $deleted = delete $params{deleted} || 0; + die "Bad params: " . join(' ', keys %params) + if scalar %params; + + my $display_folder = ($deleted ? "DELETED " : "") . $folder; + xlog $self, "Checking that $display_folder does not exist on disk"; + + if ($deleted) + { + my @dirs = $instance->folder_to_deleted_directories($folder); + $self->assert_equals(0, scalar(@dirs), + "directory unexpectedly present for $display_folder"); + } + else + { + my $dir = $instance->folder_to_directory($folder); + $self->assert_null($dir, + "directory unexpectedly present for $display_folder"); + } +} + +sub check_syslog +{ + my ($self, $instance) = @_; + + my $remove_empty_pat = qr/Remove of supposedly empty directory/; + + $self->assert_null($instance->_check_syslog($remove_empty_pat)); +} + +sub test_self_inbox_imm + :ImmediateDelete :SemidelayedExpunge :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing that a non-admin can delete an a subfolder"; + xlog $self, "but cannot delete their own INBOX, immediate delete version"; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + my $subfolder = 'INBOX.foo'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Generate a message in $inbox"; + my %exp_inbox; + $exp_inbox{A} = $self->make_message("Message $inbox A"); + $self->check_messages(\%exp_inbox); + + xlog $self, "Generate a message in $subfolder"; + my %exp_sub; + $store->set_folder($subfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $exp_sub{A} = $self->make_message("Message $subfolder A"); + $self->check_messages(\%exp_sub); + + $self->check_folder_ondisk($inbox, expected => \%exp_inbox); + $self->check_folder_ondisk($subfolder, expected => \%exp_sub); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "can delete the subfolder"; + $talk->unselect(); + $talk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Cannot select the subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + xlog $self, "But the message in $inbox is still there"; + $store->set_folder($inbox); + $store->_select(); + $self->check_messages(\%exp_inbox); + + xlog $self, "cannot delete our own $inbox"; + $talk->delete($inbox); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Operation is not supported/i, $talk->get_last_error()); + + xlog $self, "And the message in $inbox is still there"; + $store->set_folder($inbox); + $store->_select(); + $self->check_messages(\%exp_inbox); + + $self->check_folder_ondisk($inbox, expected => \%exp_inbox); + $self->check_folder_not_ondisk($subfolder); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + $self->check_syslog($self->{instance}); +} + +sub test_self_inbox_del + :DelayedDelete :SemidelayedExpunge :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing that a non-admin can delete an a subfolder"; + xlog $self, "but cannot delete their own INBOX, delayed delete version"; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + my $subfolder = 'INBOX.foo'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Generate a message in $inbox"; + my %exp_inbox; + $exp_inbox{A} = $self->make_message("Message $inbox A"); + $self->check_messages(\%exp_inbox); + + xlog $self, "Generate a message in $subfolder"; + my %exp_sub; + $store->set_folder($subfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $exp_sub{A} = $self->make_message("Message $subfolder A"); + $self->check_messages(\%exp_sub); + + $self->check_folder_ondisk($inbox, expected => \%exp_inbox); + $self->check_folder_ondisk($subfolder, expected => \%exp_sub); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "can delete the subfolder"; + $talk->unselect(); + $talk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Cannot select the subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + xlog $self, "But the message in $inbox is still there"; + $store->set_folder($inbox); + $store->_select(); + $self->check_messages(\%exp_inbox); + + xlog $self, "cannot delete our own $inbox"; + $talk->delete($inbox); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Operation is not supported/i, $talk->get_last_error()); + + xlog $self, "And the message in $inbox is still there"; + $store->set_folder($inbox); + $store->_select(); + $self->check_messages(\%exp_inbox); + + $self->check_folder_ondisk($inbox, expected => \%exp_inbox); + $self->check_folder_not_ondisk($subfolder); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_ondisk($subfolder, deleted => 1, expected => \%exp_sub); + + $self->run_delayed_expunge(); + + $self->check_folder_ondisk($inbox, expected => \%exp_inbox); + $self->check_folder_not_ondisk($subfolder); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + $self->check_syslog($self->{instance}); +} + +# old version of this test for builds without newer httpd features +# n.b. 2.5 httpd can't be built anymore because of dependency on +# very old libical +sub test_admin_inbox_imm_legacy + :ImmediateDelete :SemidelayedExpunge :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing that an admin can delete the INBOX of a user"; + xlog $self, "and it will delete the whole user, immediate delete version"; + + # can't do the magic disconnect handling on older perl + return if ($] < 5.010); + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + my $inbox = 'user.cassandane'; + my $subfolder = 'user.cassandane.foo'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Generate a message in $inbox"; + my %exp_inbox; + $exp_inbox{A} = $self->make_message("Message $inbox A"); + $self->check_messages(\%exp_inbox); + + xlog $self, "Generate a message in $subfolder"; + my %exp_sub; + $store->set_folder($subfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $exp_sub{A} = $self->make_message("Message $subfolder A"); + $self->check_messages(\%exp_sub); + $talk->unselect(); + + $self->check_folder_ondisk($inbox, expected => \%exp_inbox); + $self->check_folder_ondisk($subfolder, expected => \%exp_sub); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "admin can delete $inbox"; + $admintalk->delete($inbox); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + { + # shut up + local $SIG{__DIE__}; + local $SIG{__WARN__} = sub { 1 }; + + xlog $self, "Client was disconnected"; + my $Res = eval { $talk->select($inbox) }; + $self->assert_null($Res); + + # reconnect + $talk = $store->get_client(); + } + + xlog $self, "Cannot select $inbox anymore"; + $talk->select($inbox); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + xlog $self, "Cannot select $subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + $self->check_folder_not_ondisk($inbox); + $self->check_folder_not_ondisk($subfolder); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + $self->check_syslog($self->{instance}); +} + +sub test_admin_inbox_imm + :ImmediateDelete :SemidelayedExpunge :NoAltNameSpace + :needs_component_httpd +{ + my ($self) = @_; + + xlog $self, "Testing that an admin can delete the INBOX of a user"; + xlog $self, "and it will delete the whole user, immediate delete version"; + + # can't do the magic disconnect handling on older perl + return if ($] < 5.010); + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + my $inbox = 'user.cassandane'; + my $subfolder = 'user.cassandane.foo'; + my $sharedfolder = 'shared'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Create a shared folder"; + $admintalk->create($sharedfolder) + or $self->fail("Cannot create folder $sharedfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $admintalk->setacl($sharedfolder, admin => 'lrswipkxtecdan'); + $admintalk->setacl($sharedfolder, cassandane => 'lrsip'); + + xlog $self, "Generate a message in $inbox"; + my %exp_inbox; + $exp_inbox{A} = $self->make_message("Message $inbox A"); + $self->check_messages(\%exp_inbox); + + xlog $self, "Generate a message in $subfolder"; + my %exp_sub; + $store->set_folder($subfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $exp_sub{A} = $self->make_message("Message $subfolder A"); + $self->check_messages(\%exp_sub); + $talk->unselect(); + + xlog $self, "Generate a message in $sharedfolder"; + my %exp_shared; + $store->set_folder($sharedfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $exp_shared{A} = $self->make_message("Message $sharedfolder A"); + $self->check_messages(\%exp_shared); + + xlog $self, "Set \\Seen on message A"; + $talk->store('1', '+flags', '(\\Seen)'); + $talk->unselect(); + + $self->check_folder_ondisk($inbox, expected => \%exp_inbox); + $self->check_folder_ondisk($subfolder, expected => \%exp_sub); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "Subscribe to INBOX"; + $talk->subscribe("INBOX"); + + xlog $self, "Install a sieve script"; + $self->{instance}->install_sieve_script(<{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "Verify user data files/directories exist"; + my $data = $self->{instance}->run_mbpath('-u', 'cassandane'); + $self->assert_file_test($data->{user}{'sub'}, '-f'); + $self->assert_file_test($data->{user}{seen}, '-f'); + $self->assert_file_test($data->{user}{dav}, '-f'); + $self->assert_file_test($data->{user}{counters}, '-f'); + $self->assert_file_test($data->{user}{conversations}, '-f'); + $self->assert_file_test($data->{user}{xapianactive}, '-f'); + $self->assert_file_test("$data->{user}{sieve}/defaultbc", '-f'); + $self->assert_file_test($data->{xapian}{t1}, '-d'); + + xlog $self, "admin can delete $inbox"; + $admintalk->delete($inbox); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + { + # shut up + local $SIG{__DIE__}; + local $SIG{__WARN__} = sub { 1 }; + + xlog $self, "Client was disconnected"; + my $Res = eval { $talk->select($inbox) }; + $self->assert_null($Res); + + # reconnect + $talk = $store->get_client(); + } + + xlog $self, "Cannot select $inbox anymore"; + $talk->select($inbox); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + xlog $self, "Cannot select $subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + $self->check_folder_not_ondisk($inbox); + $self->check_folder_not_ondisk($subfolder); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + my ($maj, $min) = Cassandane::Instance->get_version(); + + xlog $self, "Verify user data directories have been deleted"; + if (($maj > 3 || ($maj == 3 && $min > 4)) + && !$self->{instance}->{config}->get_bool('mailbox_legacy_dirs')) + { + # Entire UUID-hashed directory should be removed + $self->assert_not_file_test(dirname($data->{user}{dav}), '-e'); + } + else { + # Name-hashed directory will be left behind, so check individual files + $self->assert_not_file_test($data->{user}{'sub'}, '-e'); + $self->assert_not_file_test($data->{user}{seen}, '-e'); + $self->assert_not_file_test($data->{user}{dav}, '-e'); + $self->assert_not_file_test($data->{user}{counters}, '-e'); + $self->assert_not_file_test($data->{user}{conversations}, '-e'); + $self->assert_not_file_test($data->{user}{xapianactive}, '-e'); + } + $self->assert_not_file_test($data->{user}{sieve}, '-e'); + $self->assert_not_file_test($data->{xapian}{t1}, '-e'); + + $self->check_syslog($self->{instance}); +} + +sub test_admin_inbox_del + :DelayedDelete :SemidelayedExpunge :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing that an admin can delete the INBOX of a user"; + xlog $self, "and it will delete the whole user, delayed delete version"; + + # can't do the magic disconnect handling on older perl + return if ($] < 5.010); + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + my $inbox = 'user.cassandane'; + my $subfolder = 'user.cassandane.foo'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Generate a message in $inbox"; + my %exp_inbox; + $exp_inbox{A} = $self->make_message("Message $inbox A"); + $self->check_messages(\%exp_inbox); + + xlog $self, "Generate a message in $subfolder"; + my %exp_sub; + $store->set_folder($subfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $exp_sub{A} = $self->make_message("Message $subfolder A"); + $self->check_messages(\%exp_sub); + $talk->unselect(); + + $self->check_folder_ondisk($inbox, expected => \%exp_inbox); + $self->check_folder_ondisk($subfolder, expected => \%exp_sub); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "admin can delete $inbox"; + $admintalk->delete($inbox); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + { + # shut up + local $SIG{__DIE__}; + local $SIG{__WARN__} = sub { 1 }; + + xlog $self, "Client was disconnected"; + my $Res = eval { $talk->select($inbox) }; + $self->assert_null($Res); + + # reconnect + $talk = $store->get_client(); + } + + xlog $self, "Cannot select $inbox anymore"; + $talk->select($inbox); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + xlog $self, "Cannot select $subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + $self->check_folder_not_ondisk($inbox); + $self->check_folder_not_ondisk($subfolder); + $self->check_folder_ondisk($inbox, deleted => 1, expected => \%exp_inbox); + $self->check_folder_ondisk($subfolder, deleted => 1, expected => \%exp_sub); + + $self->run_delayed_expunge(); + + $self->check_folder_not_ondisk($inbox); + $self->check_folder_not_ondisk($subfolder); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + $self->check_syslog($self->{instance}); +} + +sub test_bz3781 + :ImmediateDelete :SemidelayedExpunge :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing that a folder can be deleted when there is"; + xlog $self, "unexpected files in the proc directory (Bug 3781)"; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'user.cassandane'; + my $subfolder = 'user.cassandane.foo'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $self->check_folder_ondisk($subfolder); + + xlog $self, "Create unexpected files in proc directory"; + my $procdir = $self->{instance}->{basedir} . "/conf/proc"; + POSIX::close(POSIX::creat("$procdir/xxx", 0600)); # non-numeric name + POSIX::close(POSIX::creat("$procdir/123", 0600)); # valid name but empty + + xlog $self, "can delete $subfolder"; + $talk->delete($subfolder); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Cannot select $subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + $self->check_folder_not_ondisk($subfolder); + + # We should have generated an IOERROR + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: bogus filename/); +} + +sub test_cyr_expire_delete + :DelayedDelete :min_version_3_0 :NoAltNameSpace +{ + my ($self) = @_; + + my $store = $self->{store}; + my $adminstore = $self->{adminstore}; + my $talk = $store->get_client(); + my $admintalk = $adminstore->get_client(); + + my $inbox = 'INBOX'; + my $subfoldername = 'foo'; + my $subfolder = 'INBOX.foo'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Append a messages to $inbox"; + my %msg_inbox; + $msg_inbox{A} = $self->make_message('Message A in $inbox'); + $self->check_messages(\%msg_inbox); + + xlog $self, "Append 3 messages to $subfolder"; + my %msg_sub; + $store->set_folder($subfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $msg_sub{A} = $self->make_message('Message A in $subfolder'); + $msg_sub{B} = $self->make_message('Message B in $subfolder'); + $msg_sub{C} = $self->make_message('Message C in $subfolder'); + $self->check_messages(\%msg_sub); + + $self->check_folder_ondisk($inbox, expected => \%msg_inbox); + $self->check_folder_ondisk($subfolder, expected => \%msg_sub); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "Delete $subfolder"; + $talk->unselect(); + $talk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Ensure we can't select $subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + $self->check_folder_not_ondisk($subfolder); + + xlog $self, "Ensure we still have messages in $inbox"; + $store->set_folder($inbox); + $store->_select(); + $self->check_messages(\%msg_inbox); + + my ($datapath) = $self->{instance}->folder_to_deleted_directories("user.cassandane.$subfoldername"); + $self->assert_not_null($datapath); + + xlog $self, "Run cyr_expire -D now."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0' ); + + # the folder should not exist now! + $self->assert_not_file_test($datapath, '-d'); + + # and not exist from mbpath either... + $self->assert_null($self->{instance}->folder_to_deleted_directories("user.cassandane.$subfoldername")); + + $self->check_syslog($self->{instance}); +} + +sub test_allowdeleted + :AllowDeleted :DelayedDelete :min_version_3_1 :NoAltNameSpace +{ + my ($self) = @_; + + my $store = $self->{store}; + my $adminstore = $self->{adminstore}; + my $talk = $store->get_client(); + my $admintalk = $adminstore->get_client(); + + my $inbox = 'INBOX'; + my $subfolder = 'INBOX.foo'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $self->make_message('A message'); + $talk->select("INBOX"); + $talk->copy("1:*", $subfolder); + $talk->unselect(); + + xlog $self, "Delete $subfolder"; + $talk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Check standard list only included Inbox"; + my $result = $talk->list('', '*'); + $self->assert_num_equals(1, scalar(@$result)); + + xlog $self, "Check include-deleted LIST includes deleted mailbox"; + $result = $talk->list(['VENDOR.CMU-INCLUDE-DELETED'], '', '*'); + $self->assert_num_equals(2, scalar(@$result)); + $self->assert_str_equals("INBOX", $result->[0][2]); + $self->assert_matches(qr/^DELETED./, $result->[1][2]); + + xlog $self, "Check that select of DELETED folder works and finds messages"; + $talk->select($result->[1][2]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + $self->check_syslog($self->{instance}); +} + +sub test_cyr_expire_delete_with_annotation + :DelayedDelete :min_version_3_1 :NoAltNameSpace +{ + my ($self) = @_; + + my $store = $self->{store}; + my $adminstore = $self->{adminstore}; + my $talk = $store->get_client(); + my $admintalk = $adminstore->get_client(); + + my $inbox = 'INBOX'; + my $subfoldername = 'foo'; + my $subfolder = 'INBOX.foo'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Append a messages to $inbox"; + my %msg_inbox; + $msg_inbox{A} = $self->make_message('Message A in $inbox'); + $self->check_messages(\%msg_inbox); + + xlog $self, "Setting /vendor/cmu/cyrus-imapd/delete annotation."; + $talk->setmetadata($subfolder, "/shared/vendor/cmu/cyrus-imapd/delete", '3'); + + xlog $self, "Append 3 messages to $subfolder"; + my %msg_sub; + $store->set_folder($subfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $msg_sub{A} = $self->make_message('Message A in $subfolder'); + $msg_sub{B} = $self->make_message('Message B in $subfolder'); + $msg_sub{C} = $self->make_message('Message C in $subfolder'); + $self->check_messages(\%msg_sub); + + $self->check_folder_ondisk($inbox, expected => \%msg_inbox); + $self->check_folder_ondisk($subfolder, expected => \%msg_sub); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "Delete $subfolder"; + $talk->unselect(); + $talk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Ensure we can't select $subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + $self->check_folder_not_ondisk($subfolder); + + xlog $self, "Ensure we still have messages in $inbox"; + $store->set_folder($inbox); + $store->_select(); + $self->check_messages(\%msg_inbox); + + my ($path) = $self->{instance}->folder_to_deleted_directories("user.cassandane.$subfoldername"); + $self->assert_file_test($path, '-d'); + + xlog $self, "Run cyr_expire -D now, it shouldn't delete."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0' ); + $self->assert_file_test($path, '-d'); + + xlog $self, "Run cyr_expire -D now, with -a, skipping annotation."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0', '-a' ); + $self->assert_not_file_test($path, '-d'); + + $self->check_syslog($self->{instance}); +} + +# https://github.com/cyrusimap/cyrus-imapd/issues/2413 +sub test_cyr_expire_dont_resurrect_convdb + :Conversations :DelayedDelete :min_version_3_0 :NoAltNameSpace +{ + my ($self) = @_; + + my $store = $self->{store}; + my $adminstore = $self->{adminstore}; + my $talk = $store->get_client(); + my $admintalk = $adminstore->get_client(); + + my $basedir = $self->{instance}->{basedir}; + + my $inbox = 'INBOX'; + my $subfoldername = 'foo'; + my $subfolder = 'INBOX.foo'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Append a messages to $inbox"; + my %msg_inbox; + $msg_inbox{A} = $self->make_message('Message A in $inbox'); + $self->check_messages(\%msg_inbox); + + xlog $self, "Append 3 messages to $subfolder"; + my %msg_sub; + $store->set_folder($subfolder); + $store->_select(); + $self->{gen}->set_next_uid(1); + $msg_sub{A} = $self->make_message('Message A in $subfolder'); + $msg_sub{B} = $self->make_message('Message B in $subfolder'); + $msg_sub{C} = $self->make_message('Message C in $subfolder'); + $self->check_messages(\%msg_sub); + + $self->check_folder_ondisk($inbox, expected => \%msg_inbox); + $self->check_folder_ondisk($subfolder, expected => \%msg_sub); + $self->check_folder_not_ondisk($inbox, deleted => 1); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + # expect user has a conversations database + my $convdbfile = $self->{instance}->get_conf_user_file("cassandane", "conversations"); + $self->assert_file_test($convdbfile, '-f'); + + # log cassandane user out before it gets thrown out anyway + undef $talk; + $store->disconnect(); + + xlog $self, "Delete cassandane user"; + $admintalk->delete("user.cassandane"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # expect user does not have a conversations database + $self->assert_not_file_test($convdbfile, '-f'); + $self->check_folder_not_ondisk($inbox); + $self->check_folder_ondisk($inbox, deleted => 1); + + xlog $self, "Run cyr_expire -E now."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-E' => '1' ); + $self->check_folder_ondisk($inbox, deleted => 1); + + # expect user does not have a conversations database + $self->assert_not_file_test($convdbfile, '-f'); + + xlog $self, "Run cyr_expire -D now."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0' ); + $self->check_folder_not_ondisk($inbox, deleted => 1); + + # expect user does not have a conversations database + $self->assert_not_file_test($convdbfile, '-f'); + + $self->check_syslog($self->{instance}); +} + +sub test_no_delete_with_children + :DelayedDelete :min_version_3_3 +{ + my ($self) = @_; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $subfolder = 'INBOX.foo'; + my $subsubfolder = 'INBOX.foo.bar'; + + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $talk->create($subsubfolder) + or $self->fail("Cannot create folder $subsubfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $talk->delete($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + + $self->check_syslog($self->{instance}); +} + +sub test_cyr_expire_inherit_annot + :DelayedDelete :min_version_3_9 :NoAltNameSpace +{ + my ($self) = @_; + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Create subfolder"; + my $subfolder = 'INBOX.A'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Set /vendor/cmu/cyrus-imapd/expire annotation on inbox"; + $talk->setmetadata('INBOX', "/shared/vendor/cmu/cyrus-imapd/expire", '1s'); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + xlog $self, "Create message"; + $store->set_folder($subfolder); + $self->make_message('msg1') or die; + + $talk->unselect(); + $talk->select($subfolder); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Run cyr_expire"; + sleep(2); + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-X' => '1s' ); + + $talk->unselect(); + $talk->select($subfolder); + $self->assert_num_equals(0, $talk->get_response_code('exists')); + + $self->check_syslog($self->{instance}); +} + +sub test_cyr_expire_noexpire + :DelayedDelete :min_version_3_9 :NoAltNameSpace +{ + my ($self) = @_; + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $noexpire_annot = '/shared/vendor/cmu/cyrus-imapd/noexpire_until'; + + xlog $self, "Create subfolder"; + my $subfolder = 'INBOX.A'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Set /vendor/cmu/cyrus-imapd/expire annotation on subfolder"; + $talk->setmetadata($subfolder, "/shared/vendor/cmu/cyrus-imapd/expire", '1s'); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + xlog $self, "Create message"; + $store->set_folder($subfolder); + $self->make_message('msg1') or die; + + xlog $self, "Set $noexpire_annot annotation on inbox"; + $talk->setmetadata('INBOX', $noexpire_annot, '0'); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + $talk->unselect(); + $talk->select($subfolder); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + sleep(2); + xlog $self, "Run cyr_expire"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-X' => '1s', '-v', '-v', '-v' ); + + $talk->unselect(); + $talk->select($subfolder); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Remove $noexpire_annot from inbox"; + $talk->setmetadata('INBOX', $noexpire_annot, ''); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + xlog $self, "Run cyr_expire"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-X' => '1s', '-v', '-v', '-v' ); + + $talk->unselect(); + $talk->select($subfolder); + $self->assert_num_equals(0, $talk->get_response_code('exists')); + + $self->check_syslog($self->{instance}); +} + +sub test_cyr_expire_delete_noexpire + :DelayedDelete :min_version_3_9 :NoAltNameSpace +{ + my ($self) = @_; + my $store = $self->{store}; + my $adminstore = $self->{adminstore}; + my $talk = $store->get_client(); + my $admintalk = $adminstore->get_client(); + + my $noexpire_annot = '/shared/vendor/cmu/cyrus-imapd/noexpire_until'; + + my $subfoldername = 'foo'; + my $subfolder = 'INBOX.foo'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Setting /vendor/cmu/cyrus-imapd/delete annotation."; + $talk->setmetadata($subfolder, "/shared/vendor/cmu/cyrus-imapd/delete", '1s'); + + $self->check_folder_ondisk($subfolder); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "Delete $subfolder"; + $talk->unselect(); + $talk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Ensure we can't select $subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + $self->check_folder_not_ondisk($subfolder); + + my ($path) = $self->{instance}->folder_to_deleted_directories("user.cassandane.$subfoldername"); + $self->assert(-d "$path"); + + xlog $self, "Set $noexpire_annot annotation on inbox"; + $talk->setmetadata('INBOX', $noexpire_annot, '0'); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + sleep(2); + xlog $self, "Run cyr_expire"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '1s' ); + $self->assert(-d "$path"); + + xlog $self, "Remove $noexpire_annot annotation from inbox"; + $talk->setmetadata('INBOX', $noexpire_annot, ''); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + xlog $self, "Run cyr_expire"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '1s' ); + $self->assert(!-d "$path"); + + $self->check_syslog($self->{instance}); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Delivery.pm b/cassandane/Cassandane/Cyrus/Delivery.pm new file mode 100644 index 0000000000..a1af76b479 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Delivery.pm @@ -0,0 +1,582 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Delivery; +use strict; +use warnings; +use IO::File; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +Cassandane::Cyrus::TestCase::magic(DuplicateSuppressionOff => sub { + shift->config_set(duplicatesuppression => 0); +}); +Cassandane::Cyrus::TestCase::magic(DuplicateSuppressionOn => sub { + shift->config_set(duplicatesuppression => 1); +}); +Cassandane::Cyrus::TestCase::magic(FuzzyMatch => sub { + shift->config_set(lmtp_fuzzy_mailbox_match => 1); +}); +sub new +{ + my $class = shift; + return $class->SUPER::new({ + deliver => 1, + adminstore => 1, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_plus_address_exact + :FuzzyMatch :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where case matches"; + + my $folder = "INBOX.telephone"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane+telephone"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_underscore + :FuzzyMatch :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where case matches"; + + my $folder = "INBOX.- minusland"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane+-_minusland"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_case + :FuzzyMatch :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where case matches"; + + my $folder = "INBOX.ApplePie"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane+applepie"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_case_defdomain + :FuzzyMatch :VirtDomains :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where case matches"; + + my $folder = "INBOX.ApplePie"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane+applepie\@defdomain"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_case_bogusdomain + :FuzzyMatch :VirtDomains +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where case matches"; + + my $folder = "INBOX.ApplePie"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + my $r = $self->{instance}->deliver( + $msgs{1}, + user => "cassandane+applepie\@bogusdomain" + ); + # expect deliver to exit with EC_DATAERR + $self->assert_not_equals(0, $r); + + xlog $self, "Check that the message didn't make it"; + $self->{store}->set_folder($folder); + $self->check_messages({}, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_bothupper + :FuzzyMatch :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where case matches"; + + my $folder = "INBOX.FlatPack"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane+FlatPack"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_partial + :FuzzyMatch :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where subfolder doesn't exist"; + + my $folder = "INBOX.lists"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane+lists.nonexists"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_partial_case + :FuzzyMatch :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where subfolder doesn't exist"; + + my $folder = "INBOX.Twists"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane+twists.nonexists"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_partial_bothupper + :FuzzyMatch :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing where subfolder doesn't exist"; + + my $folder = "INBOX.Projects"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane+Projects.Grass"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_partial_virtdom + :FuzzyMatch :VirtDomains :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour of plus addressing with virtdomains"; + + my $admintalk = $self->{adminstore}->get_client(); + + $self->{instance}->create_user("domuser\@example.com"); + my $domstore = $self->{instance}->get_service('imap')->create_store(username => "domuser\@example.com") || die "can't create store"; + $self->{store} = $domstore; + my $domtalk = $domstore->get_client(); + + my $folder = "INBOX.Projects"; + + xlog $self, "Create folders"; + $domtalk->create($folder) + or die "Cannot create $folder: $@"; + $domstore->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "domuser+Projects.Grass\@example.com"); + + xlog $self, "Check that the message made it"; + $domstore->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_plus_address_shared + :AltNamespace :UnixHierarchySep +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $shared_mailbox = 'shared/foo'; + + $admintalk->create($shared_mailbox); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl($shared_mailbox, 'cassandane' => 'lrs'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl($shared_mailbox, 'anyone' => 'p'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + my $ret = $self->{instance}->deliver( + $msgs{1}, + user => "+$shared_mailbox\@example.com" + ); + $self->assert_equals(0, $ret); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder("Shared Folders/$shared_mailbox"); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_duplicate_suppression_off + :DuplicateSuppressionOff :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour with duplicate suppression off"; + + # test data from hipsteripsum.me + my $folder = "INBOX.thundercats"; + + xlog $self, "Create the target folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, folder => $folder); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); + + xlog $self, "Try to deliver the same message again"; + $self->{instance}->deliver($msgs{1}, folder => $folder); + + xlog $self, "Check that second copy of the message made it"; + $msgs{2} = $msgs{1}->clone(); + $msgs{2}->set_attribute(uid => 2); + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + + +sub test_duplicate_suppression_on + :DuplicateSuppressionOn :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour with duplicate suppression on"; + + # test data from hipsteripsum.me + my $folder1 = "INBOX.sustainable"; + my $folder2 = "INBOX.artisan"; + + xlog $self, "Create the target folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder1) + or die "Cannot create $folder1: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, folder => $folder1); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder1); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); + + xlog $self, "Try to deliver the same message again"; + $self->{instance}->deliver($msgs{1}, folder => $folder1); + + xlog $self, "Check that second copy of the message didn't make it"; + $self->{store}->set_folder($folder1); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); + + xlog $self, "Rename the folder"; + $imaptalk->rename($folder1, $folder2) + or die "Cannot rename $folder1 to $folder2: $@"; + + xlog $self, "Try to deliver the same message again"; + $self->{instance}->deliver($msgs{1}, folder => $folder2); + + xlog $self, "Check that third copy of the message DIDN'T make it"; + # This is the whole point of duplicate_mailbox_mode = uniqueid. + $self->{store}->set_folder($folder2); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_duplicate_suppression_on_delete + :DuplicateSuppressionOn :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour with duplicate suppression on"; + xlog $self, "interaction with DELETE + CREATE [IRIS-723]"; + + # test data from hipsteripsum.me + my $folder = "INBOX.mixtape"; + + xlog $self, "Create the target folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msgs{1}, folder => $folder); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); + + xlog $self, "Delete the folder"; + $imaptalk->unselect(); + $imaptalk->delete($folder) + or die "Cannot delete $folder: $@"; + + xlog $self, "Create another folder of the same name"; + $imaptalk->create($folder) + or die "Cannot create another $folder: $@"; + + xlog $self, "Check that all messages are gone"; + $self->{store}->set_folder($folder); + $self->check_messages({}, check_guid => 0, keyed_on => 'uid'); + + xlog $self, "Try to deliver the same message to the new folder"; + $self->{instance}->deliver($msgs{1}, folder => $folder); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_duplicate_suppression_on_badmbox + :DuplicateSuppressionOn :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing behaviour with duplicate suppression on"; + xlog $self, "interaction with attempted delivery to a"; + xlog $self, "non-existant mailbox"; + + my $folder = "INBOX.nonesuch"; + # DO NOT create the target folder + + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver a message"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, folder => $folder); + + xlog $self, "Check that the message made it, to INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); + + xlog $self, "Create a folder of the given name"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + + xlog $self, "Try to deliver the same message to the new folder"; + $self->{instance}->deliver($msgs{1}, folder => $folder); + + xlog $self, "Check that the message made it, to the given folder"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); +} + +sub test_auditlog_size + :min_version_3_5 +{ + my ($self) = @_; + + xlog $self, "Testing whether appended message size is auditlogged"; + + # discard syslogs from setup + $self->{instance}->getsyslog(); + + xlog $self, "Deliver a message"; + my $folder = "INBOX"; + my %msgs; + $msgs{1} = $self->{gen}->generate(subject => "Message 1"); + $msgs{1}->set_attribute(uid => 1); + $self->{instance}->deliver($msgs{1}, user => "cassandane"); + + xlog $self, "Check that the message made it"; + $self->{store}->set_folder($folder); + $self->check_messages(\%msgs, check_guid => 0, keyed_on => 'uid'); + + xlog $self, "Check the correct size was auditlogged"; + if ($self->{instance}->{have_syslog_replacement}) { + my @appends = $self->{instance}->getsyslog( + qr/auditlog: append .* uid=<1>/); + $self->assert_num_equals(1, scalar @appends); + + # delivery will add some headers, so it will be larger + my $expected_size = $msgs{1}->size(); + my ($actual_size) = $appends[0] =~ m/ size=<([0-9]+)>/; + $self->assert_num_gte($expected_size, $actual_size); + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Deny.pm b/cassandane/Cassandane/Cyrus/Deny.pm new file mode 100644 index 0000000000..4451debbc9 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Deny.pm @@ -0,0 +1,182 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Deny; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_basic +{ + my ($self) = @_; + + xlog $self, "Test the cyr_deny utility with the imap service"; + + # Data thanks to hipsteripsum.me + my @cases = ({ + # test default options + user => 'helvetica', + opts => [ ], + can_login => 0, + },{ + # test the -s option with our service + user => 'portland', + opts => [ '-s', 'imap' ], + can_login => 0, + },{ + # test the -s option with another service + user => 'stumptown', + opts => [ '-s', 'godard' ], + can_login => 1, + },{ + # test the -m option + user => 'mustache', + opts => [ '-m', 'Bugger off, you' ], + can_login => 0, + },{ + # control case - no cyr_deny command run + user => 'vegan', + can_login => 1, + }); + + + xlog $self, "Create all users"; + foreach my $case (@cases) + { + $self->{instance}->create_user($case->{user}); + } + + xlog $self, "Running cyr_deny for some users"; + foreach my $case (@cases) + { + next unless defined $case->{opts}; + $self->{instance}->run_command({ cyrus => 1 }, + 'cyr_deny', @{$case->{opts}}, $case->{user}); + } + + my $svc = $self->{instance}->get_service('imap'); + foreach my $case (@cases) + { + xlog $self, "Trying to log in as user $case->{user}"; + my $store = $svc->create_store(username => $case->{user}); + if ($case->{can_login}) + { + xlog $self, "Expecting this to succeed"; + my $talk = $store->get_client(); + my $r = $talk->status('inbox', [ 'messages' ]); + $self->assert_deep_equals({ messages => 0 }, $r); + $talk = undef; + } + else + { + xlog $self, "Expecting this to fail"; + eval { $store->get_client(); }; + my $exception = $@; + $self->assert_matches(qr/no - login failed: authorization failure/i, $exception); + } + } +} + +sub test_connected +{ + my ($self) = @_; + + xlog $self, "Test that cyr_deny shuts down any connected sessions"; + + xlog $self, "Create a user"; + my $user = 'gastropub'; + $self->{instance}->create_user($user); + + xlog $self, "Set up a logged-in client for each of two users"; + my $cass_talk = $self->{store}->get_client(); + + my $svc = $self->{instance}->get_service('imap'); + my $user_store = $svc->create_store(username => $user); + my $user_talk = $user_store->get_client(); + + xlog $self, "Check that we can run a command in each of the two clients"; + my $res; + $res = $cass_talk->status('inbox', [ 'messages' ]); + $self->assert_deep_equals({ messages => 0 }, $res); + $res = $user_talk->status('inbox', [ 'messages' ]); + $self->assert_deep_equals({ messages => 0 }, $res); + + $user_talk->clear_response_code('alert'); + + xlog $self, "Deny the user"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_deny', $user); + + xlog $self, "Check that we can run a command in the unaffected user"; + $res = $cass_talk->status('inbox', [ 'messages' ]); + $self->assert_deep_equals({ messages => 0 }, $res); + + xlog $self, "Check that the affected user is disconnected"; + $res = undef; + # Either is_open will return undef, or die; both of these + # are good. If it returned 1 we should worry. + eval { $res = $user_talk->is_open(); }; + $self->assert_null($res); + + # Could do this, but Mail::IMAPTalk drops ALERTs in a BYE response +# $self->assert_matches(qr/Access to this service has been blocked/i, +# $user_talk->get_response_code('alert')); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Expunge.pm b/cassandane/Cassandane/Cyrus/Expunge.pm new file mode 100644 index 0000000000..82e958f501 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Expunge.pm @@ -0,0 +1,281 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Expunge; +use strict; +use warnings; +use JSON::XS; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my ($class, @args) = @_; + return $class->SUPER::new({ adminstore => 1 }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_status_after_expunge +{ + my ($self, $folder, %params) = @_; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $subfolder = 'INBOX.foo'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or die "Cannot create folder $subfolder: $@"; + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Generate messages in $subfolder"; + $store->set_folder($subfolder); + $store->_select(); + for (1..5) { + $self->make_message("Message $subfolder $_"); + } + $talk->unselect(); + $talk->select($subfolder); + + my $stat = $talk->status($subfolder, '(highestmodseq unseen messages)'); + $self->assert_equals(5, $stat->{unseen}); + $self->assert_equals(5, $stat->{messages}); + + $talk->store('1,3,5', '+flags', '(\\Seen)'); + + $stat = $talk->status($subfolder, '(highestmodseq unseen messages)'); + $self->assert_equals(2, $stat->{unseen}); + $self->assert_equals(5, $stat->{messages}); + + $talk->store('1:*', '+flags', '(\\Deleted \\Seen)'); + $talk->expunge(); + + $stat = $talk->status($subfolder, '(highestmodseq unseen messages)'); + $self->assert_equals(0, $stat->{unseen}); + $self->assert_equals(0, $stat->{messages}); +} + +sub test_auditlog_size + :min_version_3_5 +{ + my ($self, $folder, %params) = @_; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $subfolder = 'INBOX.foo'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or die "Cannot create folder $subfolder: $@"; + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Generate messages in $subfolder"; + $store->set_folder($subfolder); + $store->_select(); + for (1..5) { + $self->make_message("Message $subfolder $_"); + } + $talk->unselect(); + $talk->select($subfolder); + + # discard syslogs from setup + $self->{instance}->getsyslog(); + + my $resp = $talk->fetch('1,3,5', 'RFC822.SIZE'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($resp); + my %expected_sizes = map { + $_ => $resp->{$_}->{'rfc822.size'} + } keys %{$resp}; + + $talk->store('1,3,5', '+flags', '(\\Deleted \\Seen)'); + $talk->expunge(); + + if ($self->{instance}->{have_syslog_replacement}) { + my @auditlogs = $self->{instance}->getsyslog(qr/auditlog: expunge/); + + my %actual_sizes = map { + m/ uid=<([0-9]+)>.* size=<([0-9]+)>/ + } @auditlogs; + + $self->assert_deep_equals(\%expected_sizes, \%actual_sizes); + } +} + +sub test_allowdeleted + :AllowDeleted :DelayedExpunge :min_version_3_1 +{ + my ($self, $folder, %params) = @_; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $subfolder = 'INBOX.foo'; + + xlog $self, "First create a sub folder"; + $talk->create($subfolder) + or die "Cannot create folder $subfolder: $@"; + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Generate messages in $subfolder"; + $store->set_folder($subfolder); + $store->_select(); + for (1..5) { + $self->make_message("Message $subfolder $_"); + } + $talk->unselect(); + $talk->select($subfolder); + + my $stat = $talk->status($subfolder, '(highestmodseq unseen messages)'); + $self->assert_equals(5, $stat->{unseen}); + $self->assert_equals(5, $stat->{messages}); + + my $oldemailids = $talk->fetch('1:*', 'emailid'); + my @oldemailids = map { $oldemailids->{$_}{emailid}[0] } sort { $a <=> $b } keys %$oldemailids; + + $talk->store('1,3,5', '+flags', '(\\Deleted)'); + $talk->expunge(); + + $stat = $talk->status($subfolder, '(highestmodseq unseen messages)'); + $self->assert_equals(2, $stat->{unseen}); + $self->assert_equals(2, $stat->{messages}); + + xlog $self, "regular select finds 2 messages"; + $talk->unselect(); + $talk->select($subfolder); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_num_equals(2, $talk->get_response_code('exists')); + + xlog $self, "include-expunged select finds 5 messages"; + $talk->unselect(); + # this API is janky + $talk->select($subfolder, '(vendor.cmu-include-expunged)' => 1); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_num_equals(5, $talk->get_response_code('exists')); + + my $newemailids = $talk->fetch('1:*', 'emailid'); + my @newemailids = map { $newemailids->{$_}{emailid}[0] } sort { $a <=> $b } keys %$newemailids; + $self->assert_deep_equals(\@oldemailids, \@newemailids, Data::Dumper::Dumper(\@oldemailids, \@newemailids)); + + xlog $self, "copy of deleted messages recreates them"; + $talk->copy('1,3,5', $subfolder); + $talk->unselect(); + $talk->select($subfolder); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_num_equals(5, $talk->get_response_code('exists')); + + xlog $self, "new mailbox contains the same emails"; + $newemailids = $talk->fetch('1:*', 'emailid'); + @newemailids = map { $newemailids->{$_}{emailid}[0] } sort { $a <=> $b } keys %$newemailids; + $self->assert_deep_equals([sort @oldemailids], [sort @newemailids], + Data::Dumper::Dumper([sort @oldemailids], [sort @newemailids])); +} + +# XXX this isn't really the right place for this test +sub test_ipurge_mboxevent + :NoAltNameSpace +{ + my ($self) = @_; + + my $shared_folder = 'shared.folder'; + + # set up a shared folder that's easy to write to + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create($shared_folder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl($shared_folder, 'cassandane' => 'lrswipkxtecd'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # put some test messages in shared.folder + my $talk = $self->{store}->get_client(); + $self->{store}->set_folder($shared_folder); + $self->{store}->_select(); + for (1..5) { + $self->make_message("message in $shared_folder $_"); + } + $talk->unselect(); + $talk->select($shared_folder); + + my $stat = $talk->status($shared_folder, '(highestmodseq unseen messages)'); + $self->assert_num_equals(5, $stat->{unseen}); + $self->assert_num_equals(5, $stat->{messages}); + + # consume/discard earlier events that we don't care about + $self->{instance}->getnotify(); + + # run ipurge, and collect any mboxevents it generates + $self->{instance}->run_command( + { cyrus => 1 }, + qw( ipurge -v -i -d 2 ), $shared_folder + ); + my $events = $self->{instance}->getnotify(); + + # if it stays selected you see the intermittent state + $talk->unselect(); + + # the messages we just created should've been expunged + $stat = $talk->status($shared_folder, '(highestmodseq unseen messages)'); + $self->assert_num_equals(0, $stat->{unseen}); + $self->assert_num_equals(0, $stat->{messages}); + + # examine the mboxevents + foreach (@{$events}) { + my $e = decode_json($_->{MESSAGE}); + # uri must contain the mailbox! + $self->assert_matches(qr{^imap://(?:[^/]+)/shared\.folder;UIDVALIDITY=}, + $e->{uri}); + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/FastMail.pm b/cassandane/Cassandane/Cyrus/FastMail.pm new file mode 100644 index 0000000000..1352990243 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/FastMail.pm @@ -0,0 +1,258 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::FastMail; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.09; +use Net::CardDAVTalk 0.05; +use Net::CardDAVTalk::VCard; +use Mail::JMAPTalk 0.12; +use Data::Dumper; +use Storable 'dclone'; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use charnames ':full'; + +our $RNUM = 1; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane', + conversations => 'yes', + httpmodules => 'carddav caldav jmap', + httpallowcompress => 'no', + allowusermoves => 'yes', + altnamespace => 'no', + anyoneuseracl => 'no', + archive_enabled => 'yes', + autoexpunge => 'yes', + caldav_allowattach => 'yes', + caldav_allowscheduling => 'yes', + caldav_create_attach => 'yes', + caldav_create_default => 'no', + caldav_create_sched => 'yes', + caldav_realm => 'FastMail', + calendar_component_set => 'VEVENT', + crossdomains => 'yes', + crossdomains_onlyother => 'yes', + annotation_allow_undefined => 'yes', + conversations => 'yes', + conversations_counted_flags => '\\Draft \\Flagged $IsMailingList $IsNotification $HasAttachment $HasTD', + conversations_max_thread => '100', + mailbox_initial_flags => '$X-ME-Annot-2 $IsMailingList $IsNotification $HasAttachment $HasTD', + defaultacl => 'admin lrswipkxtecdan', + defaultdomain => 'internal', + delete_unsubscribe => 'yes', + expunge_mode => 'delayed', + hashimapspool => 'on', + httpallowcompress => 'no', + httpkeepalive => '0', + httpmodules => 'caldav carddav jmap', + httpprettytelemetry => 'yes', + imapidresponse => 'no', + imapmagicplus => 'yes', + implicit_owner_rights => 'lkn', + internaldate_heuristic => 'receivedheader', + jmap_preview_annot => '/shared/vendor/messagingengine.com/preview', + jmap_nonstandard_extensions => 'yes', + jmapauth_allowsasl => 'yes', + lmtp_fuzzy_mailbox_match => 'yes', + lmtp_exclude_specialuse => '\XChats \XTemplates \XNotes \Drafts \Snoozed', + lmtp_over_quota_perm_failure => 'yes', + maxheaderlines => '4096', + maxword => '8388608', + maxquoted => '8388608', + munge8bit => 'no', + notesmailbox => 'Notes', + popsubfolders => 'yes', + popuseacl => 'yes', + postmaster => 'postmaster@example.com', + quota_db => 'quotalegacy', + quota_use_conversations => 'yes', + quotawarnpercent => '98', + reverseacls => 'yes', + rfc3028_strict => 'no', + savedate => 'yes', + sieve_extensions => 'fileinto reject vacation imapflags notify envelope body relational regex subaddress copy mailbox mboxmetadata servermetadata date index variables imap4flags editheader duplicate vacation-seconds fcc x-cyrus-jmapquery x-cyrus-snooze x-cyrus-log mailboxid special-use', + sieve_utf8fileinto => 'yes', + sieve_use_lmtp_reject => 'no', + sievenotifier => 'mailto', + sieve_maxscriptsize => '1024K', + sieve_vacation_min_response => '60', + specialusealways => 'yes', + specialuse_extra => '\\XChats \\XTemplates \\XNotes', + statuscache => 'on', + subscription_db => 'flat', + suppress_capabilities => 'URLAUTH URLAUTH=BINARY', + tcp_keepalive => 'yes', + timeout => '60', + unix_group_enable => 'no', + unixhierarchysep => 'no', + virtdomains => 'userid', + search_engine => 'xapian', + search_index_headers => 'no', + search_batchsize => '8192', + search_maxtime => '30', + search_snippet_length => '160', + search_query_language => 'yes', + search_index_language => 'yes', + telemetry_bysessionid => 'yes', + delete_mode => 'delayed', + pop3alt_uidl_format => 'dovecot', + event_content_inclusion_mode => 'standard', + event_content_size => '1', + event_exclude_specialuse => '\\Junk', + event_extra_params => 'modseq vnd.fastmail.clientId service uidnext vnd.fastmail.sessionId vnd.cmu.envelope vnd.fastmail.convUnseen vnd.fastmail.convExists vnd.fastmail.cid vnd.cmu.mbtype vnd.cmu.davFilename vnd.cmu.davUid vnd.cmu.mailboxACL vnd.fastmail.counters messages vnd.cmu.unseenMessages flagNames vnd.cmu.emailid vnd.cmu.threadid vnd.cmu.visibleUsers', + event_groups => 'mailbox message flags calendar applepushservice', + event_notifier => 'pusher', + sync_log => 'yes', + ); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + deliver => 1, + adminstore => 1, + services => [ 'imap', 'http', 'sieve' ] + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + ]); +} + +# XXX Cheating and just passing in all the using strings that cyrus +# XXX recognises -- these were ripped from http_jmap.h, try to keep +# XXX them up to date! :) +sub default_using { + return qw( + urn:ietf:params:jmap:core + urn:ietf:params:jmap:mail + urn:ietf:params:jmap:submission + https://cyrusimap.org/ns/jmap/blob + urn:ietf:params:jmap:calendars + https://cyrusimap.org/ns/jmap/contacts + https://cyrusimap.org/ns/jmap/calendars + https://cyrusimap.org/ns/jmap/mail + https://cyrusimap.org/ns/jmap/performance + https://cyrusimap.org/ns/jmap/debug + https://cyrusimap.org/ns/jmap/quota + ); +} + +# XXX This is here as documentation -- these ones are supported by +# XXX cyrus in some, but not all, configurations +# my @non_default_using = qw( +# urn:ietf:params:jmap:vacationresponse +# urn:ietf:params:jmap:websocket +# ); + +sub _fmjmap_req +{ + my ($self, $cmd, %args) = @_; + my $jmap = delete $args{jmap} || $self->{jmap}; + + my $rnum = "R" . $RNUM++; + my $res = $jmap->Request({methodCalls => [[$cmd, \%args, $rnum]], + using => [ $self->default_using ] }); + my $res1 = $res->{methodResponses}[0]; + $self->assert_not_null($res1); + $self->assert_str_equals($rnum, $res1->[2]); + return $res1; +} + +sub _fmjmap_ok +{ + my ($self, $cmd, %args) = @_; + my $res = $self->_fmjmap_req($cmd, %args); + $self->assert_str_equals($cmd, $res->[0]); + return $res->[1]; +} + +sub _fmjmap_err +{ + my ($self, $cmd, %args) = @_; + my $res = $self->_fmjmap_req($cmd, %args); + $self->assert_str_equals("error", $res->[0]); + return $res->[1]; +} + +sub _set_quotaroot +{ + my ($self, $quotaroot) = @_; + $self->{quotaroot} = $quotaroot; +} + +sub _set_quotalimits +{ + my ($self, %resources) = @_; + my $admintalk = $self->{adminstore}->get_client(); + + my $quotaroot = delete $resources{quotaroot} || $self->{quotaroot}; + my @quotalist; + foreach my $resource (keys %resources) + { + my $limit = $resources{$resource} + or die "No limit specified for $resource"; + push(@quotalist, uc($resource), $limit); + } + $self->{limits}->{$quotaroot} = { @quotalist }; + $admintalk->setquota($quotaroot, \@quotalist); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); +} + +use Cassandane::Tiny::Loader 'tiny-tests/FastMail'; + +1; diff --git a/cassandane/Cassandane/Cyrus/Fetch.pm b/cassandane/Cassandane/Cyrus/Fetch.pm new file mode 100644 index 0000000000..743524136b --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Fetch.pm @@ -0,0 +1,1137 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Fetch; +use strict; +use warnings; +use Data::Dumper; +use DateTime; +use IO::Scalar; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Address; +use Cassandane::Util::DateTime qw(to_rfc822); +use Cassandane::Util::Log; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test COPY behaviour with a very long sequence set +# +sub test_fetch_header +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.dest"); + $self->make_message("Test Message"); + + # unfortunately, you can't see the data that went over the wire very easily + # in IMAPTalk - but we know the headers will be in a literal, so.. + my $body = ""; + $imaptalk->literal_handle_control(new IO::Scalar \$body); + my $res = $imaptalk->fetch('1', '(UID FLAGS BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])'); + $imaptalk->literal_handle_control(0); + + $self->assert(defined $res, "Fetch feturned a response"); + $self->assert_matches(qr/^Message-ID: <[^>]+>\s+$/, $body); +} + +# https://github.com/cyrusimap/cyrus-imapd/issues/21 +sub test_duplicate_headers + :min_version_3_0 +{ + my ($self) = @_; + + my $from1 = Cassandane::Address->new(localpart => 'firstsender', + domain => 'example.com'); + my $from2 = Cassandane::Address->new(localpart => 'secondsender', + domain => 'example.com'); + + my $rcpt1 = Cassandane::Address->new(localpart => 'firstrecipient', + domain => 'example.com'); + my $rcpt2 = Cassandane::Address->new(localpart => 'secondrecipient', + domain => 'example.com'); + + my $cc1 = Cassandane::Address->new(localpart => 'firstcc', + domain => 'example.com'); + my $cc2 = Cassandane::Address->new(localpart => 'secondcc', + domain => 'example.com'); + + my $bcc1 = Cassandane::Address->new(localpart => 'firstbcc', + domain => 'example.com'); + my $bcc2 = Cassandane::Address->new(localpart => 'secondbcc', + domain => 'example.com'); + + my $date1 = DateTime->from_epoch(epoch => time()); + my $date2 = DateTime->from_epoch(epoch => time() - 2); + + my $msg = $self->make_message( + 'subject1', + from => $from1, + to => $rcpt1, + cc => $cc1, + bcc => $bcc1, + messageid => 'messageid1@example.com', + date => $date1, + extra_headers => [ + [subject => 'subject2'], + [from => $from2->as_string() ], + [to => $rcpt2->as_string() ], + [cc => $cc2->as_string() ], + [bcc => $bcc2->as_string() ], + ['message-id' => '' ], + [date => to_rfc822($date2) ], + ], + ); + + # Verify that it created duplicate headers, and didn't collate the values. + # If it collated the values, this test proves nothing. + $self->assert_equals(scalar(grep { $_->{name} eq 'subject' } @{$msg->{headers}}), 2); + $self->assert_equals(scalar(grep { $_->{name} eq 'from' } @{$msg->{headers}}), 2); + $self->assert_equals(scalar(grep { $_->{name} eq 'to' } @{$msg->{headers}}), 2); + $self->assert_equals(scalar(grep { $_->{name} eq 'cc' } @{$msg->{headers}}), 2); + $self->assert_equals(scalar(grep { $_->{name} eq 'bcc' } @{$msg->{headers}}), 2); + + # XXX Cassandane::Message's add_header() appends rather than prepends. + # So we currently expect all the "second" values, when we would prefer + # to expect the "first" ones. + my %exp = ( + Subject => 'subject2', + From => $from2->address(), + To => $rcpt2->address(), + Cc => $cc2->address(), + Bcc => $bcc2->address(), + Date => to_rfc822($date2), + 'Message-ID' => '', + 'In-Reply-To' => undef, + ); + + my $imaptalk = $self->{store}->get_client(); + my $res = $imaptalk->fetch('1', 'ENVELOPE'); + + # XXX what behaviour do we expect from Sender and Reply-To headers? + delete $res->{1}->{envelope}->{Sender}; + delete $res->{1}->{envelope}->{'Reply-To'}; + + $self->assert_deep_equals(\%exp, $res->{1}->{envelope}); +} + +sub test_header_multiple +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $extra_headers = [ + ['x-nice-day-for', 'start again (come on)' ], + ['x-awkward', 'interjection' ], + ['x-nice-day-for', 'white wedding' ], + ['x-nice-day-for', 'start agaaain' ], + ]; + + my %exp; + $exp{1} = $self->make_message('message 1', + 'extra_headers' => $extra_headers); + $exp{2} = $self->make_message('nice day'); + $self->check_messages(\%exp); + + my $res = $talk->fetch('1:*', + '(BODY.PEEK[HEADER.FIELDS (x-nice-day-for)])'); + $self->assert_num_equals(2, scalar keys %{$res}); + + my $expected = { + 'x-nice-day-for' => [ + 'start again (come on)', + 'white wedding', + 'start agaaain', + ], + }; + $self->assert_deep_equals($expected, $res->{1}->{headers}); + $self->assert_deep_equals({}, $res->{2}->{headers}); +} + +sub test_fetch_section +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + # Start body + my $body = "--047d7b33dd729737fe04d3bde348\r\n"; + + # Subpart 1 + $body .= "" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "\r\n" + . "body1" + . "\r\n"; + + # Subpart 2 + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: text/html;charset=\"ISO-8859-1\"\r\n" + . "\r\n" + . "

body2

" + . "\r\n"; + + # Subpart 3 + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: multipart/mixed;boundary=frontier\r\n" + . "\r\n"; + + # Subpart 3.1 + $body .= "--frontier\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body31" + . "\r\n"; + + # Subpart 3.2 + $body .= "--frontier\r\n" + . "Content-Type: multipart/mixed;boundary=border\r\n" + . "\r\n" + . "body32" + . "\r\n"; + + # Subpart 3.2.1 + $body .= "--border\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body321" + . "\r\n"; + + # Subpart 3.2.2 + $body .= "--border\r\n" + . "Content-Type: application/octet-stream\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . "PGh0bWw+CiAgPGhlYWQ+CiAg==" + . "\r\n"; + + # End subpart 3.2 + $body .= "--border--\r\n"; + + # End subpart 3 + $body .= "--frontier--\r\n"; + + # Subpart 4 + my $msg4 = "" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "body4"; + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . $msg4 + . "\r\n"; + + # Subpart 5 + my $msg5 = "" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: multipart/mixed;boundary=subpart5\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: baz\r\n" + . "From: blu\@local\r\n" + . "Message-ID: \r\n" + . "Date: Wed, 06 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "--subpart5\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body51" + . "\r\n" + . "--subpart5\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body52" + . "\r\n" + . "--subpart5--\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . $msg5 + . "\r\n"; + + # End body + $body .= "--047d7b33dd729737fe04d3bde348--"; + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body + ); + + my $res; + + $res = $imaptalk->fetch('1', '(BODY[1])'); + $self->assert_str_equals($res->{'1'}->{body}, "body1"); + + $res = $imaptalk->fetch('1', '(BODY[2])'); + $self->assert_str_equals($res->{'1'}->{body}, "

body2

"); + + $res = $imaptalk->fetch('1', '(BODY[3.1])'); + $self->assert_str_equals($res->{'1'}->{body}, "body31"); + + $res = $imaptalk->fetch('1', '(BODY[3.2.1])'); + $self->assert_str_equals($res->{'1'}->{body}, "body321"); + + $res = $imaptalk->fetch('1', '(BODY[3.2.1.MIME])'); + $self->assert($res->{'1'}->{body} =~ m/Content-Type/); + $self->assert(not $res->{'1'}->{body} =~ m/body321/); + + $res = $imaptalk->fetch('1', '(BODY[3.2.2])'); + $self->assert_str_equals($res->{'1'}->{body}, "PGh0bWw+CiAgPGhlYWQ+CiAg=="); + + $res = $imaptalk->fetch('1', '(BODY[3.2.2]<4.3>)'); + $self->assert_str_equals($res->{'1'}->{body}, substr("PGh0bWw+CiAgPGhlYWQ+CiAg==", 4, 3)); + + $res = $imaptalk->fetch('1', '(BODY.PEEK[4.HEADER.FIELDS (CONTENT-TYPE)])'); + $self->assert_str_equals($res->{'1'}->{headers}->{"content-type"}[0], "text/plain"); + + $res = $imaptalk->fetch('1', '(BODY[4.1.MIME])'); + $self->assert($res->{'1'}->{body} =~ m/Content-Type/); + + $res = $imaptalk->fetch('1', '(BODY[4])'); + $self->assert_str_equals($res->{'1'}->{body}, $msg4); + + $res = $imaptalk->fetch('1', '(BODY[5.2])'); + $self->assert_str_equals($res->{'1'}->{body}, "body52"); + + # Check for some bogus subparts + $res = $imaptalk->fetch('1', '(BODY[3.2.3])'); + $self->assert_null($res->{'1'}->{body}); + + $res = $imaptalk->fetch('1', '(BODY[3.2.1.2])'); + $self->assert_null($res->{'1'}->{body}); + + $res = $imaptalk->fetch('1', '(BODY[4.2])'); + $self->assert_null($res->{'1'}->{body}); + + $res = $imaptalk->fetch('1', '(BODY[-1])'); + $self->assert_null($res->{'1'}->{body}); +} + +sub test_fetch_section_multipart +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + # Start body + my $body = "--047d7b33dd729737fe04d3bde348\r\n"; + + # Subpart 1 + $body .= "" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "\r\n" + . "body1" + . "\r\n"; + + # Subpart 2 + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: text/html;charset=\"ISO-8859-1\"\r\n" + . "\r\n" + . "

body2

" + . "\r\n"; + + # Subpart 3 + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: multipart/mixed;boundary=frontier\r\n" + . "\r\n"; + + # Subpart 3.1 + $body .= "--frontier\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body31" + . "\r\n"; + + # Subpart 3.2 + $body .= "--frontier\r\n" + . "Content-Type: multipart/mixed;boundary=border\r\n" + . "\r\n" + . "body32" + . "\r\n"; + + # Subpart 3.2.1 + $body .= "--border\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body321" + . "\r\n"; + + # Subpart 3.2.2 + $body .= "--border\r\n" + . "Content-Type: application/octet-stream\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . "PGh0bWw+CiAgPGhlYWQ+CiAg==" + . "\r\n"; + + # End subpart 3.2 + $body .= "--border--\r\n"; + + # End subpart 3 + $body .= "--frontier--\r\n"; + + # End body + $body .= "--047d7b33dd729737fe04d3bde348--"; + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body + ); + + my $res; + + $res = $imaptalk->fetch('1', '(BODY[1])'); + $self->assert_str_equals($res->{'1'}->{body}, "body1"); + + $res = $imaptalk->fetch('1', '(BODY[2])'); + $self->assert_str_equals($res->{'1'}->{body}, "

body2

"); + + $res = $imaptalk->fetch('1', '(BODY[3.1])'); + $self->assert_str_equals($res->{'1'}->{body}, "body31"); + + $res = $imaptalk->fetch('1', '(BODY[3.2.1])'); + $self->assert_str_equals($res->{'1'}->{body}, "body321"); + + $res = $imaptalk->fetch('1', '(BODY[3.2.1.MIME])'); + $self->assert($res->{'1'}->{body} =~ m/Content-Type/); + $self->assert(not $res->{'1'}->{body} =~ m/body321/); + + $res = $imaptalk->fetch('1', '(BODY[3.2.2])'); + $self->assert_str_equals($res->{'1'}->{body}, "PGh0bWw+CiAgPGhlYWQ+CiAg=="); + + $res = $imaptalk->fetch('1', '(BODY[3.2.2]<4.3>)'); + $self->assert_str_equals($res->{'1'}->{body}, substr("PGh0bWw+CiAgPGhlYWQ+CiAg==", 4, 3)); + + # Check for some bogus subparts + $res = $imaptalk->fetch('1', '(BODY[3.2.3])'); + $self->assert_null($res->{'1'}->{body}); + + $res = $imaptalk->fetch('1', '(BODY[3.2.1.2])'); + $self->assert_null($res->{'1'}->{body}); + + $res = $imaptalk->fetch('1', '(BODY[4.2])'); + $self->assert_null($res->{'1'}->{body}); + + $res = $imaptalk->fetch('1', '(BODY[-1])'); + $self->assert_null($res->{'1'}->{body}); +} + +sub test_fetch_section_rfc822digest +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $ct = "multipart/digest; boundary=\"foo\""; + my $from = "sub\@domain.org"; + my $date = "Sun, 12 Aug 2012 12:34:56 +0300"; + my $subj = "submsg"; + + my $body = "" + . "From: $from\r\n" + . "Date: $date\r\n" + . "Subject: $subj\r\n" + . "Content-Type: $ct\r\n" + . "\r\n" + . "prologue\r\n" + . "\r\n" + . "--foo\r\n" + . "\r\n" + . "From: m1\@example.com\r\n" + . "Subject: m1\r\n" + . "\r\n" + . "m1 body\r\n" + . "\r\n" + . "--foo\r\n" + . "X-Mime: m2 header\r\n" + . "\r\n" + . "From: m2\@example.com\r\n" + . "Subject: m2\r\n" + . "\r\n" + . "m2 body\r\n" + . "\r\n" + . "--foo--\r\n" + . "\r\n" + . "epilogue\r\n" + . "\r\n"; + + $self->make_message("foo", + mime_type => "message/rfc822", + body => $body, + ); + + my $res; + + $res = $imaptalk->fetch('1', '(BODY.PEEK[TEXT])'); + $self->assert_str_equals($res->{'1'}->{body}, $body); + + $res = $imaptalk->fetch('1', '(BODY.PEEK[1])'); + $self->assert_str_equals($res->{'1'}->{body}, $body); + + $res = $imaptalk->fetch('1', '(BODY.PEEK[1.HEADER])'); + $self->assert_str_equals($res->{'1'}->{headers}->{"content-type"}[0], $ct); + $self->assert_str_equals($res->{'1'}->{headers}->{"date"}[0], $date); + $self->assert_str_equals($res->{'1'}->{headers}->{"from"}[0], $from); + $self->assert_str_equals($res->{'1'}->{headers}->{"subject"}[0], $subj); +} + +sub test_fetch_section_rfc822 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $body = "" + . "From: sub\@domain.org\r\n" + . "Date: Sun, 12 Aug 2012 12:34:56 +0300\r\n" + . "Subject: submsg\r\n" + . "\r\n" + . "foo"; + + $self->make_message("foo", + mime_type => "message/rfc822", + body => $body, + ); + + my $res; + + $res = $imaptalk->fetch('1', '(BODY.PEEK[TEXT])'); + $self->assert_str_equals($res->{'1'}->{body}, $body); + + $res = $imaptalk->fetch('1', '(BODY.PEEK[1])'); + $self->assert_str_equals($res->{'1'}->{body}, $body); + + $res = $imaptalk->fetch('1', '(BODY.PEEK[1.TEXT])'); + $self->assert_str_equals($res->{'1'}->{body}, "foo"); + + $res = $imaptalk->fetch('1', '(BODY.PEEK[1.1])'); + $self->assert_str_equals($res->{'1'}->{body}, "foo"); +} + + +sub test_fetch_section_nomultipart +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->make_message( + "foo", + from => Cassandane::Address->new( + localpart => 'foo', + domain => 'example.com', + ), + mime_type => "text/plain", + body => "body1", + ); + + my $res; + + $res = $imaptalk->fetch('1', '(BODY[1])'); + $self->assert_str_equals($res->{'1'}->{body}, "body1"); + + # RFC 3501: "Every message has at least one part number." + $res = $imaptalk->fetch('1', '(BODY[1.MIME])'); + $self->assert($res->{'1'}->{body} =~ m/Content-Type/); + $self->assert(not $res->{'1'}->{body} =~ m/body1/); + + $res = $imaptalk->fetch('1', '(BODY[HEADER])'); + $self->assert($res->{'1'}->{body} =~ m/Content-Type/); + + $res = $imaptalk->fetch('1', '(BODY.PEEK[HEADER.FIELDS (FROM)])'); + $self->assert_str_equals($res->{'1'}->{headers}->{from}[0], ""); + + $res = $imaptalk->fetch('1', '(BODY[1.HEADER])'); + $self->assert_null($res->{'1'}->{body}); + + # invalid + $res = $imaptalk->fetch('1', '(BODY[0])'); + $self->assert_null($res->{'1'}->{body}); + + # invalid + $res = $imaptalk->fetch('1', '(BODY[1.1])'); + $self->assert_null($res->{'1'}->{body}); + + # invalid + $res = $imaptalk->fetch('1', '(BODY[0.1])'); + $self->assert_null($res->{'1'}->{body}); + + # invalid + $res = $imaptalk->fetch('1', '(BODY[1.0])'); + $self->assert_null($res->{'1'}->{body}); + + $res = $imaptalk->fetch('1', '(BINARY[1]<0.2>)'); + $self->assert_str_equals($res->{'1'}->{binary}, "bo"); + + $res = $imaptalk->fetch('1', '(BINARY[1]<2.10>)'); + $self->assert_str_equals($res->{'1'}->{binary}, "dy1"); + + $res = $imaptalk->fetch('1', '(BINARY[1]<10.12>)'); + $self->assert_str_equals($res->{'1'}->{binary}, ""); +} + +sub test_fetch_urlfetch +{ + my ($self) = @_; + + my %exp_sub; + my $store = $self->{store}; + my $talk = $store->get_client(); + + $store->set_folder("INBOX"); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body; + + # Subpart 1 + $body = "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "\r\n" + . "body1" + . "\r\n"; + + # Subpart 2 + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: multipart/mixed;boundary=frontier\r\n" + . "\r\n"; + + # Subpart 2.1 + $body .= "--frontier\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body21" + . "\r\n"; + + # End subpart 2 + $body .= "--frontier--\r\n"; + + # Subpart 3 + my $msg3 = "" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "body3"; + + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . $msg3 + . "\r\n"; + + # End body + $body .= "--047d7b33dd729737fe04d3bde348--"; + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body + ); + + my $uid; + my %handlers = + ( + appenduid => sub + { + my ($cmd, $ids) = @_; + $uid = ${$ids}[1]; + }, + ); + + my $res; + + # Copy the whole message + $res = $talk->_imap_cmd('append', 0, \%handlers, + 'INBOX', [], "14-Jul-2013 17:01:02 +0000", + "CATENATE", [ + "URL", "/INBOX/;uid=1/;section=HEADER", + "URL", "/INBOX/;uid=1/;section=TEXT", + ], + ); + $self->assert_not_null($uid); + $res = $talk->fetch($uid, '(BODY.PEEK[TEXT])'); + $self->assert_str_equals($res->{$uid}->{body}, $body); + + # Merge the headers of an embedded RFC822 message with a plaintext subpart + $res = $talk->_imap_cmd('append', 0, \%handlers, + 'INBOX', [], "14-Jul-2013 17:01:02 +0000", + "CATENATE", [ + "URL", "/INBOX/;uid=1/;section=3.HEADER", + "URL", "/INBOX/;uid=1/;section=2.1", + ], + ); + $self->assert_not_null($uid); + $res = $talk->fetch($uid, '(BODY.PEEK[TEXT] BODY.PEEK[HEADER.FIELDS (CONTENT-TYPE)])'); + $self->assert_str_equals($res->{$uid}->{headers}->{'content-type'}[0], "text/plain"); + $self->assert_str_equals($res->{$uid}->{body}, "body21"); + + # Extract an embedded RFC822 message into a new standalone message + $res = $talk->_imap_cmd('append', 0, \%handlers, + 'INBOX', [], "14-Jul-2013 17:01:02 +0000", + "CATENATE", [ + "URL", "/INBOX/;uid=1/;section=3", + ], + ); + $self->assert_not_null($uid); + $res = $talk->fetch($uid, '(BODY.PEEK[TEXT] BODY.PEEK[HEADER.FIELDS (CONTENT-TYPE)])'); + $self->assert_str_equals($res->{$uid}->{headers}->{'content-type'}[0], "text/plain"); + $self->assert_str_equals($res->{$uid}->{body}, "body3"); + + # Extract part of an embedded RFC822 message into a new standalone message + $res = $talk->_imap_cmd('append', 0, \%handlers, + 'INBOX', [], "14-Jul-2013 17:01:02 +0000", + "CATENATE", [ + "URL", "/INBOX/;uid=1/;section=3.HEADER", + "URL", "/INBOX/;uid=1/;section=3.TEXT;partial=1.3", + ], + ); + $self->assert_not_null($uid); + $res = $talk->fetch($uid, '(BODY.PEEK[TEXT] BODY.PEEK[HEADER.FIELDS (CONTENT-TYPE)])'); + $self->assert_str_equals($res->{$uid}->{headers}->{'content-type'}[0], "text/plain"); + $self->assert_str_equals($res->{$uid}->{body}, "ody"); +} + +sub test_fetch_flags_before_exists +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->select('user.cassandane'); + $self->make_message("Test Message"); + # this sets the state with EXISTS = 1 + $admintalk->fetch('1:*', '(flags)'); + + $self->make_message("Test Message"); +# $res = $admintalk->fetch('1:*', '(flags)'); + + # need to make our own handlers + my %handlers; + { + my $sawfetch = -1; + use Data::Dumper; + $handlers{fetch} = sub { $sawfetch = $_[2] if $sawfetch < $_[2] }; + $handlers{exists} = sub { die "Got exists count too late for $_[2]" if $_[2] <= $sawfetch }; + } + + # expecting to see EXISTS 2 before FETCH 2 + $admintalk->_imap_cmd("fetch", 1, \%handlers, \'1:*', '(flags)'); +} + +sub test_tell_exists_count_earlier + :min_version_3_0 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->select('user.cassandane'); + + $self->make_message("Test Message 1"); + $admintalk->fetch('1:*', '(flags)'); + + $self->make_message("Test Message 2"); + $admintalk->fetch('2:*', '(flags)'); + + $self->make_message("Test Message 3"); + $admintalk->fetch('3:*', '(uid flags)'); + + $admintalk->unselect(); + $admintalk->select('user.cassandane'); + + $imaptalk->store('2', '+flags', '(\\Flagged)'); + $self->make_message("Test Message 4"); + + my %handlers; + { + my $sawfetch = -1; + my $sawmsg2fetch = -1; + my $sawmsg3fetch = -1; + my $sawmsg4fetch = -1; + use Data::Dumper; + $handlers{fetch} = sub { + $sawfetch = 1 if $sawfetch < 0; + + if ($_[2] == 2) { $sawmsg2fetch = $_[2] } + elsif ($_[2] == 3) { + $sawmsg3fetch = $_[2]; + die "Got FETCH for 3 before 2" if $sawmsg2fetch < 0; + } + elsif ($_[2] == 3) { + $sawmsg4fetch = $_[2]; + die "Got FETCH for 4 before 2" if $sawmsg2fetch < 0; + die "Got FETCH for 4 before 3" if $sawmsg3fetch < 0; + } + else { } + }; + $handlers{exists} = sub { die "Got EXISTS after FETCH for $_[2]" if $sawfetch > 0; }; + } + + $admintalk->_imap_cmd("fetch", 1, \%handlers, \'3:*', '(uid flags)'); +} + +sub test_mailboxids + :min_version_3_1 :Conversations +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $res = $imaptalk->status('INBOX', ['mailboxid']); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + my $inbox_id = $res->{'mailboxid'}->[0]; + $self->assert_not_null($inbox_id); + + $imaptalk->create("INBOX.target"); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $res = $imaptalk->status('INBOX.target', ['mailboxid']); + my $target_id = $res->{'mailboxid'}->[0]; + $self->assert_not_null($target_id); + + # make a message + my $msg = $self->make_message("test message"); + my $uid = $msg->{attrs}->{uid}; + + # expect to find it in INBOX only + $res = $imaptalk->fetch('1', '(MAILBOXES MAILBOXIDS)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals([$inbox_id], $res->{1}->{'mailboxids'}); + $self->assert_deep_equals(['INBOX'], $res->{1}->{'mailboxes'}); + + # copy it to INBOX.target + $imaptalk->copy($uid, "INBOX.target"); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # expect to find it in INBOX and INBOX.target + $res = $imaptalk->fetch('1', '(MAILBOXES MAILBOXIDS)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals([$inbox_id, $target_id], + $res->{1}->{'mailboxids'}); + $self->assert_deep_equals(['INBOX', 'INBOX.target'], + $res->{1}->{'mailboxes'}); + + # delete it from INBOX + $imaptalk->store('1', '+FLAGS', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # expect to find it in INBOX.target only + $res = $imaptalk->fetch('1', '(MAILBOXES MAILBOXIDS)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals([$target_id], + $res->{1}->{'mailboxids'}); + $self->assert_deep_equals(['INBOX.target'], + $res->{1}->{'mailboxes'}); + + # expunge INBOX + $imaptalk->expunge(); + + # expect to find it in INBOX.target only + $res = $imaptalk->fetch('1', '(MAILBOXES MAILBOXIDS)'); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $imaptalk->select('INBOX.target'); + $res = $imaptalk->fetch('1', '(MAILBOXES MAILBOXIDS)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals([$target_id], + $res->{1}->{'mailboxids'}); + $self->assert_deep_equals(['INBOX.target'], + $res->{1}->{'mailboxes'}); +} + +sub test_mailboxids_noconversations + :min_version_3_1 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + # make a message + my $msg = $self->make_message("test message"); + my $uid = $msg->{attrs}->{uid}; + + # expect FETCH MAILBOXES to be rejected + my $res = $imaptalk->fetch('1', '(MAILBOXES)'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + # expect FETCH MAILBOXIDS to be rejected + $res = $imaptalk->fetch('1', '(MAILBOXIDS)'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); +} + +# test for older draft preview behaviour, obsoleted by publication of +# RFC 8970 +sub test_preview_args_legacy + :min_version_3_1 :max_version_3_4 :Conversations +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + # make a message + my $msg = $self->make_message("test message"); + my $uid = $msg->{attrs}->{uid}; + + my $res; + + # expect no name to be accepted + $res = $imaptalk->fetch('1', '(PREVIEW)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # expect no name to be accepted + $res = $imaptalk->fetch('1', '(PREVIEW ())'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # expect bad name to be rejected + $res = $imaptalk->fetch('1', '(PREVIEW (FUZZY=BUZZY))'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + # expect fuzzy name to be accepted + $res = $imaptalk->fetch('1', '(PREVIEW (FUZZY))'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # expect lazy fuzzy name to be accepted + $res = $imaptalk->fetch('1', '(PREVIEW (LAZY=FUZZY))'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); +} + +sub test_preview_args + :min_version_3_5 :Conversations +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + # make a message + my $msg = $self->make_message("test message"); + my $uid = $msg->{attrs}->{uid}; + + my $res; + + # expect no modifier to be accepted + $res = $imaptalk->fetch('1', '(PREVIEW)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # expect empty modifier list to be rejected + $res = $imaptalk->fetch('1', '(PREVIEW ())'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + # expect bad modifier name to be rejected + $res = $imaptalk->fetch('1', '(PREVIEW (FOO))'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + # expect lazy modifier to be accepted + $res = $imaptalk->fetch('1', '(PREVIEW (LAZY))'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # expect lazy + bad modifier to be rejected + $res = $imaptalk->fetch('1', '(PREVIEW (LAZY FOO))'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); +} + +sub test_unknown_cte +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $ct = "multipart/mixed"; + my $boundary = "mitomycin"; + + # Subpart 1 + my $body = "" + . "--$boundary\r\n" + . "Content-Type: text/plain; charset=\"utf8\"\r\n" + . "\r\n" + . "The attachment to this email is meaningless gibberish\r\n"; + + # Subpart 2 + $body .= "" + . "--$boundary\r\n" + . "Content-Disposition: attachment; filename=\"gibberish.dat\"\r\n" + . "Content-Type: application/octet-stream; name=\"gibberish.dat\"\r\n" + . "Content-Transfer-Encoding: x-no-such-encoding\r\n" + . "\r\n" + . "There'll never be a decoder for this encoding, so this\r\n" + . "text can't possibly ever decode to something coherent.\r\n" + . "--$boundary--\r\n"; + + my $msg = $self->make_message("foo", + mime_type => $ct, + mime_boundary => $boundary, + body => $body + ); + my $uid = $msg->{attrs}->{uid}; + my $res; + + # fetch BINARY.PEEK should fail + $res = $imaptalk->fetch('1', '(BINARY.PEEK[2])'); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert_matches(qr{UNKNOWN-CTE}, $imaptalk->get_last_error()); + + # fetch BINARY.SIZE should fail + $res = $imaptalk->fetch('1', '(BINARY.SIZE[2])'); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert_matches(qr{UNKNOWN-CTE}, $imaptalk->get_last_error()); + + # enable UID mode... + $imaptalk->uid(1); + + # UID fetch BINARY.PEEK should fail + $res = $imaptalk->fetch($uid, '(BINARY.PEEK[2])'); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert_matches(qr{UNKNOWN-CTE}, $imaptalk->get_last_error()); + + # UID fetch BINARY.SIZE should fail + $res = $imaptalk->fetch($uid, '(BINARY.SIZE[2])'); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert_matches(qr{UNKNOWN-CTE}, $imaptalk->get_last_error()); +} + +sub test_partial +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "append some messages"; + my %exp; + my $N = 10; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + # expunge the 1st and 6th + $imaptalk->store('1,6', '+FLAGS', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $imaptalk->expunge(); + + # fetch all + my $res = $imaptalk->fetch('1:*', '(UID)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals($res->{'1'}->{uid}, "2"); + $self->assert_str_equals($res->{'2'}->{uid}, "3"); + $self->assert_str_equals($res->{'3'}->{uid}, "4"); + $self->assert_str_equals($res->{'4'}->{uid}, "5"); + $self->assert_str_equals($res->{'5'}->{uid}, "7"); + $self->assert_str_equals($res->{'6'}->{uid}, "8"); + $self->assert_str_equals($res->{'7'}->{uid}, "9"); + $self->assert_str_equals($res->{'8'}->{uid}, "10"); + + # fetch first 2 + $res = $imaptalk->fetch('1:*', '(UID) (PARTIAL 1:2)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals($res->{'1'}->{uid}, "2"); + $self->assert_str_equals($res->{'2'}->{uid}, "3"); + + # fetch next 2 + $res = $imaptalk->fetch('1:*', '(UID) (PARTIAL 3:4)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals($res->{'3'}->{uid}, "4"); + $self->assert_str_equals($res->{'4'}->{uid}, "5"); + + # fetch last 2 + $res = $imaptalk->fetch('1:*', '(UID) (PARTIAL -1:-2)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals($res->{'8'}->{uid}, "10"); + $self->assert_str_equals($res->{'7'}->{uid}, "9"); + + # fetch the previous 2 + $res = $imaptalk->fetch('1:*', '(UID) (PARTIAL -3:-4)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals($res->{'6'}->{uid}, "8"); + $self->assert_str_equals($res->{'5'}->{uid}, "7"); + + # enable UID mode... + $imaptalk->uid(1); + + # fetch the middle 2 by UID + $res = $imaptalk->fetch('4:8', '(UID) (PARTIAL 2:3)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals($res->{'5'}->{uid}, "5"); + $self->assert_str_equals($res->{'7'}->{uid}, "7"); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Flags.pm b/cassandane/Cassandane/Cyrus/Flags.pm new file mode 100644 index 0000000000..3a7b60f6ed --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Flags.pm @@ -0,0 +1,1606 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Flags; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Words; +use JSON; + +sub new +{ + my $class = shift; + return $class->SUPER::new({adminstore => 1}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test that +# - the \Deleted flag can be set +# - the message still exists with \Deleted in flags +# - after EXPUNGE the message is gone +# - UIDs remain stable after the expunge +# - message numbers remain contiguous after the expunge +# even when UIDs aren't contiguous anymore +# +sub test_deleted +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Append 3 messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $msg{C} = $self->make_message('Message C'); + $msg{C}->set_attributes(id => 3, + uid => 3, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Mark the middle message \\Deleted"; + my $res = $talk->store('2', '+flags', '(\\Deleted)'); + $self->assert_deep_equals({ '2' => { 'flags' => [ '\\Deleted' ] }}, $res); + $msg{B}->set_attribute(flags => ['\\Deleted']); + $self->check_messages(\%msg); + + xlog $self, "Expunge the middle message"; + $talk->expunge(); + delete $msg{B}; + $msg{A}->set_attribute(id => 1); + $msg{C}->set_attribute(id => 2); + $self->check_messages(\%msg); +# +# $talk->store($seq', '+flags', '(\\flagged)') or die $@; +} + +# +# Test that +# - the \Seen flag can be set +# - the \Seen flag can be cleared again +# - other messages don't get the \Seen flag +# - once set, it's persistent across sessions +# +# Note that we do this test again for \Flagged because +# \Seen is a special case in the backend. +# +# TODO: test that \Seen gets set as a side effect of +# doing body fetches. +# +sub test_seen +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \\Seen on message A"; + my $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Seen on message A"; + $res = $talk->store('1', '-flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \\Seen on message A again"; + $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Reconnect, \\Seen should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +# +# Test that +# - the \Seen flag can be set +# - the \Seen flag can be cleared again +# - other messages don't get the \Seen flag +# - once set, it's persistent across sessions +# +# Note that we do this test again for \Flagged because +# \Seen is a special case in the backend. +# +# TODO: test that \Seen gets set as a side effect of +# doing body fetches. +# +sub test_seen_otheruser +{ + my ($self) = @_; + + # no particular reason to use an admin rather than just another user, + # but it's easy + my $admintalk = $self->{adminstore}->get_client(); + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + $self->{adminstore}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + # select AFTER creating messages so we don't get \Recent + $admintalk->select('user.cassandane'); + $admintalk->unselect(); + $admintalk->select('user.cassandane'); + + xlog $self, "Set \\Seen on message A"; + my $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $self->check_messages(\%msg, store => $self->{adminstore}); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Set \\Seen on message A as admin"; + $res = $admintalk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $self->check_messages(\%msg, store => $self->{adminstore}); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Seen on message A"; + $res = $talk->store('1', '-flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $self->check_messages(\%msg, store => $self->{adminstore}); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Seen on message A as admin"; + $res = $admintalk->store('1', '-flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $self->check_messages(\%msg, store => $self->{adminstore}); + $self->check_messages(\%msg); +} + +# https://github.com/cyrusimap/cyrus-imapd/issues/3240 +sub test_seen_sharedmb_nosharedseen + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $folder = 'shared'; + + # shared mailbox with sharedseen=false + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create($folder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl($folder, 'cassandane' => 'lrswipkxtecdan'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setmetadata($folder, + '/shared/vendor/cmu/cyrus-imapd/sharedseen' => 'false' + ); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + + # add some messages + my $talk = $self->{store}->get_client(); + $self->{store}->set_folder("Shared Folders/$folder"); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + # fiddle with seen flag, making sure we get both the expected results + # and the expected untagged fetch response + xlog $self, "Set \\Seen on message A"; + my $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Seen on message A"; + $res = $talk->store('1', '-flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \\Seen on message A again"; + $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + # seen flag should survive a reconnect + xlog $self, "Reconnect, \\Seen should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +# https://github.com/cyrusimap/cyrus-imapd/issues/4611 +sub test_seen_sharedmb_sharedseen + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $folder = 'shared'; + + # shared mailbox with sharedseen=false + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create($folder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl($folder, 'cassandane' => 'lrswipkxtecdan'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setmetadata($folder, + '/shared/vendor/cmu/cyrus-imapd/sharedseen' => 'true' + ); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + + # add some messages + my $talk = $self->{store}->get_client(); + $self->{store}->set_folder("Shared Folders/$folder"); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + # fiddle with seen flag, making sure we get both the expected results + # and the expected untagged fetch response + xlog $self, "Set \\Seen on message A"; + my $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Seen on message A"; + $res = $talk->store('1', '-flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \\Seen on message A again"; + $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + # seen flag should survive a reconnect + xlog $self, "Reconnect, \\Seen should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +# +# Test that +# - the \Flagged flag can be set +# - the \Flagged flag can be cleared again +# - other messages don't get the \Flagged flag +# - once set, it's persistent across sessions +# +sub test_flagged +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \\Flagged on message A"; + my $res = $talk->store('1', '+flags', '(\\Flagged)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Flagged' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Flagged']); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Flagged on message A"; + $res = $talk->store('1', '-flags', '(\\Flagged)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \\Flagged on message A again"; + $res = $talk->store('1', '+flags', '(\\Flagged)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Flagged' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Flagged']); + $self->check_messages(\%msg); + + xlog $self, "Reconnect, \\Flagged should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +# +# Test that +# - the $Foobar flag can be set +# - the $Foobar flag can be cleared again +# - other messages don't get the $Foobar flag +# - once set, it's persistent across sessions +# +# This is basically the same test as for \Flagged but with a user flag, +# which is an entirely different code path in the server. +# +sub test_userflag +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \$Foobar on message A"; + my $res = $talk->store('1', '+flags', '($Foobar)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '$Foobar' ] }}, $res); + $msg{A}->set_attribute(flags => ['$Foobar']); + $self->check_messages(\%msg); + + xlog $self, "Clear \$Foobar on message A"; + $res = $talk->store('1', '-flags', '($Foobar)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \$Foobar on message A again"; + $res = $talk->store('1', '+flags', '($Foobar)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '$Foobar' ] }}, $res); + $msg{A}->set_attribute(flags => ['$Foobar']); + $self->check_messages(\%msg); + + xlog $self, "Reconnect, \$Foobar should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +# +# Test that +# - the $Foobar flag can be set +# - the $Foobar flag can be cleared again +# - cyr_expire -t can remove the $Foobar flag from the mailbox permanentflags +# +# +sub test_expunge_removeflag +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + my $perm = $talk->get_response_code('permanentflags'); + my @flags = grep { !m{^\\} } @$perm; + $self->assert_deep_equals([], \@flags); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \$Foobar on message A"; + my $res = $talk->store('1', '+flags', '($Foobar)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '$Foobar' ] }}, $res); + $msg{A}->set_attribute(flags => ['$Foobar']); + $self->check_messages(\%msg); + + xlog $self, "Clear \$Foobar on message A"; + $res = $talk->store('1', '-flags', '($Foobar)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $talk = $self->{store}->get_client(); + + $self->check_messages(\%msg); + + xlog $self, "Flag is still in the mailbox"; + + $perm = $talk->get_response_code('permanentflags'); + @flags = grep { !m{^\\} } @$perm; + $self->assert_deep_equals(['$Foobar'], \@flags); + + $self->{store}->disconnect(); + + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-t'); + + $self->{store}->connect(); + $self->{store}->_select(); + $talk = $self->{store}->get_client(); + + $perm = $talk->get_response_code('permanentflags'); + @flags = grep { !m{^\\} } @$perm; + $self->assert_deep_equals([], \@flags); +} + +# +# Test that +# - 100 separate user flags can be used +# - no more can be used +# - (we lock out at 100 except for replication to avoid +# - one-extra problems) +# +use constant MAX_USER_FLAGS => 100; +sub test_max_userflags +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + my %allflags; + for (my $i = 0 ; $i < MAX_USER_FLAGS ; $i++) + { + my $flag; + + for (;;) + { + $flag = '$' . ucfirst(random_word()); + if (!defined $allflags{$flag}) + { + $allflags{$flag} = $i; + last; + } + } + + xlog $self, "Set $flag on message A"; + my $res = $talk->store('1', '+flags', "($flag)"); + $self->assert_deep_equals({ '1' => { 'flags' => [ "$flag" ] }}, + $res); + $msg{A}->set_attribute(flags => [$flag]); + $self->check_messages(\%msg); + + xlog $self, "Clear $flag on message A"; + $res = $talk->store('1', '-flags', "($flag)"); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + } + + xlog $self, "Cannot set one more wafer-thin user flag"; + my $flag = '$Farnarkle'; + $self->assert_null($allflags{$flag}); + my $res = $talk->store('1', '+flags', "($flag)"); + my $e = $@; + $self->assert_null($res); + $self->assert_matches(qr/Too many user flags in mailbox/, $e); + + # We should have generated an IOERROR + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: out of flags/); + + xlog $self, "Can set all the flags at once"; + my @flags = sort { $allflags{$a} <=> $allflags{$b} } (keys %allflags); + xlog $self, "Set all the user flags on message A"; + $res = $talk->store('1', '+flags', '(' . join(' ',@flags) . ')'); + $self->assert_deep_equals({ '1' => { 'flags' => [ @flags ] }}, + $res); + $msg{A}->set_attribute(flags => [@flags]); + $self->check_messages(\%msg); + + xlog $self, "Reconnect, all the flags should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +# +# Test that +# - more than 32 flags can be searched for +# - no more can be used +# +sub test_search_allflags +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add messages and flags"; + + my %msg; + for (my $i = 1 ; $i <= MAX_USER_FLAGS ; $i++) + { + my $flag = "flag$i"; + $msg{$i} = $self->make_message("Message $i"); + xlog $self, "Set $flag on message $i"; + my $res = $talk->store($i, '+flags', "($flag)"); + $self->assert_deep_equals({ "$i" => { 'flags' => [ + '\\Recent', $flag + ]}}, $res); + } + + # for debugging + $talk->fetch('1:*', '(uid flags)'); + + for (my $i = 1 ; $i <= MAX_USER_FLAGS ; $i++) { + xlog $self, "Can search for flag $i"; + my $uids = $talk->search("keyword", "flag$i"); + $self->assert_equals(1, scalar(@$uids)); + $self->assert_equals($i, $uids->[0]); + } +} + +# +# Test that +# - multiple flags can be set together +# - flags can be set and cleared without affecting other flags +# - other messages aren't affected by those changes +# - flags are persistent across sessions +# +sub test_multi_flags +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set many flags on message A"; + my $res = $talk->store('1', '+flags', + '(\\Answered \\Flagged \\Draft \\Deleted \\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ + qw(\\Answered \\Flagged \\Draft + \\Deleted \\Seen) + ]}}, $res); + $msg{A}->set_attribute(flags => [qw(\\Answered \\Flagged \\Draft \\Deleted \\Seen)]); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Flagged on message A"; + $res = $talk->store('1', '-flags', '(\\Flagged)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ + qw(\\Answered \\Draft \\Deleted \\Seen) + ]}}, $res); + $msg{A}->set_attribute(flags => [qw(\\Answered \\Draft \\Deleted \\Seen)]); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Draft and \\Deleted on message A"; + $res = $talk->store('1', '-flags', '(\\Draft \\Deleted)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ + qw(\\Answered \\Seen) + ]}}, $res); + $msg{A}->set_attribute(flags => [qw(\\Answered \\Seen)]); + $self->check_messages(\%msg); + + xlog $self, "Set \\Draft and \\Flagged on message A"; + $res = $talk->store('1', '+flags', '(\\Draft \\Flagged)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ + qw(\\Answered \\Flagged \\Draft \\Seen) + ]}}, $res); + $msg{A}->set_attribute(flags => [qw(\\Answered \\Flagged \\Draft \\Seen)]); + $self->check_messages(\%msg); + + xlog $self, "Set to just \\Answered and \\Seen on message A"; + $res = $talk->store('1', 'flags', '(\\Answered \\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ + qw(\\Answered \\Seen) + ]}}, $res); + $msg{A}->set_attribute(flags => [qw(\\Answered \\Seen)]); + $self->check_messages(\%msg); + + xlog $self, "Walk through every combination of flags"; + my %rev_map = ( + 1 => '\\Answered', + 2 => '\\Flagged', + 4 => '\\Draft', + 8 => '\\Deleted', + 16 => '\\Seen' ); + my $max = (2 ** scalar keys %rev_map) - 1; + for (my $i = 0 ; $i <= $max ; $i++) + { + my @flags; + for (my $m = 1 ; defined($rev_map{$m}) ; $m *= 2) + { + push(@flags, $rev_map{$m}) if ($i & $m); + } + xlog $self, "Setting " . join(',',@flags) . " on message A"; + my $res = $talk->store('1', 'flags', '(' . join(' ',@flags) . ')'); + $self->assert_deep_equals({ '1' => { 'flags' => \@flags }}, $res); + $msg{A}->set_attribute(flags => \@flags); + $self->check_messages(\%msg); + } + + xlog $self, "Reconnect, all the flags should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +# Quoth RFC 4314: +# STORE operation SHOULD NOT fail if the user has rights to modify +# at least one flag specified in the STORE, as the tagged NO +# response to a STORE command is not handled very well by deployed +# clients +sub test_multi_flags_acl + :min_version_3_5 :NoAltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + my %acls = ( + '\\Seen' => 's', + '\\Deleted' => 't', + '\\Flagged' => 'w', + ); + my @flags = sort keys %acls; + + my $firsttime = 1; + while (my ($flag, $acl_bit) = each %acls) { + xlog $self, "testing flag $flag"; + # reset to no flags set + $admintalk->setacl("user.cassandane", "cassandane", "lrstw") or die; + $talk->unselect(); + $talk->select('INBOX'); + my $res = $talk->store('1', 'flags', '()'); + if ($firsttime) { + $self->assert_deep_equals({}, $res); + $firsttime = 0; + } + else { + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + } + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + # limit user access + $admintalk->setacl("user.cassandane", "cassandane", "lr$acl_bit") + or die; + $talk->unselect(); + $talk->select('INBOX'); + + # set a bunch of flags + $res = $talk->store('1', '+flags', "(@flags)"); + + # it should work, but only the allowed flag should have been set + $self->assert_deep_equals({ '1' => { 'flags' => [ $flag ] }}, $res); + $self->assert_equals('ok', $talk->get_last_completion_response()); + $msg{A}->set_attribute(flags => [$flag]); + $self->check_messages(\%msg); + + # reset to all flags set + $admintalk->setacl("user.cassandane", "cassandane", "lrstw") or die; + $talk->unselect(); + $talk->select('INBOX'); + $res = $talk->store('1', 'flags', "(@flags)"); + $self->assert_not_null($res); + $self->assert_deep_equals([@flags], [sort @{$res->{1}->{flags}}]); + $msg{A}->set_attribute(flags => [@flags]); + $self->check_messages(\%msg); + + # limit user access + $admintalk->setacl("user.cassandane", "cassandane", "lr$acl_bit") + or die; + $talk->unselect(); + $talk->select('INBOX'); + + # remove a bunch of flags + $res = $talk->store('1', '-flags', "(@flags)"); + + # it should work, but only the allowed flag should have been changed + $self->assert_not_null($res); + $self->assert_deep_equals([ grep { $_ ne $flag } @flags ], + [ sort @{$res->{1}->{flags}} ]); + $self->assert_equals('ok', $talk->get_last_completion_response()); + $msg{A}->set_attribute(flags => [ grep { $_ ne $flag } @flags ]); + $self->check_messages(\%msg); + + # explicit set with any of them missing permission should fail + $res = $talk->store('1', 'flags', "(@flags)"); + + # nothing should have changed + $self->assert_null($res); + $self->assert_equals('no', $talk->get_last_completion_response()); + $self->check_messages(\%msg); + + # no flags we're allowed to change + $res = $talk->store('1', '+flags', + '(' . join(' ', grep { $_ ne $flag } @flags) . ')'); + + # nothing should have changed + $self->assert_null($res); + $self->assert_equals('no', $talk->get_last_completion_response()); + $self->check_messages(\%msg); + + # no flags we're allowed to change + $res = $talk->store('1', '-flags', + '(' . join(' ', grep { $_ ne $flag } @flags) . ')'); + + # nothing should have changed + $self->assert_null($res); + $self->assert_equals('no', $talk->get_last_completion_response()); + $self->check_messages(\%msg); + } +} + +sub test_explicit_store_acl + :NoAltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + # add a message + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $self->check_messages(\%msg); + + # set some flags on it + my $res = $talk->store('1', '+flags', '(\\Deleted \\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ qw(\\Deleted \\Seen)] }}, + $res); + $msg{A}->set_attribute(flags => [ '\\Deleted', '\\Seen' ]); + $self->check_messages(\%msg); + + # remove 't' right from user + my %acl = @{ $admintalk->getacl('user.cassandane') }; + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + xlog "acl: " . Dumper \%acl; + $self->assert_not_null($acl{'cassandane'}); + $acl{'cassandane'} =~ s/t//g; + $admintalk->setacl("user.cassandane", "cassandane", $acl{'cassandane'}); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + + # try to set flags to a new set not containing \Deleted or \Seen. + # \Seen should be removed, but \Deleted must not be + $talk->unselect(); + $talk->select('INBOX'); + $res = $talk->store('1', 'flags', '(\\Flagged)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ + qw(\\Flagged \\Deleted) + ]}}, $res); + $msg{A}->set_attribute(flags => [ '\\Flagged', '\\Deleted' ]); + $self->check_messages(\%msg); +} + +# Get the modseq of a given returned message +sub get_modseq +{ + my ($actual, $which) = @_; + + my $msl = $actual->{'Message ' . $which}->get_attribute('modseq'); + return undef unless defined $msl; + return undef unless ref $msl eq 'ARRAY'; + return undef unless scalar @$msl == 1; + return 0 + $msl->[0]; +} + +# Get the modseq from a FETCH response +sub get_modseq_from_fetch +{ + my ($fetched, $i) = @_; + + my $msl = $fetched->{$i}->{modseq}; + return undef unless defined $msl; + return undef unless ref $msl eq 'ARRAY'; + return undef unless scalar @$msl == 1; + return 0 + $msl->[0]; +} + +# Get the highestmodseq of the folder +sub get_highestmodseq +{ + my ($self) = @_; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $stat = $talk->status($store->{folder}, '(highestmodseq)'); + return undef unless defined $stat; + return undef unless ref $stat eq 'HASH'; + return undef unless defined $stat->{highestmodseq}; + return 0 + $stat->{highestmodseq}; +} + +# +# Test interaction between RFC4551 modseq and STORE FLAGS +# - setting a flag bumps the message's modseq +# and the folder's highestmodseq +# - clearing a flag bumps the message's modseq etc +# - setting an already-set flag does not bump modseq +# (actually this isn't explicitly stated in RFC4551) +# - clearing an already-clear flag does not bump modseq +# (actually this isn't explicitly stated in RFC4551) +# - modseq of other messages is never affected +# +# TODO: test that changing a flag results in an untagged +# FETCH response. +# TODO: test the .SILENT suffix +# +sub test_modseq +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags modseq)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + my $act0 = $self->check_messages(\%msg); + my $hms0 = $self->get_highestmodseq(); + + xlog $self, "Set \\Flagged on message A"; + my $res = $talk->store('1', '+flags', '(\\Flagged)'); + $self->assert_not_null($res); + $self->assert_deep_equals([ '\\Flagged' ], $res->{1}->{flags}); + $msg{A}->set_attribute(flags => ['\\Flagged']); + my $act1 = $self->check_messages(\%msg); + my $hms1 = $self->get_highestmodseq(); + xlog $self, "A should have a new modseq higher than any other message"; + $self->assert(get_modseq($act1, 'A') > get_modseq($act0, 'A')); + $self->assert(get_modseq($act1, 'A') > get_modseq($act0, 'B')); + $self->assert(get_modseq($act1, 'B') == get_modseq($act0, 'B')); + $self->assert($hms1 > $hms0); + $self->assert(get_modseq($act1, 'A') == $hms1); + + xlog $self, "Set \\Flagged on message A while already set"; + $res = $talk->store('1', '+flags', '(\\Flagged)'); + $self->assert_deep_equals({}, $res); + $self->assert_equals('ok', $talk->get_last_completion_response()); + $msg{A}->set_attribute(flags => ['\\Flagged']); + my $act2 = $self->check_messages(\%msg); + my $hms2 = $self->get_highestmodseq(); + xlog $self, "A should have not changed modseq"; + $self->assert(get_modseq($act2, 'A') == get_modseq($act1, 'A')); + $self->assert(get_modseq($act2, 'B') == get_modseq($act1, 'B')); + $self->assert($hms2 == $hms1); + $self->assert(get_modseq($act2, 'A') == $hms2); + + xlog $self, "Clear \\Flagged on message A"; + $res = $talk->store('1', '-flags', '(\\Flagged)'); + $self->assert_not_null($res); + $self->assert_deep_equals([], $res->{1}->{flags}); + $msg{A}->set_attribute(flags => []); + my $act3 = $self->check_messages(\%msg); + my $hms3 = $self->get_highestmodseq(); + xlog $self, "A should have a new modseq higher than any other message"; + $self->assert(get_modseq($act3, 'A') > get_modseq($act2, 'A')); + $self->assert(get_modseq($act3, 'A') > get_modseq($act2, 'B')); + $self->assert(get_modseq($act3, 'B') == get_modseq($act2, 'B')); + $self->assert($hms3 > $hms2); + $self->assert(get_modseq($act3, 'A') == $hms3); + + xlog $self, "Clear \\Flagged on message A while already clear"; + $res = $talk->store('1', '-flags', '(\\Flagged)'); + $self->assert_deep_equals({}, $res); + $self->assert_equals('ok', $talk->get_last_completion_response()); + $msg{A}->set_attribute(flags => []); + my $act4 = $self->check_messages(\%msg); + my $hms4 = $self->get_highestmodseq(); + xlog $self, "A should have not changed modseq"; + $self->assert(get_modseq($act4, 'A') == get_modseq($act3, 'A')); + $self->assert(get_modseq($act4, 'B') == get_modseq($act3, 'B')); + $self->assert($hms4 == $hms3); + $self->assert(get_modseq($act4, 'A') == $hms4); +} + +# +# Test UNCHANGEDSINCE modifier; RFC4551 section 3.2. +# - changing a flag with current modseq equal to the +# UNCHANGEDSINCE value +# - updates the flag +# - updates modseq +# - sends an untagged FETCH response +# - the FETCH response has the new modseq +# - returns an OK response +# - the UID does not appear in the MODIFIED response code +# - ditto less than +# - changing a flag with current modseq greater than the +# UNCHANGEDSINCE value +# - doesn't update the flag +# - doesn't update modseq +# - sent no FETCH untagged response +# - returns an OK response +# - but reports the UID in the MODIFIED response code +# +sub test_unchangedsince +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags modseq)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + my $act0 = $self->check_messages(\%msg); + + my %fetched; + my $modified; + my %handlers = + ( + fetch => sub + { + my ($response, $rr, $id) = @_; + + # older versions of Mail::IMAPTalk don't have + # the 3rd argument. We can't test properly in + # those circumstances. + $self->assert_not_null($id); + + $fetched{$id} = $rr; + }, + modified => sub + { + my ($response, $rr) = @_; + # we should not get more than one of these ever + $self->assert_null($modified); + $modified = $rr; + } + ); + + # Note: Mail::IMAPTalk::store() doesn't support modifiers + # so we have to resort to the lower level interface. + + xlog $self, "Changing a flag with current modseq == UNCHANGEDSINCE"; + %fetched = (); + $modified = undef; + $talk->_imap_cmd('store', 1, \%handlers, + '1', ['unchangedsince', get_modseq($act0, 'A')], + '+flags', ['\\Flagged']); + my $res1 = $talk->get_last_completion_response(); + # - updates the flag + $msg{A}->set_attribute(flags => ['\\Flagged']); + my $act1 = $self->check_messages(\%msg); + xlog $self, "returns an OK response?"; + $self->assert_str_equals('ok', $res1); + xlog $self, "updated modseq?"; + $self->assert(get_modseq($act1, 'A') > get_modseq($act0, 'A')); + xlog $self, "returned no MODIFIED response code?"; + $self->assert_null($modified); + xlog $self, "sent an untagged FETCH response?"; + $self->assert_num_equals(1, scalar keys %fetched); + $self->assert_not_null($fetched{1}); + xlog $self, "the FETCH response has the new modseq?"; + $self->assert_num_equals(get_modseq($act1, 'A'), + get_modseq_from_fetch(\%fetched, 1)); + + xlog $self, "Changing a flag with current modseq < UNCHANGEDSINCE"; + %fetched = (); + $modified = undef; + $talk->_imap_cmd('store', 1, \%handlers, + '1', ['unchangedsince', get_modseq($act1, 'A')+1], + '-flags', ['\\Flagged']); + my $res2 = $talk->get_last_completion_response(); + # - updates the flag + $msg{A}->set_attribute(flags => []); + my $act2 = $self->check_messages(\%msg); + xlog $self, "returns an OK response?"; + $self->assert_str_equals('ok', $res2); + xlog $self, "updated modseq?"; + $self->assert(get_modseq($act2, 'A') > get_modseq($act0, 'A')); + xlog $self, "returned no MODIFIED response code?"; + $self->assert_null($modified); + xlog $self, "sent an untagged FETCH response?"; + $self->assert_num_equals(1, scalar keys %fetched); + $self->assert_not_null($fetched{1}); + xlog $self, "the FETCH response has the new modseq?"; + $self->assert_num_equals(get_modseq($act2, 'A'), + get_modseq_from_fetch(\%fetched, 1)); + + xlog $self, "Changing a flag with current modseq > UNCHANGEDSINCE"; + %fetched = (); + $modified = undef; + $talk->_imap_cmd('store', 1, \%handlers, + '1', ['unchangedsince', get_modseq($act2, 'A')-1], + '+flags', ['\\Flagged']); + my $res3 = $talk->get_last_completion_response(); + # - doesn't update the flag + $msg{A}->set_attribute(flags => []); + my $act3 = $self->check_messages(\%msg); + xlog $self, "returns an OK response?"; + $self->assert_str_equals('ok', $res3); + xlog $self, "didn't update modseq?"; + $self->assert_num_equals(get_modseq($act3, 'A'), get_modseq($act2, 'A')); + xlog $self, "reports the UID in the MODIFIED response code?"; + $self->assert_not_null($modified); + $self->assert_deep_equals($modified, [1]); + xlog $self, "sent no FETCH untagged response?"; + $self->assert_num_equals(0, scalar keys %fetched); +} + +# +# More tests for UNCHANGEDSINCE, RFC4551 section 3.2. +# +# - success/failure is per-message, i.e. the update can +# fail on one message and succeed on another. +# - example 11: STORE UNCHANGEDSINCE +FLAGS \Seen on a set +# of messages where some are expunged and some have been +# modified since: response is NO because of the expunged +# messages, with a MODIFIED response code. +# +# +# +# TODO: Once the client specified the UNCHANGEDSINCE modifier in a STORE +# command, the server MUST include the MODSEQ fetch response data items +# in all subsequent unsolicited FETCH responses. Once the client +# specified the UNCHANGEDSINCE modifier in a STORE command, the server +# MUST include the MODSEQ fetch response data items in all subsequent +# unsolicited FETCH responses. +# +# TODO the untagged FETCH response is returned even when +# .SILENT is used +# +sub test_unchangedsince_multi +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags modseq)); + + xlog $self, "Add some messages"; + my %msg; + for (my $i = 1 ; $i <= 26 ; $i++) + { + my $letter = chr(64 + $i); # A ... Z + $msg{$letter} = $self->make_message('Message ' . $letter); + $msg{$letter}->set_attributes(id => $i, + uid => $i, + flags => []); + } + + xlog $self, "Bump the modseq on M,N,O"; + my $res = $talk->store('13,14,15', '+flags', '(\\Draft)'); + $self->assert_deep_equals({ + '13' => { 'flags' => [ '\\Draft' ] }, + '14' => { 'flags' => [ '\\Draft' ] }, + '15' => { 'flags' => [ '\\Draft' ] }, + }, $res); + $msg{M}->set_attribute(flags => ['\\Draft']); + $msg{N}->set_attribute(flags => ['\\Draft']); + $msg{O}->set_attribute(flags => ['\\Draft']); + + my $act0 = $self->check_messages(\%msg); + + { + my $store2 = $self->{instance}->get_service('imap')->create_store(); + $store2->connect(); + $store2->_select(); + my $talk2 = $store2->get_client(); + xlog $self, "Delete and expunge D,E,F from another session"; + for (my $i = 4 ; $i <= 6 ; $i++) + { + my $letter = chr(64 + $i); # D, E, F + my $res = $talk2->store($i, '+flags', '(\\Deleted)'); + $self->assert_deep_equals({ + "$i" => { 'flags' => [ '\\Deleted' ] } + }, $res); + delete $msg{$letter}; + } + $talk2->expunge(); + $store2->disconnect(); + } + + + my %fetched; + my $modified; + my %handlers = + ( + fetch => sub + { + my ($response, $rr, $id) = @_; + + # older versions of Mail::IMAPTalk don't have + # the 3rd argument. We can't test properly in + # those circumstances. + $self->assert_not_null($id); + + $fetched{$id} = $rr; + }, + modified => sub + { + my ($response, $rr) = @_; + # we should not get more than one of these ever + $self->assert_null($modified); + $modified = $rr; + } + ); + + # Note: Mail::IMAPTalk::store() doesn't support modifiers + # so we have to resort to the lower level interface. + + xlog $self, "Changing a flag on multiple messages"; + %fetched = (); + $modified = undef; + $talk->_imap_cmd('store', 1, \%handlers, + \'1:*', ['unchangedsince', get_modseq($act0, 'Z')], + '+flags', ['\\Flagged']); + my $res1 = $talk->get_last_completion_response(); + + $msg{A}->set_attribute(flags => ['\\Flagged']); + $msg{B}->set_attribute(flags => ['\\Flagged']); + $msg{C}->set_attribute(flags => ['\\Flagged']); + # D,E,F deleted + $msg{G}->set_attribute(flags => ['\\Flagged']); + $msg{H}->set_attribute(flags => ['\\Flagged']); + $msg{I}->set_attribute(flags => ['\\Flagged']); + $msg{J}->set_attribute(flags => ['\\Flagged']); + $msg{K}->set_attribute(flags => ['\\Flagged']); + $msg{L}->set_attribute(flags => ['\\Flagged']); + # M,N,O should fail the conditional store + $msg{M}->set_attribute(flags => ['\\Draft']); + $msg{N}->set_attribute(flags => ['\\Draft']); + $msg{O}->set_attribute(flags => ['\\Draft']); + $msg{P}->set_attribute(flags => ['\\Flagged']); + $msg{Q}->set_attribute(flags => ['\\Flagged']); + $msg{R}->set_attribute(flags => ['\\Flagged']); + $msg{S}->set_attribute(flags => ['\\Flagged']); + $msg{T}->set_attribute(flags => ['\\Flagged']); + $msg{U}->set_attribute(flags => ['\\Flagged']); + $msg{V}->set_attribute(flags => ['\\Flagged']); + $msg{W}->set_attribute(flags => ['\\Flagged']); + $msg{X}->set_attribute(flags => ['\\Flagged']); + $msg{Y}->set_attribute(flags => ['\\Flagged']); + $msg{Z}->set_attribute(flags => ['\\Flagged']); + # We start a new session in check_messages, so we + # have to renumber here to account for deletion + for (my $i = 7 ; $i <= 26 ; $i++) + { + my $letter = chr(64 + $i); # G ... Z + $msg{$letter}->set_attribute(id => $i-3); + } + my $act1 = $self->check_messages(\%msg); + +# TODO: this fails with current Cyrus code +# xlog $self, "returns a NO response?"; +# $self->assert_str_equals('NO', $res1); + + xlog $self, "updated modseq?"; + $self->assert(get_modseq($act1, 'A') > get_modseq($act0, 'A')); + $self->assert(get_modseq($act1, 'B') > get_modseq($act0, 'B')); + $self->assert(get_modseq($act1, 'C') > get_modseq($act0, 'C')); + # D,E,F deleted + $self->assert(get_modseq($act1, 'G') > get_modseq($act0, 'G')); + $self->assert(get_modseq($act1, 'H') > get_modseq($act0, 'H')); + $self->assert(get_modseq($act1, 'I') > get_modseq($act0, 'I')); + $self->assert(get_modseq($act1, 'J') > get_modseq($act0, 'J')); + $self->assert(get_modseq($act1, 'K') > get_modseq($act0, 'K')); + $self->assert(get_modseq($act1, 'L') > get_modseq($act0, 'L')); + # M,N,O have the same modseq + $self->assert(get_modseq($act1, 'M') == get_modseq($act0, 'M')); + $self->assert(get_modseq($act1, 'N') == get_modseq($act0, 'N')); + $self->assert(get_modseq($act1, 'O') == get_modseq($act0, 'O')); + $self->assert(get_modseq($act1, 'P') > get_modseq($act0, 'P')); + $self->assert(get_modseq($act1, 'Q') > get_modseq($act0, 'Q')); + $self->assert(get_modseq($act1, 'R') > get_modseq($act0, 'R')); + $self->assert(get_modseq($act1, 'S') > get_modseq($act0, 'S')); + $self->assert(get_modseq($act1, 'T') > get_modseq($act0, 'T')); + $self->assert(get_modseq($act1, 'U') > get_modseq($act0, 'U')); + $self->assert(get_modseq($act1, 'V') > get_modseq($act0, 'V')); + $self->assert(get_modseq($act1, 'W') > get_modseq($act0, 'W')); + $self->assert(get_modseq($act1, 'X') > get_modseq($act0, 'X')); + $self->assert(get_modseq($act1, 'Y') > get_modseq($act0, 'Y')); + $self->assert(get_modseq($act1, 'Z') > get_modseq($act0, 'Z')); + + xlog $self, "returned MODIFIED response code?"; + $self->assert_not_null($modified); + $self->assert_deep_equals($modified, ['13:15']); + + xlog $self, "sent untagged FETCH responses with the new modseq?"; + # also tells about the 3 messages which were deleted since + # the last command + $self->assert_num_equals(23, scalar keys %fetched); + foreach my $i (1..3, 7..12, 16..26) + { + my $letter = chr(64 + $i); + $self->assert_not_null($fetched{$i}); + $self->assert_num_equals(get_modseq($act1, $letter), + get_modseq_from_fetch(\%fetched, $i)); + } + +} + +# check that seen flags are set correctly on body fetch +sub test_setseen +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add three messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $msg{C} = $self->make_message('Message C'); + $msg{C}->set_attributes(id => 3, + uid => 3, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Fetch body of message A"; + my $res = $talk->fetch('1', '(body[])'); + $self->assert_deep_equals([ '\\Seen' ], $res->{1}->{flags}); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Fetch body.peek of message B"; + $res = $talk->fetch('2', '(body.peek[])'); + $self->assert(not exists $res->{2}->{flags}); + $self->check_messages(\%msg); + + xlog $self, "Fetch binary of message C"; + $res = $talk->fetch('3', '(binary[])'); + $self->assert_deep_equals([ '\\Seen' ], $res->{3}->{flags}); + $msg{C}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Reconnect, \\Seen should still be on messages A and C"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +# check that seen flags are set correctly on body fetch +# even if the flag was removed in the same session +sub test_setseen_after_store +{ + my ($self) = @_; + + # need to version-gate IMAP features that aren't in 3.9... + my ($maj, $min) = Cassandane::Instance->get_version(); + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Fetch body of message A"; + $talk->fetch('1', '(body[])'); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Fetch remove the flag again, and immediately fetch the body"; + my $res = $talk->store('1', '-flags.silent', "(\\Seen)"); + if ($maj > 3 || ($maj == 3 && $min >= 9)) { + $self->assert_deep_equals({}, $res); + } + else { + # XXX flags.silent should cause there to not be an untagged response + # XXX unless the affected data was also modified by another user, but + # XXX for some reason Cyrus still returns it here? + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + } + $talk->fetch('1', '(body[])'); + $self->check_messages(\%msg); + + xlog $self, "Reconnect, \\Seen should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); +} + +sub test_setseen_notify + :Conversations :FastMailEvent :min_version_3_0 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Throw away existing notify"; + $self->{instance}->getnotify(); + + xlog $self, "Add a messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $self->check_messages(\%msg); + + my $notify1 = $self->{instance}->getnotify(); + + $msg{A}->set_attribute(flags => ['\\Seen']); + my $res = $talk->store('1', '+flags', '\\Seen'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + + my $notify2 = $self->{instance}->getnotify(); + + my $payload1 = decode_json($notify1->[0]{MESSAGE}); + my $payload2 = decode_json($notify2->[0]{MESSAGE}); + $self->assert($payload2->{modseq} > $payload1->{modseq}, "modseq has increased: $payload2->{modseq} > $payload1->{modseq}"); +} + +sub test_userflags_crash + :Conversations + :LowEmailLimits + :min_version_3_6 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + my $folder = 'foo'; + + # make two messages + my %msg; + $msg{1} = $self->make_message('Message 1'); + $msg{2} = $self->make_message('Message 2'); + $self->check_messages(\%msg); + + # make a second mailbox + $talk->create($folder) or die; + + # set a bunch of flags on first message + $talk->store('1', '+flags', qw(a b c d e f g h i j k l)); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + # set some different flags on second message + $talk->store('2', '+flags', qw(what the heck)); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + # duplicate messages into other folder until duplicate limit reached + my $limit = 100; # just in case + do { + $talk->copy('1:*', $folder); + } while (--$limit && 'ok' eq $talk->get_last_completion_response()); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + + # should have got a syslog message about conversations GUID limit + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: conversations GUID limit/); + + # change some flags on the first message + $talk->store('1', '-flags', qw(a b c d)); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $talk->store('1', '+flags', qw(this is new stuff)); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + # duplicate messages again + $talk->copy('1:*', $folder); + # --- better not have crashed here! --- + $self->assert_str_equals('no', $talk->get_last_completion_response()); + + # should have got a syslog message about conversations GUID limit + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: conversations GUID limit/); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/HTTPPTS.pm b/cassandane/Cassandane/Cyrus/HTTPPTS.pm new file mode 100644 index 0000000000..c97f294f42 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/HTTPPTS.pm @@ -0,0 +1,379 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2022 Fastmail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::HTTPPTS; +use strict; +use warnings; +use Cwd qw(realpath); +use JSON; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set( + auth_mech => 'pts', + pts_module => 'http', + ptloader_sock => '@basedir@/conf/ptsock', + ); + + my $self = $class->SUPER::new({ + config => $config, + adminstore => 1, + services => [qw( imap ptloader )], + start_instances => 0, + }, @args); + + return $self; +} + +sub set_up +{ + my ($self) = @_; + + $self->SUPER::set_up(); + + $self->{server} = $self->new_test_url(sub { + my $env = shift; + my $req = Plack::Request->new($env); + + my $res; + + if ($req->method eq 'GET') { + if ($req->query_parameters->{id} eq 'cassandane') { + $res = Plack::Response->new(200); + $res->content_type('application/json'); + $res->body(encode_json({ cassandane => [ "group:group co", + "group:group c" ] })); + } elsif ($req->query_parameters->{id} eq 'otheruser') { + $res = Plack::Response->new(200); + $res->content_type('application/json'); + $res->body(encode_json({ otheruser => [ "group:group co", + "group:group o" ] })); + } elsif ($req->query_parameters->{id} eq 'group:group c') { + $res = Plack::Response->new(200); + $res->content_type('application/json'); + $res->body(encode_json({ 'group:group c' => [ "cassandane" ] })); + } elsif ($req->query_parameters->{id} eq 'group:group co') { + $res = Plack::Response->new(200); + $res->content_type('application/json'); + $res->body(encode_json({ 'group:group co' => [ "cassandane", + "otheruser" ] })); + } elsif ($req->query_parameters->{id} eq 'group:group o') { + $res = Plack::Response->new(200); + $res->content_type('application/json'); + $res->body(encode_json({ 'group:group o' => [ "otheruser" ] })); + } elsif ($req->query_parameters->{id} eq 'group:foo') { + $res = Plack::Response->new(200); + $res->content_type('application/json'); + $res->body(encode_json({ 'group:foo' => [ ] })); + } elsif ($req->query_parameters->{id} eq 'group:this group name has spaces') { + $res = Plack::Response->new(200); + $res->content_type('application/json'); + $res->body(encode_json({ 'group:this group name has spaces' => [ ] })); + } else { + $res = Plack::Response->new(404); + } + } + elsif ($req->method eq 'OPTIONS') { + $res = Plack::Response->new(200); + } + else { + $res = Plack::Response->new(501); + } + + return $res->finalize; + }); + + my $uri = $self->{server}->url . "?id={groupId}"; + + $self->{instance}->{config}->set( + httppts_uri => $uri + ); + + $self->_start_instances(); + + $self->{instance}->create_user("otheruser"); +} + +sub tear_down +{ + my ($self) = @_; + + # clean this up as soon as we're done with it, cause it's holding a + # port open! + delete $self->{server}; + + $self->SUPER::tear_down(); +} + +sub test_alternate_ptscache_db_path + :min_version_3_7 :AltPTSDBPath +{ + my ($self) = @_; + + # just interact with the store, and it should work + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->list('user.cassandane', '*'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $confdir = $self->{instance}->{basedir} . "/conf"; + $self->assert_file_test($confdir . "/non-default-ptscache.db"); + $self->assert_not_file_test($confdir . "/ptclient/ptscache.db"); +} + +sub test_setacl_groupid + :min_version_3_7 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.cassandane.groupid"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.groupid", + "group:foo", + "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); +} + +sub test_setacl_groupid_spaces + :min_version_3_7 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.groupid_spaces", + "group:this group name has spaces", + "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $data = $admintalk->getacl("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $self->assert(scalar @{$data} % 2 == 0); + my %acl = @{$data}; + $self->assert_str_equals($acl{"group:this group name has spaces"}, + "lrswipkxtecdan"); + + $admintalk->select("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); +} + +sub test_list_groupaccess_noracl + :min_version_3_7 :NoAltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $admintalk->create("user.otheruser.groupaccess"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.otheruser.groupaccess", + "group:group co", "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $list = $imaptalk->list("", "*"); + my @boxes = sort map { $_->[2] } @{$list}; + + $self->assert_deep_equals(\@boxes, + ['INBOX', 'user.otheruser.groupaccess']); +} + +sub test_list_groupaccess_racl + :ReverseACLs :min_version_3_7 :NoAltNamespace :Conversations +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $admintalk->create("user.otheruser.groupaccess"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $precounters = $self->{store}->get_counters(); + + $admintalk->setacl("user.otheruser.groupaccess", + "group:group co", "lrswipkxtecdn"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $postcounters = $self->{store}->get_counters(); + $self->assert_num_not_equals($precounters->{raclmodseq}, $postcounters->{raclmodseq}, "RACL modseq changed"); + + if (get_verbose()) { + $self->{instance}->run_command( + { cyrus => 1, }, + 'cyr_dbtool', + "$self->{instance}->{basedir}/conf/mailboxes.db", + 'twoskip', + 'show' + ); + } + + my $list = $imaptalk->list("", "*"); + my @boxes = sort map { $_->[2] } @{$list}; + + $self->assert_deep_equals(\@boxes, + ['INBOX', 'user.otheruser.groupaccess']); +} + +sub do_test_list_order + :min_version_3_7 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.zzz"); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); + + $imaptalk->create("INBOX.aaa"); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); + + my %adminfolders = ( + 'user.otheruser.order-user' => 'cassandane', + 'user.otheruser.order-co' => 'group:group co', + 'user.otheruser.order-c' => 'group:group c', + 'user.otheruser.order-o' => 'group:group o', + 'shared.order-co' => 'group:group co', + 'shared.order-c' => 'group:group c', + 'shared.order-o' => 'group:group o', + ); + + while (my ($folder, $identifier) = each %adminfolders) { + $admintalk->create($folder); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "created folder $folder successfully"); + + $admintalk->setacl($folder, $identifier, "lrswipkxtecdn"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "setacl folder $folder for $identifier successfully"); + + if ($folder =~ m/^shared/) { + # subvert default permissions on shared namespace for + # purpose of testing ordering + $admintalk->setacl($folder, "anyone", "p"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "setacl folder $folder for anyone successfully"); + } + } + + if (get_verbose()) { + $self->{instance}->run_command( + { cyrus => 1, }, + 'cyr_dbtool', + "$self->{instance}->{basedir}/conf/mailboxes.db", + 'twoskip', + 'show' + ); + } + + my $list = $imaptalk->list("", "*"); + my @boxes = map { $_->[2] } @{$list}; + + # Note: order is + # * mine, alphabetically, + # * other users', alphabetically, + # * shared, alphabetically + # ... which is not the order we created them ;) + # Also, the "order-o" folders are not returned, because cassandane + # is not a member of that group + my @expect = qw( + INBOX + INBOX.aaa + INBOX.zzz + user.otheruser.order-c + user.otheruser.order-co + user.otheruser.order-user + ); + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min > 4)) { + push @expect, qw(shared); + } + push @expect, qw( shared.order-c shared.order-co ); + $self->assert_deep_equals(\@boxes, \@expect); +} + +sub test_list_order_noracl + :min_version_3_7 :NoAltNamespace +{ + my $self = shift; + return $self->do_test_list_order(@_); +} + +sub test_list_order_racl + :ReverseACLs :min_version_3_7 :NoAltNamespace +{ + my $self = shift; + return $self->do_test_list_order(@_); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/ID.pm b/cassandane/Cassandane/Cyrus/ID.pm new file mode 100644 index 0000000000..b2185c3650 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/ID.pm @@ -0,0 +1,99 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::ID; +use strict; +use warnings; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_cmd_id +{ + my ($self) = @_; + + # Purge any syslog lines before this test runs. + $self->{instance}->getsyslog(); + + my $imaptalk = $self->{store}->get_client(); + + return if not $imaptalk->capability()->{id}; + + my $res = $imaptalk->id(name => "cassandane"); + xlog $self, Dumper $res; + + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # should have logged some timer output, which should include the sess id, + # and since we sent a client id via IMAP ID, we should get that, too! + if ($self->{instance}->{have_syslog_replacement}) { + # make sure that the connection is ended so that imapd reset happens + $imaptalk->logout(); + undef $imaptalk; + + my @behavior_lines = $self->{instance}->getsyslog(qr/session ended/); + + $self->assert_num_gte(1, scalar @behavior_lines); + + $self->assert_matches(qr/\bid\.name=/, $_) for @behavior_lines; + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/IMAP4rev2.pm b/cassandane/Cassandane/Cyrus/IMAP4rev2.pm new file mode 100644 index 0000000000..2051f8ba68 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/IMAP4rev2.pm @@ -0,0 +1,286 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2022 Fastmail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::IMAP4rev2; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::NetString; + + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1, services => ['imap'] }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_basic + :NoAltNameSpace :min_version_3_7 +{ + my ($self) = @_; + + xlog $self, "Make some messages"; + my $uid = 1; + my %msgs; + for (1..20) + { + $msgs{$uid} = $self->make_message("Message $uid"); + $msgs{$uid}->set_attribute('uid', $uid); + $uid++; + } + + my $talk = $self->{store}->get_client(); + $talk->unselect(); + + xlog $self, "Create mailbox with mUTF7 encoded name"; + my $res = $talk->_imap_cmd('CREATE', 0, "", "INBOX.&JgA-"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "ENABLE IMAP4rev2"; + $res = $talk->_imap_cmd('ENABLE', 0, "enabled", "IMAP4rev2"); + $self->assert_num_equals(1, $res->{imap4rev2}); + + xlog $self, "Verify that LIST responses use UTF8 mailbox names"; + $res = $talk->list("", "*"); + $self->assert_mailbox_structure($res, '.', { + 'INBOX' => [qw( \\HasChildren )], + "INBOX.☀" => [qw( \\HasNoChildren )], + }); + + xlog $self, "EXAMINE mailbox with UTF8 mailbox name"; + $res = $talk->_imap_cmd('EXAMINE', 0, "", "INBOX.☀"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that LIST response is returned with UTF8 mailbox name"; + my @list = $talk->get_response_code('list'); + $self->assert_str_equals("INBOX.☀", $list[0][0][2]); + + xlog $self, "Mark some messages \\Deleted"; + $talk->select('INBOX'); + $res = $talk->store('5:9', '+flags', '(\\Deleted)'); + + xlog $self, "Verify that FETCH responses include UID"; + $self->assert_str_equals("5", $res->{5}->{uid}); + $self->assert_str_equals("6", $res->{6}->{uid}); + $self->assert_str_equals("7", $res->{7}->{uid}); + $self->assert_str_equals("8", $res->{8}->{uid}); + $self->assert_str_equals("9", $res->{9}->{uid}); + + xlog $self, "Check STATUS (DELETED)"; + $res = $talk->status('INBOX', [ 'deleted' ]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals("5", $res->{deleted}); + + xlog $self, "SEARCH DELETED"; + my @results = (); + my %handlers = + ( + esearch => sub + { + my (undef, $esearch) = @_; + push(@results, $esearch); + }, + ); + $res = $talk->_imap_cmd('SEARCH', 0, \%handlers, 'DELETED'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that ESEARCH response is returned"; + $self->assert_num_equals(1, scalar @results); + $self->assert_str_equals('5:9', $results[0][2]); + + xlog $self, "COPY a deleted message to mailbox with UTF8 name"; + $res = $talk->_imap_cmd('COPY', 0, "", '5', "INBOX.☀"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "ESEARCH IN (PERSONAL) DELETED"; + @results = (); + $res = $talk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(PERSONAL)', 'DELETED'); + + xlog $self, "Verify that ESEARCH response uses UTF8 mailbox name"; + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_num_equals(2, scalar @results); + $self->assert_str_equals("INBOX", $results[0][0][3]); + $self->assert_str_equals('5:9', $results[0][3]); + $self->assert_str_equals("INBOX.☀", $results[1][0][3]); + $self->assert_str_equals('1', $results[1][3]); +} + +sub test_oldname + :NoAltNameSpace :min_version_3_7 +{ + my ($self) = @_; + + xlog $self, "Make some messages"; + my $uid = 1; + my %msgs; + for (1..20) + { + $msgs{$uid} = $self->make_message("Message $uid"); + $msgs{$uid}->set_attribute('uid', $uid); + $uid++; + } + + my $talk = $self->{store}->get_client(); + $talk->unselect(); + + xlog $self, "ENABLE IMAP4rev2"; + my $res = $talk->_imap_cmd('ENABLE', 0, "enabled", "IMAP4rev2"); + $self->assert_num_equals(1, $res->{imap4rev2}); + + my @results = (); + my %handlers = + ( + list => sub + { + my (undef, $list) = @_; + push(@results, $list); + }, + ); + + xlog $self, "Create a mailbox with denormalized mailbox name"; + $res = $talk->_imap_cmd('CREATE', 0, \%handlers, "INBOX.Å"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that LIST response is returned with OLDNAME"; + $self->assert_str_equals("INBOX.Å", $results[0][2]); + $self->assert_str_equals('OLDNAME', $results[0][3][0]); + $self->assert_str_equals('INBOX.Å', $results[0][3][1][0]); + + xlog $self, "Create a child mailbox with normalized mailbox name"; + @results = (); + $res = $talk->_imap_cmd('CREATE', 0, \%handlers, "INBOX.Å.B"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that there is no LIST response"; + $self->assert_null($results[0]); + + xlog $self, "Append to mailbox with denormalized mailbox name"; + my $MsgTxt = <_imap_cmd('APPEND', 0, \%handlers, "INBOX.Å", { Literal => $MsgTxt }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that LIST response is returned with OLDNAME"; + $self->assert_str_equals("INBOX.Å", $results[0][2]); + $self->assert_str_equals('OLDNAME', $results[0][3][0]); + $self->assert_str_equals('INBOX.Å', $results[0][3][1][0]); + + xlog $self, "EXAMINE mailbox with denormalized mailbox name"; + @results = (); + $res = $talk->_imap_cmd('EXAMINE', 0, \%handlers, "INBOX.Å"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that LIST response is returned with OLDNAME"; + $self->assert_str_equals("INBOX.Å", $results[0][2]); + $self->assert_str_equals('OLDNAME', $results[0][3][0]); + $self->assert_str_equals('INBOX.Å', $results[0][3][1][0]); + + $talk->unselect(); + + xlog $self, "RENAME mailbox with denormalized mailbox names"; + @results = (); + $res = $talk->_imap_cmd('RENAME', 0, \%handlers, "INBOX.Å", "INBOX.Ω"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that LIST responses are returned with OLDNAMEs"; + $self->assert_str_equals("\\NonExistent", $results[0][0][0]); + $self->assert_str_equals("INBOX.Å", $results[0][2]); + $self->assert_str_equals('OLDNAME', $results[0][3][0]); + $self->assert_str_equals('INBOX.Å', $results[0][3][1][0]); + $self->assert_str_equals("\\HasChildren", $results[1][0][0]); + $self->assert_str_equals("INBOX.Ω", $results[1][2]); + $self->assert_str_equals('OLDNAME', $results[1][3][0]); + $self->assert_str_equals('INBOX.Ω', $results[1][3][1][0]); + + xlog $self, "LIST renamed mailbox"; + @results = (); + $res = $talk->_imap_cmd('LIST', 0, \%handlers, "", "INBOX.Ω"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that OLDNAME appears in LIST response"; + $self->assert_str_equals('OLDNAME', $results[0][3][0]); + $self->assert_str_equals('INBOX.Å', $results[0][3][1][0]); + + xlog $self, "DELETE a child mailbox with normalized mailbox name"; + @results = (); + $res = $talk->_imap_cmd('DELETE', 0, \%handlers, "INBOX.Ω.B"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that there is no LIST response"; + $self->assert_null($results[0]); + + xlog $self, "DELETE mailbox with denormalized mailbox name"; + @results = (); + $res = $talk->_imap_cmd('DELETE', 0, \%handlers, "INBOX.Ω"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that LIST response is returned with OLDNAME"; + $self->assert_str_equals("\\NonExistent", $results[0][0][0]); + $self->assert_str_equals("INBOX.Ω", $results[0][2]); + $self->assert_str_equals('OLDNAME', $results[0][3][0]); + $self->assert_str_equals('INBOX.Ω', $results[0][3][1][0]); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/IMAPLimits.pm b/cassandane/Cassandane/Cyrus/IMAPLimits.pm new file mode 100644 index 0000000000..563be8abdd --- /dev/null +++ b/cassandane/Cassandane/Cyrus/IMAPLimits.pm @@ -0,0 +1,571 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2023 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::IMAPLimits; +use strict; +use warnings; +use Mail::JMAPTalk 0.13; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +my $email = < + +Body +EOF + +$email =~ s/\r?\n/\r\n/gs; + +my $toobig_email = $email . "X" x 100; + +# Check that we got an untagged BYE [TOOBIG] response +sub assert_bye_toobig +{ + my ($self, $store) = @_; + + $store = $self->{store} if (!defined $store); + + # We want to override Mail::IMAPTalk's builtin handling of the BYE + # untagged response, as it will 'die' immediately without parsing + # the remainder of the line and especially without picking out the + # [TOOBIG] response code that we want to see. + my $got_toobig = 0; + my $handlers = + { + bye => sub + { + my (undef, $resp) = @_; + $got_toobig = 1 if (uc($resp->[0]) eq '[TOOBIG]'); + } + }; + + $store->idle_response($handlers, 1); + $self->assert_num_equals(1, $got_toobig); +} + +# Send a command and expect an untagged BYE [TOOBIG] response +sub assert_cmd_bye_toobig +{ + my $self = shift; + my $cmd = shift; + + my $talk = $self->{store}->get_client(); + $talk->enable('qresync'); # IMAPTalk requires lower-case + $talk->select('INBOX'); + + $talk->_send_cmd($cmd, @_); + $self->assert_bye_toobig(); +} + +# Check that we got a tagged NO [TOOBIG] response +sub assert_no_toobig +{ + my ($self, $talk) = @_; + + my $got_toobig = 0; + my $handlers = + { + 'no' => sub + { + my (undef, $resp) = @_; + $got_toobig = 1 if (uc($resp->[0]) eq '[TOOBIG]'); + } + }; + + eval { + $talk->_parse_response($handlers); + }; + + $self->assert_num_equals(1, $got_toobig); +} + +# Send a command and expect a tagged NO [TOOBIG] response +sub assert_cmd_no_toobig +{ + my $self = shift; + my $talk = shift; + my $cmd = shift; + + $talk->_send_cmd($cmd, @_); + $self->assert_no_toobig($talk); +} + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(maxword => 25); + $config->set(maxquoted => 25); + $config->set(maxliteral => 25); + $config->set(literalminus => 1); + $config->set(maxargssize => 45); + $config->set(maxmessagesize => 100); + $config->set(event_groups => "message mailbox applepushservice"); + $config->set(aps_topic => "mail"); + + return $class->SUPER::new({ + adminstore => 1, + config => $config, + services => ['imap'], + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_maxword +{ + my ($self) = @_; + + # Oversized command name + $self->assert_cmd_bye_toobig("X" x 26); +} + +sub test_maxword_astring +{ + my ($self) = @_; + + # Oversized mailbox name + $self->assert_cmd_bye_toobig('SELECT', "X" x 26); +} + +sub test_maxquoted +{ + my ($self) = @_; + + # Oversized mailbox name + $self->assert_cmd_bye_toobig('SELECT', { Quote => "X" x 26 }); +} + +sub test_maxliteral_nosync +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + # Do this by brute force until we have IMAPTalk v4.06+ + $talk->_imap_socket_out($talk->{CmdId}++ . " SELECT {26+}\015\012"); + $self->assert_bye_toobig(); +} + +sub test_maxliteral_sync +{ + my ($self) = @_; + + # Unlike oversized non-sync literals which fatal() in one central location, + # oversized sync literals fail with a NO response in multiple places, + # so we test as many of those places as possible. + # Having said that, arguments parsed in cmdloop() or in get_search_criterion() + # are mostly handled centrally. + + # Authenticated State + + # Synchronizing literals are the default in IMAPTalk v4.05 (and earlier) + my $talk = $self->{store}->get_client(NoLiteralPlus => 1); + + $self->assert_cmd_no_toobig($talk, 'SELECT', + { Literal => "X" x 26 }); + + $self->assert_cmd_no_toobig($talk, 'ID', + [ { Literal => "X" x 26 } ]); + + $self->assert_cmd_no_toobig($talk, 'ID', + [ { Quote => 'foo' }, { Literal => "X" x 26 } ] ); + + $self->assert_cmd_no_toobig($talk, 'LIST', + { Literal => "X" x 26 }); + + $self->assert_cmd_no_toobig($talk, 'LIST', + { Quote => '' }, { Literal => "X" x 26 }); + + $self->assert_cmd_no_toobig($talk, 'NOTIFY', + 'SET', [ 'MAILBOXES', [ { Literal => "X" x 26 } ] ] ); + + $self->assert_cmd_no_toobig($talk, 'LISTRIGHTS', + 'INBOX', { Literal => "X" x 26 }); + + $self->assert_cmd_no_toobig($talk, 'SETACL', + 'INBOX', 'anyone', { Literal => "X" x 26 }); + + $self->assert_cmd_no_toobig($talk, 'GETMETADATA', + 'INBOX', { Literal => "X" x 26 } ); + + $self->assert_cmd_no_toobig($talk, 'GETMETADATA', + 'INBOX', [ { Literal => "X" x 26 } ] ); + + $self->assert_cmd_no_toobig($talk, 'SETMETADATA', + 'INBOX', [ { Literal => "X" x 26 } ] ); + + $self->assert_cmd_no_toobig($talk, 'SETMETADATA', + 'INBOX', [ '/comment', { Literal => "X" x 26 } ] ); + + $self->assert_cmd_no_toobig($talk, 'XAPPLEPUSHSERVICE', + { Literal => "X" x 26 }); + + $self->assert_cmd_no_toobig($talk, 'XAPPLEPUSHSERVICE', + 'FOO', { Literal => "X" x 26 }); + + # Selected State + $talk->select('INBOX'); + + $self->assert_cmd_no_toobig($talk, 'FETCH', + '1', [ 'ANNOTATION', + [ { Literal => "X" x 26 } ] ] ); + + $self->assert_cmd_no_toobig($talk, 'FETCH', + '1', [ 'BODY[HEADER.FIELDS', + [ { Literal => "X" x 26 } ] ] ); + + $self->assert_cmd_no_toobig($talk, 'FETCH', + '1', [ 'RFC822.HEADER.LINES', + [ { Literal => "X" x 26 } ] ] ); + + $self->assert_cmd_no_toobig($talk, 'STORE', + '1', 'ANNOTATION', [ { Literal => "X" x 26 } ] ); + + $self->assert_cmd_no_toobig($talk, 'STORE', + '1', 'ANNOTATION', + [ { Quote => '/comment' }, + [ { Literal => "X" x 26 } ] ] ); + + $self->assert_cmd_no_toobig($talk, 'STORE', + '1', 'ANNOTATION', + [ { Quote => '/comment' }, + [ { Quote => 'value' }, + { Literal => "X" x 26 } ] ] ); + + $self->assert_cmd_no_toobig($talk, 'SEARCH', + 'HEADER', { Literal => "X" x 26 } ); + + $self->assert_cmd_no_toobig($talk, 'SEARCH', + 'HEADER', 'SUBJECT', { Literal => "X" x 26 } ); + + $self->assert_cmd_no_toobig($talk, 'SEARCH', + 'ANNOTATION', { Literal => "X" x 26 } ); + + $self->assert_cmd_no_toobig($talk, 'SEARCH', + 'ANNOTATION', '/comment', { Literal => "X" x 26 } ); + + $self->assert_cmd_no_toobig($talk, 'SEARCH', + 'ANNOTATION', '/comment', + 'value', { Literal => "X" x 26 } ); + + $self->assert_cmd_no_toobig($talk, 'ESEARCH', + 'IN', [ 'MAILBOXES', { Literal => "X" x 26 } ] ); +} + +sub test_maxargssize_append_flags +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('APPEND', 'INBOX', + [ "X" x 25, "X" x 25 ], { Literal => $email } ); +} + +sub test_maxargssize_append_annot +{ + my ($self) = @_; + + # Use MULTIAPPEND, fail the second + $self->assert_cmd_bye_toobig('APPEND', 'INBOX', + { Literal => $email }, + 'ANNOTATION', + [ "X" x 25, [ 'VALUE', { Quote => "X" x 25 } ] ], + { Literal => $email } ); +} + +sub test_maxargssize_create +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('CREATE', "X" x 25, [ "X" x 25 ] ); +} + +sub test_maxargssize_create_ext +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('CREATE', + "X" x 5, [ "X" x 5, [ "X" x 25, "X" x 25 ] ] ); +} + +sub test_maxargssize_fetch +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('FETCH', '1', + [ 'BODY', 'ENVELOPE', 'FLAGS', + 'INTERNALDATE', 'RFC822.SIZE' ]); +} + +sub test_maxargssize_fetch_annot +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('FETCH', '1', + [ 'ANNOTATION', + [ [ "X" x 25, "X" x 25 ] ], "X" x 5 ] ); +} + +sub test_maxargssize_fetch_annot2 +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('FETCH', '1', + [ 'ANNOTATION', + [ "X" x 5, [ "X" x 25, "X" x 25 ] ] ] ); +} + +sub test_maxargssize_fetch_headers +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('FETCH', '1', + [ 'BODY[HEADER.FIELDS', [ "X" x 25, "X" x 25 ] ] ); +} + +sub test_maxargssize_getmetadata +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('GETMETADATA', 'INBOX', [ "X" x 25, "X" x 25 ] ); +} + +sub test_maxargssize_list_multi +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('LIST', { Quote => '' }, [ "X" x 25, "X" x 25 ]); +} + +sub test_maxargssize_list_select +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('LIST', + [ 'SUBSCRIBED', 'REMOTE', + 'RECURSIVEMATCH', 'SPECIAL-USE' ], + { Quote => '' }, '*'); +} + +sub test_maxargssize_list_return +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('LIST', + { Quote => '' }, '*', 'RETURN', + [ 'SUBSCRIBED', 'CHILDREN', + 'MYRIGHTS', 'SPECIAL-USE' ] ); +} + +sub test_maxargssize_notify_events +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('NOTIFY', 'SET', + [ 'SELECTED', + [ 'MessageNew', 'MessageExpunge', 'FlagChange' ] ] ); +} + +sub test_maxargssize_notify_multi +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('NOTIFY', 'SET', + [ 'PERSONAL', 'NONE' ], + [ 'SELECTED', 'NONE' ], + [ 'SUBSCRIBED', 'NONE' ] ); +} + +sub test_maxargssize_notify_subtree +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('NOTIFY', 'SET', + [ 'SUBTREE', [ "X" x 25, "X" x 25 ] ] ); +} + +sub test_maxargssize_search +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('SEARCH', + 'TEXT', "X" x 25, 'TEXT', { Quote => "X" x 25 } ); +} + +sub test_maxargssize_multisearch +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('ESEARCH', + 'IN', [ 'MAILBOXES', [ "X" x 25, "X" x 25 ] ]); +} + +sub test_maxargssize_select +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('SELECT', 'INBOX', + [ 'QRESYNC', [ '1234567890', '1234567890' ], + 'ANNOTATE' ] ); +} + +sub test_maxargssize_setmetadata +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('SETMETADATA', 'INBOX', + [ "X" x 25, { Quote => "X" x 25 } ] ); +} + +sub test_maxargssize_setmetadata2 +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('SETMETADATA', 'INBOX', + [ '/shared', { Quote => "X" x 25 }, + '/shared', { Quote => "X" x 25 } ] ); +} + +sub test_maxargssize_setquota +{ + my ($self) = @_; + + my $store = $self->{adminstore}; + my $talk = $store->get_client(); + + $talk->_send_cmd('SETQUOTA', 'user.cassandane', + [ 'STORAGE', '1234567890', + 'MESSAGE', '1234567890', + 'MAILBOX', '1234567890' ] ); + $self->assert_bye_toobig($store); +} + +sub test_maxargssize_sort +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('SORT', + [ 'ARRIVAL', 'CC', 'DATE', + 'FROM', 'REVERSE', 'SIZE', 'TO' ], + 'UTF-8', 'ALL'); +} + +sub test_maxargssize_status +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('STATUS', 'INBOX', + [ 'MESSAGES', 'UIDNEXT', + 'UIDVALIDITY', 'UNSEEN', 'SIZE' ] ); +} + +sub test_maxargssize_store_annot +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('STORE', '1', 'ANNOTATION', + [ "X" x 25, [ 'VALUE', { Quote => "X" x 25 } ] ] ); +} + +sub test_maxargssize_store_annot2 +{ + my ($self) = @_; + + $self->assert_cmd_bye_toobig('STORE', '1', 'ANNOTATION', + [ "X" x 5, [ 'VALUE', { Quote => "X" x 25 } ], + "X" x 5, [ 'VALUE', { Quote => "X" x 25 } ] ] ); +} + +sub test_append_zero +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $talk->_imap_cmd('APPEND', 0, '', 'INBOX', { Literal => '' } ); + $self->assert_str_equals('no', $talk->get_last_completion_response()); +} + +sub test_maxmessagesize_sync_literal +{ + my ($self) = @_; + + # Synchronizing literals are the default in IMAPTalk v4.05 (and earlier) + my $talk = $self->{store}->get_client(NoLiteralPlus => 1); + + $self->assert_cmd_no_toobig($talk, 'APPEND', + 'INBOX', { Literal => $toobig_email } ); +} + +sub test_maxmessagesize_nosync_literal +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + # Do this by brute force until we have IMAPTalk v4.06+ + $talk->_imap_socket_out($talk->{CmdId}++ . " APPEND INBOX {101+}\015\012"); + $self->assert_no_toobig($talk); + $self->assert_bye_toobig(); +} + +sub test_literal_minus +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $talk->_imap_socket_out($talk->{CmdId}++ . " APPEND INBOX {4097+}\015\012"); + $self->assert_no_toobig($talk); + $self->assert_bye_toobig(); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Idle.pm b/cassandane/Cassandane/Cyrus/Idle.pm new file mode 100644 index 0000000000..9ce685eb27 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Idle.pm @@ -0,0 +1,610 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Idle; +use strict; +use warnings; +use DateTime; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config->default()->clone(); + $config->set(imapidlepoll => 2); + return $class->SUPER::new({ + config => $config, + deliver => 1, + start_instances => 0, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub start_and_abort_idled +{ + my ($self) = @_; + + # We don't start idled via the START section in cyrus.conf, + # because master would restart it when we kill it, and we + # want to test that the fallback to polling mode works even + # when it's not restarted. + # + # Also note, one of the effects of the -d option is to prevent + # idled forking, which lets us predict which pid to kill. + + my $pid = $self->{instance}->run_command({ + cyrus => 1, + background => 1 + }, 'idled', '-d'); + xlog $self, "pid of idled should be $pid"; + + xlog $self, "giving idled some time to start up"; + my $tries = 60; + my $idle_sock = $self->{instance}->{basedir} . "/conf/socket/idle"; + while ($tries--) { + last if -S $idle_sock; + sleep 1; + } + $self->assert($tries > 0, "idled started successfully"); + + xlog $self, "bring idled's reign to an abrupt and brutal end"; + kill('KILL', $pid) + or die "Failed to kill idled $pid: $!"; + + # reap_command will 'die' because the process terminated + # on SIGKILL. We need to avoid that stopping the test. + # But we still need to waitpid() to avoid zombies. + xlog $self, "reaping pid $pid"; + eval { $self->{instance}->reap_command($pid); }; + + # Now, no idled is running, but any state created by idled in the + # filesystem is still present. In particular, the idle socket. + # Let's check that our assumption is correct. + xlog $self, "check that idle left a socket lying around"; + $self->assert( -S $idle_sock, "$idle_sock exists and is a socket"); +} + +sub test_disabled +{ + my ($self) = @_; + + xlog $self, "Test that the IDLE command can be disabled in"; + xlog $self, "imapd.conf by setting imapidlepoll = 0"; + + xlog $self, "Starting up the instance"; + $self->{instance}->{config}->set(imapidlepoll => '0'); + $self->{instance}->start(); + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(folder => 'INBOX'); + my $talk = $store->get_client(); + + xlog $self, "The server should not report the IDLE capability"; + $self->assert(!$talk->capability()->{idle}); + + xlog $self, "The IDLE command should not be recognised"; + # Note that we don't use idle_begin() because that will get + # upset if we get "tag BAD ..." back instead of "+ something". + my $r = $talk->_imap_cmd('idle', 0, ''); + $self->assert_null($r); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + $self->assert_matches(qr/Unrecognized command/, $talk->get_last_error()); +} + +sub common_basic +{ + my ($self) = @_; + + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(folder => 'INBOX'); + my $talk = $store->get_client(); + $store->_select(); + + xlog $self, "The server should report the IDLE capability"; + $self->assert($talk->capability()->{idle}); + + xlog $self, "Sending the IDLE command"; + $store->idle_begin() + or die "IDLE failed: $@"; + + xlog $self, "Poll for any unsolicited response - should be none"; + my $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + xlog $self, "Sending DONE continuation"; + $store->idle_end({}); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Testing that normal IMAP commands still work"; + my $res = $talk->status('INBOX', '(messages unseen)'); + $self->assert_deep_equals({ messages => 0, unseen => 0 }, $res); +} + +sub test_basic_idled + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "Basic test of the IDLE command, idled started"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + $self->common_basic(); +} + +sub test_basic_noidled +{ + my ($self) = @_; + + xlog $self, "Basic test of the IDLE command, no idled started"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->start(); + $self->common_basic(); +} + +sub test_basic_abortedidled + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "Basic test of the IDLE command, idled started but aborted"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->start(); + $self->start_and_abort_idled(); + + $self->common_basic(); +} + +sub common_delivery +{ + my ($self) = @_; + + xlog $self, "Starting up the instance"; + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(folder => 'INBOX'); + my $talk = $store->get_client(); + $store->_select(); + + xlog $self, "Sending the IDLE command"; + $store->idle_begin() + or die "IDLE failed: $@"; + + xlog $self, "Poll for any unsolicited response - should be none"; + my $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + xlog $self, "sleeping for 3 seconds"; + sleep(3); + + xlog $self, "Poll for any unsolicited response - should be none"; + $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + $r = $store->idle_response({}, 5); + $self->assert($r, "received an unsolicited response"); + $r = $store->idle_response({}, 5); + $self->assert($r, "received an unsolicited response"); + $r = $store->idle_response({}, 1); + $self->assert(!$r, "no more unsolicited responses"); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + $self->assert_num_equals(1, $talk->get_response_code('recent')); + + xlog $self, "Sending DONE continuation"; + $store->idle_end({}); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); +} + +sub test_delivery_idled + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "Test the IDLE command vs local delivery, idled started"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + $self->common_delivery(); +} + +sub test_delivery_noidled +{ + my ($self) = @_; + + xlog $self, "Test the IDLE command vs local delivery, no idled started"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->start(); + $self->common_delivery(); +} + +sub test_delivery_abortedidled + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "Test the IDLE command vs local delivery, idled started but aborted"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->start(); + $self->start_and_abort_idled(); + + $self->common_delivery(); +} + +sub common_shutdownfile +{ + my ($self) = @_; + + xlog $self, "Starting up the instance"; + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(folder => 'INBOX'); + my $talk = $store->get_client(); + $store->_select(); + + xlog $self, "Sending the IDLE command"; + $store->idle_begin() + or die "IDLE failed: $@"; + + xlog $self, "Poll for any unsolicited response - should be none"; + my $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + xlog $self, "sleeping for 3 seconds"; + sleep(3); + + xlog $self, "Poll for any unsolicited response - should be none"; + $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + $self->assert_null($talk->get_response_code('alert')); + + xlog $self, "Write some text to the shutdown file"; + my $admin_store = $svc->create_store(folder => 'user.cassandane', + username => 'admin'); + my $shut_message = "The Mayans were right"; + $admin_store->get_client()->setmetadata("", + "/shared/vendor/cmu/cyrus-imapd/shutdown", $shut_message); + $admin_store->disconnect(); + $admin_store = undef; + + # We want to override Mail::IMAPTalk's builtin handling of the BYE + # untagged response, as it will 'die' immediately without parsing + # the remainder of the line and especially without picking out the + # [ALERT] message that we want to see. + my $got_bye_alert; + my $handlers = + { + bye => sub + { + my ($response, $rr) = @_; + if (lc($rr->[0]) eq '[alert]') + { + # Arguments to [ALERT] is the rest of the line + # Sadly we've already split on whitespace but lets + # hope the original message only had single spaces + $got_bye_alert = join(' ', splice(@$rr, 1)); + } + } + }; + + xlog $self, "Check that we got a BYE [ALERT] response with the message"; + $r = $store->idle_response($handlers, 5); + $self->assert($r, "Got an unsolicited response"); + $self->assert_not_null($got_bye_alert); + $self->assert_str_equals($shut_message, $got_bye_alert); + + xlog $self, "Check that the server disconnected"; + eval + { + # We use _send_cmd() and _next_atom() rather the normal path + # through _imap_cmd() because the latter will warn() to stderr + # about the exception we're about to generate, which is + # downright untidy. + $talk->_send_cmd('status', 'INBOX', '(messages unseen)'); + $talk->_parse_response({}); + }; + my $mm = $@; # this doesn't survive unless we save it + $self->assert_matches(qr/IMAP Connection closed by other end/, $mm); +} + +sub test_shutdownfile_idled + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "Test the IDLE command vs the shutdownfile, idled started"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + $self->common_shutdownfile(); +} + +sub test_shutdownfile_noidled +{ + my ($self) = @_; + + xlog $self, "Test the IDLE command vs the shutdownfile"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->start(); + $self->common_shutdownfile(); +} + +sub test_shutdownfile_abortedidled + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "Test the IDLE command vs the shutdownfile, idled started but aborted"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->start(); + $self->start_and_abort_idled(); + + $self->common_shutdownfile(); +} + +sub test_sigterm + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "Test that an imapd can be killed with SIGTERM"; + xlog $self, "while executing an IDLE command"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + xlog $self, "Starting up the instance"; + $self->{instance}->start(); + + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(folder => 'INBOX'); + my $talk = $store->get_client(); + + $store->_select(); + + xlog $self, "Sending the IDLE command"; + $store->idle_begin() + or die "IDLE failed: $@"; + + # procinfo: pid SP servicename SP host [SP user] [SP mailbox] [SP cmdname] + my $procinfo = join '', $self->{instance}->run_cyr_info('proc'); + my ($imapd_pid, undef) = + ($procinfo =~ m/^(\d+) imap (.+) cassandane user.cassandane Idle$/); + $self->assert_not_null($imapd_pid); + $imapd_pid = 0 + $imapd_pid; + $self->assert($imapd_pid > 1); + xlog $self, "PID of imapd process is $imapd_pid"; + + xlog $self, "Poll for any unsolicited response - should be none"; + my $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + xlog $self, "sleeping for 3 seconds"; + sleep(3); + + xlog $self, "Poll for any unsolicited response - should be none"; + $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + $self->assert_null($talk->get_response_code('alert')); + + xlog $self, "Send SIGQUIT (or worse) to the imapd"; + $r = Cassandane::Instance::_stop_pid($imapd_pid); + $self->assert($r == 1, "shutdown required brute force"); + + xlog $self, "Check that the server disconnected"; + eval + { + # We use _send_cmd() and _next_atom() rather the normal path + # through _imap_cmd() because the latter will warn() to stderr + # about the exception we're about to generate, which is + # downright untidy. + $talk->_send_cmd('status', 'INBOX', '(messages unseen)'); + $talk->_parse_response({}); + }; + my $mm = $@; # this doesn't survive unless we save it + $self->assert_matches(qr/IMAP Connection closed by other end/, $mm); +} + +sub test_sigterm_many + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "Test that the Cyrus instance can be cleanly shut"; + xlog $self, "down with SIGTERM while many imapds execute an"; + xlog $self, "IDLE command"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + xlog $self, "Starting up the instance"; + $self->{instance}->start(); + + my $svc = $self->{instance}->get_service('imap'); + + my $N = 16; + my @stores; + my $r; + + for (my $i = 0 ; $i < $N ; $i++) + { + my $store = $svc->create_store(folder => 'INBOX'); + push(@stores, $store); + my $talk = $store->get_client(); + + $store->_select(); + + xlog $self, "Sending the IDLE command"; + $store->idle_begin() + or die "IDLE failed: $@"; + + xlog $self, "Poll for any unsolicited response - should be none"; + $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + } + + xlog $self, "sleeping for 3 seconds"; + sleep(3); + + foreach my $store (@stores) + { + xlog $self, "Poll for any unsolicited response - should be none"; + $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + $self->assert_null($store->get_client()->get_response_code('alert')); + } + + xlog $self, "Shut down the instance"; + $self->{instance}->stop(); +# $self->assert($r == 1, "shutdown required brute force"); + + xlog $self, "Check that the server disconnected"; + + foreach my $store (@stores) + { + eval + { + # We use _send_cmd() and _next_atom() rather the normal path + # through _imap_cmd() because the latter will warn() to stderr + # about the exception we're about to generate, which is + # downright untidy. + my $talk = $store->get_client(); + $talk->_send_cmd('status', 'INBOX', '(messages unseen)'); + $talk->_parse_response({}); + }; + my $mm = $@; # this doesn't survive unless we save it + $self->assert_matches(qr/IMAP Connection closed by other end/, $mm); + } +} + +sub test_idled_default_timeout + :needs_component_idled +{ + my ($self) = @_; + + # The default timeout if `imapidlepoll` isn't set in imapd.conf + # is set to 60 seconds. If idled is not broken, then we should + # return immediately(pretty much), instead of having to wait all + # of 60 seconds. + xlog $self, "Set idle poll timeout 60 seconds"; + $self->{instance}->{config}->set(imapidlepoll => '60'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + + xlog $self, "Starting up the instance"; + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(folder => 'INBOX'); + my $talk = $store->get_client(); + $store->_select(); + + xlog $self, "Sending the IDLE command"; + $store->idle_begin() + or die "IDLE failed: $@"; + + my $date1 = DateTime->from_epoch(epoch => time()); + + xlog $self, "Poll for any unsolicited response - should be none"; + my $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + xlog $self, "Poll for any unsolicited response - should be none"; + $r = $store->idle_response({}, 0); + $self->assert(!$r, "No unsolicted response"); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + $r = $store->idle_response({}, 5); + $self->assert($r, "received an unsolicited response"); + $r = $store->idle_response({}, 5); + $self->assert($r, "received an unsolicited response"); + $r = $store->idle_response({}, 1); + $self->assert(!$r, "no more unsolicited responses"); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + $self->assert_num_equals(1, $talk->get_response_code('recent')); + + xlog $self, "Sending DONE continuation"; + $store->idle_end({}); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + my $date2 = DateTime->from_epoch(epoch => time()); + + my $dur = $date2->epoch - $date1->epoch; + $self->assert($dur < 15, "IDLE took longer than expected"); +} + + +1; diff --git a/cassandane/Cassandane/Cyrus/ImapTest.pm b/cassandane/Cassandane/Cyrus/ImapTest.pm new file mode 100644 index 0000000000..6595806f9e --- /dev/null +++ b/cassandane/Cassandane/Cyrus/ImapTest.pm @@ -0,0 +1,190 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::ImapTest; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Cassini; + +my $basedir; +my $binary; +my $testdir; +my %suppressed; + +sub init +{ + my $cassini = Cassandane::Cassini->instance(); + $basedir = $cassini->val('imaptest', 'basedir'); + return unless defined $basedir; + $basedir = abs_path($basedir); + + my $supp = $cassini->val('imaptest', 'suppress', + 'listext subscribe'); + map { $suppressed{$_} = 1; } split(/\s+/, $supp); + + $binary = "$basedir/src/imaptest"; + $testdir = "$basedir/src/tests"; +} +init; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(servername => "127.0.0.1"); # urlauth needs matching servername + $config->set(virtdomains => 'userid'); + $config->set(unixhierarchysep => 'on'); + $config->set(altnamespace => 'yes'); + + return $class->SUPER::new({ config => $config }, @_); + +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + $self->{instance}->create_user('user2', subdirs => ['imaptest']); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub list_tests +{ + my @tests; + + if (!defined $basedir) + { + return ( 'test_warning_imaptest_is_not_installed' ); + } + + opendir TESTS, $testdir + or die "Cannot open directory $testdir: $!"; + while (my $e = readdir TESTS) + { + next if $e =~ m/^\./; + next if $e =~ m/\.mbox$/; + next if $suppressed{$e}; + next if ( ! -f "$testdir/$e" ); + push(@tests, "test_$e"); + } + closedir TESTS; + + return @tests; +} + +sub run_test +{ + my ($self) = @_; + + if (!defined $basedir) + { + xlog $self, "ImapTests are not enabled. To enabled them, please"; + xlog $self, "install ImapTest from http://www.imapwiki.org/ImapTest/"; + xlog $self, "and edit [imaptest]basedir in cassandane.ini"; + xlog $self, "This is not a failure"; + return; + } + + my $name = $self->name(); + $name =~ s/^test_//; + + my $logdir = "$self->{instance}->{basedir}/rawlog/"; + mkdir($logdir); + + my $svc = $self->{instance}->get_service('imap'); + my $params = $svc->store_params(); + + my $errfile = $self->{instance}->{basedir} . "/$name.errors"; + my $status; + $self->{instance}->run_command({ + redirects => { stderr => $errfile }, + workingdir => $logdir, + handlers => { + exited_normally => sub { $status = 1; }, + exited_abnormally => sub { $status = 0; }, + }, + }, + $binary, + "host=" . $params->{host}, + "port=" . $params->{port}, + "user=" . $params->{username}, + "user2=" . "user2", + "pass=" . $params->{password}, + "rawlog", + "test=$testdir/$name"); + + if ((!$status || get_verbose)) { + if (-f $errfile) { + open FH, '<', $errfile + or die "Cannot open $errfile for reading: $!"; + while (readline FH) { + xlog $self, $_; + } + close FH; + } + opendir(DH, $logdir) or die "Can't open logdir $logdir"; + while (my $item = readdir(DH)) { + next unless $item =~ m/^rawlog\./; + print "============> $item <=============\n"; + open(FH, '<', "$logdir/$item") or die "Can't open $logdir/$item"; + while (readline FH) { + print $_; + } + close(FH); + } + } + + $self->assert($status); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/InProgress.pm b/cassandane/Cassandane/Cyrus/InProgress.pm new file mode 100644 index 0000000000..3433ca5cc7 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/InProgress.pm @@ -0,0 +1,365 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2023 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::InProgress; +use strict; +use warnings; +use DateTime; +use JSON; +use JSON::XS; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use File::Basename; +use IO::File; +use Cwd qw(abs_path getcwd); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use charnames ':full'; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(mailbox_legacy_dirs => 'yes'); + $config->set(singleinstancestore => 'no'); + $config->set(imap_inprogress_interval => '1s'); + + return $class->SUPER::new({ + adminstore => 1, + config => $config, + services => ['imap'], + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_xrename + :NoAltNameSpace +{ + my ($self) = @_; + + my @resp; + my %handlers = + ( + ok => sub + { + my (undef, $ok) = @_; + push(@resp, $ok); + }, + ); + + xlog $self, "Create some personal folders"; + my $talk = $self->{store}->get_client(); + $self->setup_mailbox_structure($talk, [ + [ 'create' => [qw( INBOX.src INBOX.src.child INBOX.src.child.grand)] ], + ]); + + xlog $self, "rename mailbox tree"; + @resp = (); + $talk->_imap_cmd('XRENAME', 0, \%handlers, "INBOX.src", "INBOX.dst"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we shouldn't have a count or total + $self->assert_null($resp[0][1][1]); + $self->assert_null($resp[0][1][2]); + $self->assert_str_equals('rename', $resp[0][3]); + $self->assert_str_equals('INBOX.src', $resp[0][4]); + $self->assert_str_equals('INBOX.dst', $resp[0][5]); + $self->assert_str_equals('INBOX.src.child', $resp[1][4]); + $self->assert_str_equals('INBOX.dst.child', $resp[1][5]); + $self->assert_str_equals('INBOX.src.child.grand', $resp[2][4]); + $self->assert_str_equals('INBOX.dst.child.grand', $resp[2][5]); +} + +sub test_copy + :SlowIO :needs_component_slowio :NoAltNameSpace +{ + my ($self) = @_; + + my @resp; + my %handlers = + ( + ok => sub + { + my (undef, $ok) = @_; + push(@resp, $ok); + }, + ); + + xlog "generate some test messages"; + foreach (1..100) { + $self->make_message("Message $_", size => 128_000); + } + + xlog $self, "Create another folder"; + my $talk = $self->{store}->get_client(); + $talk->create("INBOX.dst"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "copy messages"; + @resp = (); + $talk->select("INBOX"); + $talk->_imap_cmd('COPY', 0, \%handlers, '1:100', 'INBOX.dst'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we don't know what the exact count will be, be we know the total + $self->assert_matches(qr/^[0-9]+$/, $resp[0][1][1]); + $self->assert_str_equals('100', $resp[0][1][2]); +} + +sub test_search + :SlowIO :needs_component_slowio :NoAltNameSpace +{ + my ($self) = @_; + + my @resp; + my %handlers = + ( + ok => sub + { + my (undef, $ok) = @_; + push(@resp, $ok); + }, + ); + + xlog "generate some test messages"; + foreach (1..100) { + $self->make_message("Message $_", size => 128_000); + } + + xlog $self, "search messages"; + my $talk = $self->{store}->get_client(); + @resp = (); + $talk->_imap_cmd('SEARCH', 0, \%handlers, 'RETURN', '(PARTIAL -1:-500)', '1:100', 'BODY', 'needle'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we don't know what the exact count will be, be we know the total + $self->assert_matches(qr/^[0-9]+$/, $resp[0][1][1]); + $self->assert_str_equals('100', $resp[0][1][2]); +} + +sub test_esearch_selected + :SlowIO :needs_component_slowio :NoAltNameSpace +{ + my ($self) = @_; + + my @resp; + my %handlers = + ( + ok => sub + { + my (undef, $ok) = @_; + push(@resp, $ok); + }, + ); + + xlog "generate some test messages"; + foreach (1..100) { + $self->make_message("Message $_", size => 128_000); + } + + xlog $self, "esearch selected mailbox"; + my $talk = $self->{store}->get_client(); + @resp = (); + $talk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(SELECTED)', '1:100', 'BODY', 'needle'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we don't know what the exact count will be, be we know the total + $self->assert_matches(qr/^[0-9]+$/, $resp[0][1][1]); + $self->assert_str_equals('100', $resp[0][1][2]); +} + +sub test_esearch_multiple + :SlowIO :needs_component_slowio :NoAltNameSpace +{ + my ($self) = @_; + + my @resp; + my %handlers = + ( + ok => sub + { + my (undef, $ok) = @_; + push(@resp, $ok); + }, + ); + + xlog "generate some test messages"; + foreach (1..100) { + $self->make_message("Message $_", size => 128_000); + } + + xlog $self, "Create another folder"; + my $talk = $self->{store}->get_client(); + $talk->create("INBOX.dst"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "copy messages"; + @resp = (); + $talk->select("INBOX"); + $talk->_imap_cmd('COPY', 0, \%handlers, '1:100', 'INBOX.dst'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we don't know what the exact count will be, be we know the total + $self->assert_matches(qr/^[0-9]+$/, $resp[0][1][1]); + $self->assert_str_equals('100', $resp[0][1][2]); + + xlog $self, "esearch multiple mailboxes"; + @resp = (); + $talk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(PERSONAL)', '1:100', 'BODY', 'needle'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we shouldn't have a count or total + $self->assert_null($resp[0][1][1]); + $self->assert_null($resp[0][1][2]); +} + +sub test_sort + :SlowIO :needs_component_slowio :NoAltNameSpace +{ + my ($self) = @_; + + my @resp; + my %handlers = + ( + ok => sub + { + my (undef, $ok) = @_; + push(@resp, $ok); + }, + ); + + xlog "generate some test messages"; + foreach (1..100) { + $self->make_message("Message $_", size => 128_000); + } + + xlog $self, "sort messages"; + my $talk = $self->{store}->get_client(); + @resp = (); + $talk->_imap_cmd('SORT', 0, \%handlers, + '(ARRIVAL)', 'US-ASCII', '1:100', 'BODY', 'needle'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we don't know what the exact count will be, be we know the total + $self->assert_matches(qr/^[0-9]+$/, $resp[0][1][1]); + $self->assert_str_equals('100', $resp[0][1][2]); +} + +sub test_thread + :SlowIO :needs_component_slowio :NoAltNameSpace +{ + my ($self) = @_; + + my @resp; + my %handlers = + ( + ok => sub + { + my (undef, $ok) = @_; + push(@resp, $ok); + }, + ); + + xlog "generate some test messages"; + foreach (1..100) { + $self->make_message("Message $_", size => 128_000); + } + + xlog $self, "thread messages"; + my $talk = $self->{store}->get_client(); + @resp = (); + $talk->_imap_cmd('THREAD', 0, \%handlers, + 'REFERENCES', 'US-ASCII', '1:100', 'BODY', 'needle'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we don't know what the exact count will be, be we know the total + $self->assert_matches(qr/^[0-9]+$/, $resp[0][1][1]); + $self->assert_str_equals('100', $resp[0][1][2]); +} + +sub test_rename + :SlowIO :needs_component_slowio :NoAltNameSpace +{ + my ($self) = @_; + + my @resp; + my %handlers = + ( + ok => sub + { + my (undef, $ok) = @_; + push(@resp, $ok); + }, + ); + + xlog "generate some test messages"; + foreach (1..100) { + $self->make_message("Message $_", size => 128_000); + } + + xlog $self, "rename INBOX"; + my $talk = $self->{store}->get_client(); + @resp = (); + $talk->_imap_cmd('RENAME', 0, \%handlers, 'INBOX', 'INBOX.Archive'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_str_equals('[INPROGRESS', $resp[0][0]); + # we don't know what the exact count will be, be we know the total + $self->assert_matches(qr/^[0-9]+$/, $resp[0][1][1]); + $self->assert_str_equals('100', $resp[0][1][2]); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Info.pm b/cassandane/Cassandane/Cyrus/Info.pm new file mode 100644 index 0000000000..b8d89d5169 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Info.pm @@ -0,0 +1,443 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Info; +use strict; +use warnings; +use Cwd qw(realpath); +use Data::Dumper; +use Date::Format qw(time2str); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_conf +{ + my ($self) = @_; + + my %imapd_conf; + my $filename = $self->{instance}->_imapd_conf(); + open my $fh, '<', $filename + or die "Cannot open $filename for reading: $!"; + while (my $line = <$fh>) { + chomp $line; + my ($name, $value) = split /\s*:\s*/, $line, 2; + if (Cassandane::Config::is_bitfield($name)) { + my @values = split /\s+/, $value; + $imapd_conf{$name} = join q{ }, sort @values; + } + else { + $imapd_conf{$name} = $value; + } + } + close $fh; + + my %cyr_info_conf; + foreach my $line ($self->{instance}->run_cyr_info('conf')) { + chomp $line; + my ($name, $value) = split /\s*:\s*/, $line, 2; + if (Cassandane::Config::is_bitfield($name)) { + my @values = split /\s+/, $value; + $cyr_info_conf{$name} = join q{ }, sort @values; + } + else { + $cyr_info_conf{$name} = $value; + } + } + + $self->assert_deep_equals(\%imapd_conf, \%cyr_info_conf); +} + +sub test_conf_all +{ + my ($self) = @_; + + my %imapd_conf; + my $filename = $self->{instance}->_imapd_conf(); + open my $fh, '<', $filename + or die "Cannot open $filename for reading: $!"; + while (my $line = <$fh>) { + chomp $line; + my ($name, $value) = split /\s*:\s*/, $line, 2; + if (Cassandane::Config::is_bitfield($name)) { + my @values = split /\s+/, $value; + $imapd_conf{$name} = join q{ }, sort @values; + } + else { + $imapd_conf{$name} = $value; + } + } + close $fh; + + my %cyr_info_conf; + foreach my $line ($self->{instance}->run_cyr_info('conf-all')) { + chomp $line; + my ($name, $value) = split /\s*:\s*/, $line, 2; + + # conf-all outputs ALL configured values (including defaults) + # but we can only really test for the ones we know we put there + next if not exists $imapd_conf{$name}; + + if (Cassandane::Config::is_bitfield($name)) { + my @values = split /\s+/, $value; + $cyr_info_conf{$name} = join q{ }, sort @values; + } + else { + $cyr_info_conf{$name} = $value; + } + } + + $self->assert_deep_equals(\%imapd_conf, \%cyr_info_conf); +} + +sub test_conf_default +{ + my ($self) = @_; + + # conf-default spits out all the defaults. can't do much to + # check the actual contents, short of duplicating lib/imapoptions + # in here, but we can at least make sure it runs without crashing + # and its output looks reasonably sane + + foreach my $line ($self->{instance}->run_cyr_info('conf-default')) { + chomp $line; + my ($name, $value) = split /\s*:\s*/, $line, 2; + + $self->assert_not_null($name); + $self->assert_not_null($value); + + if (Cassandane::Config::is_bitfield($name)) { + foreach my $v (split /\s+/, $value) { + $self->assert_not_null(Cassandane::Config::is_bitfield_bit($name, $v)); + } + } + } +} + +sub test_lint +{ + my ($self) = @_; + + xlog $self, "test 'cyr_info conf-lint' in the simplest case"; + + my @output = $self->{instance}->run_cyr_info('conf-lint'); + $self->assert_deep_equals([], \@output); +} + +Cassandane::Cyrus::TestCase::magic(ConfigJunk => sub { + shift->config_set(trust_fund => 'street art'); +}); + +sub test_lint_junk + :ConfigJunk +{ + my ($self) = @_; + + xlog $self, "test 'cyr_info conf-lint' with junk in the config"; + + my @output = $self->{instance}->run_cyr_info('conf-lint'); + $self->assert_deep_equals(["trust_fund: street art\n"], \@output); +} + +sub test_lint_channels + :min_version_3_2 :NoStartInstances +{ + my ($self) = @_; + + $self->config_set( + 'sync_log_channels' => 'banana', + 'banana_sync_host' => 'banana.internal', + 'banana_sync_trust_fund' => 'street art', + 'banana_tcp_keepalive' => 'yes', + ); + + $self->_start_instances(); + + xlog $self, "test 'cyr_info conf-lint' with channel-specific sync config"; + + my @output = $self->{instance}->run_cyr_info('conf-lint'); + + $self->assert_deep_equals( + [ sort( + "banana_sync_trust_fund: street art\n", + "banana_tcp_keepalive: yes\n", + ) ], + [ sort @output ] + ); +} + +sub test_lint_partitions + :min_version_3_0 :NoStartInstances +{ + my ($self) = @_; + + $self->config_set( + # metapartition-, archivepartition- and searchpartition- must + # correspond with an extant partition- + # + # backuppartition- is independent + 'partition-good' => '/tmp/pgood', + 'metapartition-good' => '/tmp/mgood', + 'archivepartition-good' => '/tmp/agood', + 'foosearchpartition-good' => '/tmp/sgood', + 'backuppartition-good' => '/tmp/bgood', + + 'metapartition-bad' => '/tmp/mbad', + 'archivepartition-bad' => '/tmp/abad', + 'foosearchpartition-bad' => '/tmp/sbad', + + # not actually bad + 'backuppartition-bad' => '/tmp/bbad', + ); + + $self->_start_instances(); + + xlog $self, "test 'cyr_info conf-lint' with partitions configured"; + + my @output = $self->{instance}->run_cyr_info('conf-lint'); + + $self->assert_deep_equals( + [ sort( + "archivepartition-bad: /tmp/abad\n", + "foosearchpartition-bad: /tmp/sbad\n", + "metapartition-bad: /tmp/mbad\n", + ) ], + [ sort @output ] + ); +} + +sub test_proc_services +{ + my ($self) = @_; + + # no clients => no service daemons => no processes + my @output = $self->{instance}->run_cyr_info('proc'); + $self->assert_num_equals(0, scalar @output); + + # master spawns service processes when clients connect to them + my $imap_svc = $self->{instance}->get_service('imap'); + my @clients; + foreach (1..5) { + # five concurrent connections for a single user is normal, + # e.g. thunderbird does this + my $store = $imap_svc->create_store(username => 'cassandane'); + my $imaptalk = $store->get_client(); + push @clients, $imaptalk if $imaptalk; + } + + # better have got some clients from that! + $self->assert_num_gte(1, scalar @clients); + + # five clients => five service daemons => five processes + @output = $self->{instance}->run_cyr_info('proc'); + $self->assert_num_equals(scalar @clients, scalar @output); + + # log clients out one at a time, expect proc count to decrease + while (scalar @clients) { + my $old = shift @clients; + $old->logout(); + + @output = $self->{instance}->run_cyr_info('proc'); + $self->assert_num_equals(scalar @clients, scalar @output); + } +} + +sub test_proc_starts + :NoStartInstances +{ + my ($self) = @_; + + # we used to recommend starting idled from START, and it will + # still work like that, so using it here saves me mocking something + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + + # entries listed in START run to completion before master fully + # starts up. if they fork themselves and hang around (like idled + # does) then that's their business, but master can't and doesn't + # track them + my @output = $self->{instance}->run_cyr_info('proc'); + + $self->assert_num_equals(0, scalar @output); +} + +sub test_proc_periodic_events_slow + :NoStartInstances +{ + my ($self) = @_; + + my $sleeper_time = 10; # seconds + + # periodic events first fire immediately at startup, and then every + # 'period' minutes thereafter. the fastest we can schedule them is + # every 1 minute, so this test must run for at least several real + # minutes + $self->{instance}->add_event( + name => 'sleeper', + argv => [ realpath('utils/sleeper'), $sleeper_time ], + period => 1, + ); + $self->{instance}->start(); + + sleep 2; # offset our checks a little to avoid races + + # observe for three cycles + my $observations = 3; + while ($observations > 0) { + # event should have fired and be running + my @output = $self->{instance}->run_cyr_info('proc'); + $self->assert_num_equals(1, scalar @output); + + # wait for it to finish and check again + sleep $sleeper_time; + @output = $self->{instance}->run_cyr_info('proc'); + $self->assert_num_equals(0, scalar @output); + + # skip final wait if we're done + $observations--; + last if $observations == 0; + + # wait until next period + sleep 60 - $sleeper_time; + } +} + +sub test_proc_scheduled_events + :NoStartInstances +{ + my ($self) = @_; + + my $sleeper_time = 10; + + # schedule an event to fire at the next minute boundary that is at + # least ten seconds away + my $at = time + 70; + $at -= ($at % 60); + my $at_hm = time2str('%H%M', $at); + xlog $self, "scheduling event to run at $at_hm ($at)"; + $self->{instance}->add_event( + name => 'sleeper', + argv => [ realpath('utils/sleeper'), $sleeper_time ], + at => $at_hm, + ); + $self->{instance}->start(); + + # event process should not be running at startup + my @output = $self->{instance}->run_cyr_info('proc'); + $self->assert_num_equals(0, scalar @output); + + # should be running at the scheduled time (with a little slop) + sleep 2 + $at - time; + @output = $self->{instance}->run_cyr_info('proc'); + $self->assert_num_equals(1, scalar @output); + + # should not be running after we expect it to have finished + sleep $sleeper_time; + @output = $self->{instance}->run_cyr_info('proc'); + $self->assert_num_equals(0, scalar @output); +} + +sub test_proc_daemons + :NoStartInstances +{ + my ($self) = @_; + + my $sleeper_time = 10; # seconds + my $daemons = 3; + + for my $i (1 .. $daemons) { + # you wouldn't usually run a daemon that exits and needs to be + # restarted every ten seconds, but it's useful for testing + # that cyr_info proc notices the pid changing + $self->{instance}->add_daemon( + name => "sleeper$i", + argv => [ realpath('utils/sleeper'), $sleeper_time ], + ); + } + $self->{instance}->start(); + + sleep 2; # offset our checks a little to avoid races + + my $observations = 3; + my %lastpid = map {; "sleeper$_" => 0 } (1 .. $daemons); + while ($observations > 0) { + my @output = $self->{instance}->run_cyr_info('proc'); + + # always exactly one process per daemon + $self->assert_num_equals($daemons, scalar @output); + + # expect a new pid for each daemon each time + foreach my $line (@output) { + my ($pid, $servicename, $host, $user, $mailbox, $cmd) + = split /\s/, $line, 6; + $self->assert_num_not_equals($lastpid{$servicename}, $pid); + $lastpid{$servicename} = $pid; + } + + # skip final wait if we're done + $observations--; + last if $observations == 0; + + # wait for next restart + sleep $sleeper_time; + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPBackup.pm b/cassandane/Cassandane/Cyrus/JMAPBackup.pm new file mode 100644 index 0000000000..5a2dc0edcb --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPBackup.pm @@ -0,0 +1,99 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2019 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPBackup; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.09; +use Net::CardDAVTalk 0.03; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use File::Basename; +use XML::Spice; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane', + caldav_historical_age => -1, + conversations => 'yes', + httpmodules => 'carddav caldav jmap', + httpallowcompress => 'no', + imipnotifier => 'imip', + notesmailbox => 'Notes', + jmap_nonstandard_extensions => 'yes'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => [ 'imap', 'http' ] + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/backup', + 'https://cyrusimap.org/ns/jmap/contacts', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/notes', + ]); +} + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPBackup'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPBlob.pm b/cassandane/Cassandane/Cyrus/JMAPBlob.pm new file mode 100644 index 0000000000..bbbc7c4405 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPBlob.pm @@ -0,0 +1,76 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2024 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPBlob; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Mail::JMAPTalk 0.15; +use Data::Dumper; +use MIME::Base64 qw(encode_base64); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set(conversations => 'yes', + httpmodules => 'jmap', + jmap_max_size_upload => '1k', + httpallowcompress => 'no'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => [ 'imap', 'http' ] + }, @args); +} + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPBlob'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPCalendars.pm b/cassandane/Cassandane/Cyrus/JMAPCalendars.pm new file mode 100644 index 0000000000..da90b0286b --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPCalendars.pm @@ -0,0 +1,638 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPCalendars; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.09; +use Net::CardDAVTalk 0.03; +use Mail::JMAPTalk 0.13; +use Data::ICal; +use Data::Dumper; +use Data::GUID qw(guid_string); +use Storable 'dclone'; +use Cwd qw(abs_path); +use File::Basename; +use XML::Spice; +use MIME::Base64 qw(encode_base64url decode_base64url); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + my $config = Cassandane::Config->default()->clone(); + + $config->set(caldav_realm => 'Cassandane', + caldav_historical_age => -1, + conversations => 'yes', + httpmodules => 'carddav caldav jmap', + httpallowcompress => 'no', + imipnotifier => 'imip', + sync_log => 'yes', + jmap_nonstandard_extensions => 'yes', + defaultdomain => 'example.com'); + + # Configure Sieve iMIP delivery + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj == 3 && $min == 0) { + # need to explicitly add 'body' to sieve_extensions for 3.0 + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags mailbox mboxmetadata servermetadata variables " . + "body"); + } + elsif ($maj < 3) { + # also for 2.5 (the earliest Cyrus that Cassandane can test) + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags body"); + } + $config->set(sievenotifier => 'mailto'); + $config->set(calendar_user_address_set => 'example.com'); + $config->set(caldav_historical_age => -1); + $config->set(virtdomains => 'no'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + deliver => 1, + services => [ 'imap', 'sieve', 'http' ], + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + if ($self->{_want}->{start_instances}) { + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'urn:ietf:params:jmap:calendars:preferences', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/debug', + ]); + } +} + +sub encode_eventid +{ + # This function hard-codes the event id format. + # It might break if we change the id scheme. + my ($uid, $recurid) = @_; + my $eid = 'E'; + if ($recurid) { + $eid .= 'R' + } + if ($uid =~ /[^0-9A-Za-z\-_]/) { + $eid .= 'B'; + } + $eid .= '-'; + if ($recurid) { + $eid .= $recurid . '-'; + } + if ($uid =~ /[^0-9A-Za-z\-_]/) { + $eid .= encode_base64url($uid); + } + else { + $eid .= $uid; + } + return $eid; +} + +sub normalize_event +{ + my ($event) = @_; + + if (not exists $event->{q{@type}}) { + $event->{q{@type}} = 'Event'; + } + if (not exists $event->{freeBusyStatus}) { + $event->{freeBusyStatus} = 'busy'; + } + if (not exists $event->{priority}) { + $event->{priority} = 0; + } + if (not exists $event->{title}) { + $event->{title} = ''; + } + if (not exists $event->{description}) { + $event->{description} = ''; + } + if (not exists $event->{descriptionContentType}) { + $event->{descriptionContentType} = 'text/plain'; + } + if (not exists $event->{showWithoutTime}) { + $event->{showWithoutTime} = JSON::false; + } + if (not exists $event->{locations}) { + $event->{locations} = undef; + } elsif (defined $event->{locations}) { + foreach my $loc (values %{$event->{locations}}) { + if (not exists $loc->{name}) { + $loc->{name} = ''; + } + if (not exists $loc->{q{@type}}) { + $loc->{q{@type}} = 'Location'; + } + foreach my $link (values %{$loc->{links}}) { + if (not exists $link->{q{@type}}) { + $link->{q{@type}} = 'Link'; + } + } + } + } + if (not exists $event->{virtualLocations}) { + $event->{virtualLocations} = undef; + } elsif (defined $event->{virtualLocations}) { + foreach my $loc (values %{$event->{virtualLocations}}) { + if (not exists $loc->{name}) { + $loc->{name} = '' + } + if (not exists $loc->{description}) { + $loc->{description} = undef; + } + if (not exists $loc->{uri}) { + $loc->{uri} = undef; + } + if (not exists $loc->{q{@type}}) { + $loc->{q{@type}} = 'VirtualLocation'; + } + } + } + if (not exists $event->{keywords}) { + $event->{keywords} = undef; + } + if (not exists $event->{locale}) { + $event->{locale} = undef; + } + if (not exists $event->{links}) { + $event->{links} = undef; + } elsif (defined $event->{links}) { + foreach my $link (values %{$event->{links}}) { + if (not exists $link->{q{@type}}) { + $link->{q{@type}} = 'Link'; + } + } + } + if (not exists $event->{relatedTo}) { + $event->{relatedTo} = undef; + } elsif (defined $event->{relatedTo}) { + foreach my $rel (values %{$event->{relatedTo}}) { + if (not exists $rel->{q{@type}}) { + $rel->{q{@type}} = 'Relation'; + } + } + } + if (not exists $event->{participants}) { + $event->{participants} = undef; + } elsif (defined $event->{participants}) { + foreach my $p (values %{$event->{participants}}) { + if (not exists $p->{linkIds}) { + $p->{linkIds} = undef; + } + if (not exists $p->{participationStatus}) { + $p->{participationStatus} = 'needs-action'; + } + if (not exists $p->{expectReply}) { + $p->{expectReply} = JSON::false; + } + if (not exists $p->{scheduleSequence}) { + $p->{scheduleSequence} = 0; + } + if (not exists $p->{q{@type}}) { + $p->{q{@type}} = 'Participant'; + } + foreach my $link (values %{$p->{links}}) { + if (not exists $link->{q{@type}}) { + $link->{q{@type}} = 'Link'; + } + } + } + } + if (not exists $event->{replyTo}) { + $event->{replyTo} = undef; + } + if (not exists $event->{recurrenceRules}) { + $event->{recurrenceRules} = undef; + } elsif (defined $event->{recurrenceRules}) { + foreach my $rrule (@{$event->{recurrenceRules}}) { + if (not exists $rrule->{interval}) { + $rrule->{interval} = 1; + } + if (not exists $rrule->{firstDayOfWeek}) { + $rrule->{firstDayOfWeek} = 'mo'; + } + if (not exists $rrule->{rscale}) { + $rrule->{rscale} = 'gregorian'; + } + if (not exists $rrule->{skip}) { + $rrule->{skip} = 'omit'; + } + if (not exists $rrule->{byDay}) { + $rrule->{byDay} = undef; + } elsif (defined $rrule->{byDay}) { + foreach my $nday (@{$rrule->{byDay}}) { + if (not exists $nday->{q{@type}}) { + $nday->{q{@type}} = 'NDay'; + } + } + } + if (not exists $rrule->{q{@type}}) { + $rrule->{q{@type}} = 'RecurrenceRule'; + } + } + } + if (not exists $event->{excludedRecurrenceRules}) { + $event->{excludedRecurrenceRules} = undef; + } elsif (defined $event->{excludedRecurrenceRules}) { + foreach my $exrule (@{$event->{excludedRecurrenceRules}}) { + if (not exists $exrule->{interval}) { + $exrule->{interval} = 1; + } + if (not exists $exrule->{firstDayOfWeek}) { + $exrule->{firstDayOfWeek} = 'mo'; + } + if (not exists $exrule->{rscale}) { + $exrule->{rscale} = 'gregorian'; + } + if (not exists $exrule->{skip}) { + $exrule->{skip} = 'omit'; + } + if (not exists $exrule->{byDay}) { + $exrule->{byDay} = undef; + } elsif (defined $exrule->{byDay}) { + foreach my $nday (@{$exrule->{byDay}}) { + if (not exists $nday->{q{@type}}) { + $nday->{q{@type}} = 'NDay'; + } + } + } + if (not exists $exrule->{q{@type}}) { + $exrule->{q{@type}} = 'RecurrenceRule'; + } + } + } + if (not exists $event->{recurrenceOverrides}) { + $event->{recurrenceOverrides} = undef; + } + if (not exists $event->{alerts}) { + $event->{alerts} = undef; + } + elsif (defined $event->{alerts}) { + foreach my $alert (values %{$event->{alerts}}) { + if (not exists $alert->{action}) { + $alert->{action} = 'display'; + } + if (not exists $alert->{q{@type}}) { + $alert->{q{@type}} = 'Alert'; + } + if (not exists $alert->{relatedTo}) { + $alert->{relatedTo} = undef; + } elsif (defined $alert->{relatedTo}) { + foreach my $rel (values %{$alert->{relatedTo}}) { + if (not exists $rel->{q{@type}}) { + $rel->{q{@type}} = 'Relation'; + } + } + } + if ($alert->{trigger} and $alert->{trigger}{q{@type}} eq 'OffsetTrigger') { + if (not exists $alert->{trigger}{relativeTo}) { + $alert->{trigger}{relativeTo} = 'start'; + } + } + } + } + if (not exists $event->{useDefaultAlerts}) { + $event->{useDefaultAlerts} = JSON::false; + } + if (not exists $event->{prodId}) { + $event->{prodId} = undef; + } + if (not exists $event->{links}) { + $event->{links} = undef; + } elsif (defined $event->{links}) { + foreach my $link (values %{$event->{links}}) { + if (not exists $link->{cid}) { + $link->{cid} = undef; + } + if (not exists $link->{contentType}) { + $link->{contentType} = undef; + } + if (not exists $link->{size}) { + $link->{size} = undef; + } + if (not exists $link->{title}) { + $link->{title} = undef; + } + if (not exists $link->{q{@type}}) { + $link->{q{@type}} = 'Link'; + } + } + } + if (not exists $event->{status}) { + $event->{status} = "confirmed"; + } + if (not exists $event->{privacy}) { + $event->{privacy} = "public"; + } + if (not exists $event->{isDraft}) { + $event->{isDraft} = JSON::false; + } + if (not exists $event->{excluded}) { + $event->{excluded} = JSON::false, + } + + if (not exists $event->{calendarIds}) { + $event->{calendarIds} = undef; + } + if (not exists $event->{timeZone}) { + $event->{timeZone} = undef; + } + + if (not exists $event->{mayInviteSelf}) { + $event->{mayInviteSelf} = JSON::false, + } + + # undefine dynamically generated values + $event->{created} = undef; + $event->{updated} = undef; + $event->{uid} = undef; + $event->{id} = undef; + $event->{"x-href"} = undef; + $event->{sequence} = 0; + $event->{prodId} = undef; + $event->{isOrigin} = undef; + delete($event->{blobId}); + delete($event->{debugBlobId}); +} + +sub assert_normalized_event_equals +{ + my ($self, $a, $b) = @_; + my $copyA = dclone($a); + my $copyB = dclone($b); + normalize_event($copyA); + normalize_event($copyB); + return $self->assert_deep_equals($copyA, $copyB); +} + +sub putandget_vevent +{ + my ($self, $id, $ical, $props) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "get default calendar id"; + my $res = $jmap->CallMethods([['Calendar/get', {ids => ["Default"]}, "R1"]]); + $self->assert_str_equals("Default", $res->[0][1]{list}[0]{id}); + my $calid = $res->[0][1]{list}[0]{id}; + my $xhref = $res->[0][1]{list}[0]{"x-href"}; + + # Create event via CalDAV to test CalDAV/JMAP interop. + xlog $self, "create event (via CalDAV)"; + my $href = "$xhref/$id.ics"; + + $caldav->Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar'); + + xlog $self, "get event $id"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$id], properties => $props}, "R1"]]); + + my $event = $res->[0][1]{list}[0]; + $self->assert_not_null($event); + return $event; +} + +sub icalfile +{ + my ($self, $name) = @_; + + my $path = abs_path("data/icalendar/$name.ics"); + $self->assert_file_test($path, '-f'); + my $data = slurp_file($path); + + my ($id) = ($data =~ m/^UID:(\S+)\r?$/m); + $self->assert($id); + return ($id, $data); +} + +sub createandget_event +{ + my ($self, $event, %params) = @_; + + my $jmap = $self->{jmap}; + my $accountId = $params{accountId} || 'cassandane'; + + xlog $self, "create event"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { + accountId => $accountId, + create => {"1" => $event}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get calendar event $id"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$id]}, "R1"]]); + my $ret = $res->[0][1]{list}[0]; + return $ret; +} + +sub updateandget_event +{ + my ($self, $event) = @_; + + my $jmap = $self->{jmap}; + my $id = $event->{id}; + + xlog $self, "update event $id"; + my $res = $jmap->CallMethods([['CalendarEvent/set', {update => {$id => $event}}, "R1"]]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "get calendar event $id"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$id]}, "R1"]]); + my $ret = $res->[0][1]{list}[0]; + return $ret; +} + +sub createcalendar +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", color => "coral", sortOrder => 1, isVisible => \1 + }}}, "R1"] + ]); + $self->assert_not_null($res->[0][1]{created}); + return $res->[0][1]{created}{"1"}{id}; +} + +sub assert_rewrite_webdav_attachment_url_itip + :min_version_3_5 :needs_component_jmap +{ + my ($self, $eventHref) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Assert ATTACH in iTIP message is a BINARY value"; + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + my $payload = decode_json($imip->{MESSAGE}); + + my $ical = Data::ICal->new(data => $payload->{ical}); + my %entries = map { $_->ical_entry_type() => $_ } @{$ical->entries()}; + my $event = $entries{'VEVENT'}; + $self->assert_not_null($event); + + my $attach = $event->property('ATTACH'); + $self->assert_num_equals(1, scalar @{$attach}); + $self->assert_null($attach->[0]->parameters()->{'MANAGED-ID'}); + $self->assert_str_equals('BINARY', $attach->[0]->parameters()->{VALUE}); + $self->assert_str_equals('c29tZWJsb2I=', $attach->[0]->value()); # 'someblob' in base64 + + xlog "Assert ATTACH on server is a WebDAV attachment URI"; + my $caldavResponse = $caldav->Request('GET', $eventHref); + $ical = Data::ICal->new(data => $caldavResponse->{content}); + %entries = map { $_->ical_entry_type() => $_ } @{$ical->entries()}; + $event = $entries{'VEVENT'}; + $self->assert_not_null($event); + + $attach = $event->property('ATTACH'); + $self->assert_num_equals(1, scalar @{$attach}); + $self->assert_not_null($attach->[0]->parameters()->{'MANAGED-ID'}); + $self->assert_null($attach->[0]->parameters()->{VALUE}); + my $webdavAttachURI = + $self->{instance}->{config}->get('webdav_attachments_baseurl') . + '/dav/calendars/user/cassandane/Attachments/'; + $self->assert($attach->[0]->value() =~ /^$webdavAttachURI.+/); +} + +sub create_user +{ + my ($self, $username) = @_; + + xlog $self, "create user $username"; + my $admin = $self->{adminstore}->get_client(); + $admin->create("user.$username"); + $admin->setacl("user.$username", admin => 'lrswipkxtecdan') or die; + $admin->setacl("user.$username", $username => 'lrswipkxtecdn') or die; + + my $http = $self->{instance}->get_service("http"); + my $userJmap = Mail::JMAPTalk->new( + user => $username, + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $userJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $userCalDAV = Net::CalDAVTalk->new( + user => $username, + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + return ($userJmap, $userCalDAV); +} + +sub deliver_imip { + my ($self) = @_; + + my $uuid = guid_string(); + my $imip = <<"EOF"; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); +}; + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPCalendars'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPContacts.pm b/cassandane/Cassandane/Cyrus/JMAPContacts.pm new file mode 100644 index 0000000000..be65c2b2e1 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPContacts.pm @@ -0,0 +1,141 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPContacts; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.09; +use Net::CardDAVTalk 0.03; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use File::Basename; +use File::Copy; +use Cwd qw(abs_path getcwd); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set(carddav_realm => 'Cassandane', + conversations => 'yes', + httpmodules => 'carddav jmap', + httpallowcompress => 'no', + vcard_max_size => 100000, + jmap_nonstandard_extensions => 'yes'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => [ 'imap', 'http' ] + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/contacts', + 'https://cyrusimap.org/ns/jmap/debug', + ]); + + my $buildinfo = Cassandane::BuildInfo->new(); + if ($buildinfo->get('dependency', 'icalvcard')) { + $self->{jmap}->AddUsing('urn:ietf:params:jmap:contacts'); + } +} + +sub normalize_jscard +{ + my ($jscard) = @_; + + if ($jscard->{vCardProps}) { + my @sorted = sort { $a->[0] cmp $b->[0] } @{$jscard->{vCardProps}}; + $jscard->{vCardProps} = \@sorted; + } + + if (not exists $jscard->{kind}) { + $jscard->{kind} = 'individual'; + } + + if (not exists $jscard->{'cyrusimap.org:importance'}) { + $jscard->{'cyrusimap.org:importance'} = '0'; + } +} + +sub _set_quotaroot +{ + my ($self, $quotaroot) = @_; + $self->{quotaroot} = $quotaroot; +} + +sub _set_quotalimits +{ + my ($self, %resources) = @_; + my $admintalk = $self->{adminstore}->get_client(); + + my $quotaroot = delete $resources{quotaroot} || $self->{quotaroot}; + my @quotalist; + foreach my $resource (keys %resources) + { + my $limit = $resources{$resource} + or die "No limit specified for $resource"; + push(@quotalist, uc($resource), $limit); + } + $self->{limits}->{$quotaroot} = { @quotalist }; + $admintalk->setquota($quotaroot, \@quotalist); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); +} + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPContacts'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPCore.pm b/cassandane/Cassandane/Cyrus/JMAPCore.pm new file mode 100644 index 0000000000..4ff2492e27 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPCore.pm @@ -0,0 +1,82 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPCore; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.09; +use Net::CardDAVTalk 0.03; +use Mail::JMAPTalk 0.15; +use Data::Dumper; +use Storable 'dclone'; +use MIME::Base64 qw(encode_base64); +use Encode qw(decode_utf8); +use Cwd qw(abs_path getcwd); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane', + conversations => 'yes', + httpmodules => 'carddav caldav jmap', + jmap_max_size_upload => '1k', + httpallowcompress => 'no'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => [ 'imap', 'http' ] + }, @args); +} + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPCore'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPEmail.pm b/cassandane/Cassandane/Cyrus/JMAPEmail.pm new file mode 100644 index 0000000000..6057dbff9f --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPEmail.pm @@ -0,0 +1,387 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPEmail; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.09; +use Net::CardDAVTalk 0.03; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use MIME::Base64 qw(encode_base64); +use Cwd qw(abs_path getcwd); +use URI; +use URI::Escape; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_historical_age => -1, + caldav_realm => 'Cassandane', + conversations => 'yes', + conversations_counted_flags => "\\Draft \\Flagged \$IsMailingList \$IsNotification \$HasAttachment \$muted", + defaultdomain => 'example.com', + httpallowcompress => 'no', + httpmodules => 'carddav caldav jmap', + imipnotifier => 'imip', + icalendar_max_size => 100000, + jmap_nonstandard_extensions => 'yes', + jmapsubmission_deleteonsend => 'no', + notesmailbox => 'Notes', + sync_log => 'yes'); + + # setup sieve + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj == 3 && $min == 0) { + # need to explicitly add 'body' to sieve_extensions for 3.0 + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags mailbox mboxmetadata servermetadata variables " . + "body"); + } + elsif ($maj < 3) { + # also for 2.5 (the earliest Cyrus that Cassandane can test) + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags body"); + } + $config->set(sievenotifier => 'mailto'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + deliver => 1, + adminstore => 1, + services => [ 'imap', 'http', 'sieve' ] + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + ]); +} + +sub getinbox +{ + my ($self, $args) = @_; + + $args = {} unless $args; + + my $jmap = $self->{jmap}; + + xlog $self, "get existing mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', $args, "R1"]]); + $self->assert_not_null($res); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + return $m{"Inbox"}; +} + +sub get_account_capabilities + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $Request; + my $Response; + + xlog $self, "get session"; + $Request = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + $Response = $jmap->ua->get($jmap->uri(), $Request); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($Request, $Response); + } + $self->assert_str_equals('200', $Response->{status}); + + my $session; + $session = eval { decode_json($Response->{content}) } if $Response->{success}; + + return $session->{accounts}{cassandane}{accountCapabilities}; +} + +sub defaultprops_for_email_get +{ + return ( "id", "blobId", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "messageId", "inReplyTo", "references", "sender", "from", "to", "cc", "bcc", "replyTo", "subject", "sentAt", "hasAttachment", "preview", "bodyValues", "textBody", "htmlBody", "attachments" ); +} + +sub download +{ + my ($self, $accountid, $blobid) = @_; + my $jmap = $self->{jmap}; + + my $uri = $jmap->downloaduri($accountid, $blobid); + my %Headers; + $Headers{'Authorization'} = $jmap->auth_header(); + my %getopts = (headers => \%Headers); + my $res = $jmap->ua->get($uri, \%getopts); + xlog $self, "JMAP DOWNLOAD @_ " . Dumper($res); + return $res; +} + +sub email_query_window_internal +{ + my ($self, %params) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + + $params{filter} //= undef; + $params{wantGuidSearch} //= JSON::false; + $params{calculateTotal} //= JSON::true; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $imaptalk = $self->{store}->get_client(); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating email A"; + $exp{A} = $self->make_message("Email A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A} ]); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "generating email D"; + $exp{D} = $self->make_message("Email D"); + $exp{D}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "list all emails"; + $res = $jmap->CallMethods([['Email/query', { + calculateTotal => JSON::true, + }, "R1"]]); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{ids}}); + $self->assert_num_equals(4, $res->[0][1]->{total}); + + my $ids = $res->[0][1]->{ids}; + my @subids; + + xlog $self, "list emails from position 1"; + $res = $jmap->CallMethods([ + ['Email/query', { + position => 1, + filter => $params{filter}, + calculateTotal => $params{calculateTotal}, + }, "R1"] + ], $using); + $self->assert_equals($params{wantGuidSearch}, + $res->[0][1]{performance}{details}{isGuidSearch}); + @subids = @{$ids}[1..3]; + $self->assert_deep_equals(\@subids, $res->[0][1]->{ids}); + if ($params{calculateTotal}) { + $self->assert_num_equals(4, $res->[0][1]->{total}); + } + + xlog $self, "list emails from position 4"; + $res = $jmap->CallMethods([ + ['Email/query', { + position => 4, + filter => $params{filter}, + calculateTotal => $params{calculateTotal}, + }, "R1"] + ], $using); + $self->assert_equals($params{wantGuidSearch}, + $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + if ($params{calculateTotal}) { + $self->assert_num_equals(4, $res->[0][1]->{total}); + } + + xlog $self, "limit emails from position 1 to one email"; + $res = $jmap->CallMethods([ + ['Email/query', { + position => 1, + limit => 1, + filter => $params{filter}, + calculateTotal => $params{calculateTotal}, + }, "R1"] + ], $using); + $self->assert_equals($params{wantGuidSearch}, + $res->[0][1]{performance}{details}{isGuidSearch}); + @subids = @{$ids}[1..1]; + $self->assert_deep_equals(\@subids, $res->[0][1]->{ids}); + $self->assert_num_equals(1, $res->[0][1]->{position}); + if ($params{calculateTotal}) { + $self->assert_num_equals(4, $res->[0][1]->{total}); + } + + xlog $self, "anchor at 2nd email"; + $res = $jmap->CallMethods([ + ['Email/query', { + anchor => @{$ids}[1], + filter => $params{filter}, + calculateTotal => $params{calculateTotal}, + }, "R1"] + ], $using); + $self->assert_equals($params{wantGuidSearch}, + $res->[0][1]{performance}{details}{isGuidSearch}); + @subids = @{$ids}[1..3]; + $self->assert_deep_equals(\@subids, $res->[0][1]->{ids}); + $self->assert_num_equals(1, $res->[0][1]->{position}); + if ($params{calculateTotal}) { + $self->assert_num_equals(4, $res->[0][1]->{total}); + } + + xlog $self, "anchor at 2nd email and offset 1"; + $res = $jmap->CallMethods([ + ['Email/query', { + anchor => @{$ids}[1], + anchorOffset => 1, + filter => $params{filter}, + calculateTotal => $params{calculateTotal}, + }, "R1"] + ], $using); + $self->assert_equals($params{wantGuidSearch}, + $res->[0][1]{performance}{details}{isGuidSearch}); + @subids = @{$ids}[2..3]; + $self->assert_deep_equals(\@subids, $res->[0][1]->{ids}); + $self->assert_num_equals(2, $res->[0][1]->{position}); + if ($params{calculateTotal}) { + $self->assert_num_equals(4, $res->[0][1]->{total}); + } + + xlog $self, "anchor at 3rd email and offset -1"; + $res = $jmap->CallMethods([ + ['Email/query', { + anchor => @{$ids}[2], + anchorOffset => -1, + filter => $params{filter}, + calculateTotal => $params{calculateTotal}, + }, "R1"] + ], $using); + $self->assert_equals($params{wantGuidSearch}, + $res->[0][1]{performance}{details}{isGuidSearch}); + @subids = @{$ids}[1..3]; + $self->assert_deep_equals(\@subids, $res->[0][1]->{ids}); + $self->assert_num_equals(1, $res->[0][1]->{position}); + if ($params{calculateTotal}) { + $self->assert_num_equals(4, $res->[0][1]->{total}); + } + + xlog $self, "anchor at 1st email offset 1 and limit 2"; + $res = $jmap->CallMethods([ + ['Email/query', { + anchor => @{$ids}[0], + anchorOffset => 1, + limit => 2, + filter => $params{filter}, + calculateTotal => $params{calculateTotal}, + }, "R1"] + ], $using); + $self->assert_equals($params{wantGuidSearch}, + $res->[0][1]{performance}{details}{isGuidSearch}); + @subids = @{$ids}[1..2]; + $self->assert_deep_equals(\@subids, $res->[0][1]->{ids}); + $self->assert_num_equals(1, $res->[0][1]->{position}); + if ($params{calculateTotal}) { + $self->assert_num_equals(4, $res->[0][1]->{total}); + } +} + +sub _set_quotaroot +{ + my ($self, $quotaroot) = @_; + $self->{quotaroot} = $quotaroot; +} + +sub _set_quotalimits +{ + my ($self, %resources) = @_; + my $admintalk = $self->{adminstore}->get_client(); + + my $quotaroot = delete $resources{quotaroot} || $self->{quotaroot}; + my @quotalist; + foreach my $resource (keys %resources) + { + my $limit = $resources{$resource} + or die "No limit specified for $resource"; + push(@quotalist, uc($resource), $limit); + } + $self->{limits}->{$quotaroot} = { @quotalist }; + $admintalk->setquota($quotaroot, \@quotalist); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); +} + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPEmail'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPEmailSubmission.pm b/cassandane/Cassandane/Cyrus/JMAPEmailSubmission.pm new file mode 100644 index 0000000000..7b98a8437b --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPEmailSubmission.pm @@ -0,0 +1,111 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPEmailSubmission; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.09; +use Net::CardDAVTalk 0.03; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use MIME::Base64 qw(encode_base64); +use Cwd qw(abs_path getcwd); +use URI; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane', + conversations => 'yes', + conversations_counted_flags => "\\Draft \\Flagged \$IsMailingList \$IsNotification \$HasAttachment", + event_groups => 'mailbox message flags calendar applepushservice jmap', + jmapsubmission_deleteonsend => 'no', + httpmodules => 'carddav caldav jmap', + httpallowcompress => 'no'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => [ 'imap', 'http' ], + smtpdaemon => 1, + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + ]); +} + +sub getinbox +{ + my ($self, $args) = @_; + + $args = {} unless $args; + + my $jmap = $self->{jmap}; + + xlog $self, "get existing mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', $args, "R1"]]); + $self->assert_not_null($res); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + return $m{"Inbox"}; +} + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPEmailSubmission'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPMailbox.pm b/cassandane/Cassandane/Cyrus/JMAPMailbox.pm new file mode 100644 index 0000000000..f3e43926a2 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPMailbox.pm @@ -0,0 +1,155 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPMailbox; +use strict; +use warnings; +use DateTime; +use JSON::XS; +use Net::CalDAVTalk 0.09; +use Net::CardDAVTalk 0.03; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use MIME::Base64 qw(encode_base64); +use Cwd qw(abs_path getcwd); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use lib '../perl/imap'; +use Cyrus::DList; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set(caldav_realm => 'Cassandane', + conversations => 'yes', + conversations_counted_flags => "\\Draft \\Flagged \$IsMailingList \$IsNotification \$HasAttachment", + httpmodules => 'carddav caldav jmap', + specialuse_extra => '\\XSpecialUse \\XChats \\XTemplates \\XNotes', + notesmailbox => 'Notes', + httpallowcompress => 'no'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + adminstore => 1, + services => [ 'imap', 'http' ] + }, @args); +} + +sub setup_default_using +{ + my ($self) = @_; + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + if ($self->{jmap}) { + $self->setup_default_using(); + } + # n.b. tests that use :NoStartInstances will need to call + # $self->setup_default_using() themselves! +} + +sub getinbox +{ + my ($self, $args) = @_; + + $args = {} unless $args; + + my $jmap = $self->{jmap}; + + xlog $self, "get existing mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', $args, "R1"]]); + $self->assert_not_null($res); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + return $m{"Inbox"}; +} + +sub _check_one_count { + my $self = shift; + my $want = shift; + my $have = shift; + my $name = shift; + $self->assert_num_equals($want, $have); +} + +sub _check_counts +{ + my $self = shift; + my $name = shift; + my %expect = @_; + + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([['Mailbox/get', {}, 'R']]); + + # "totalEmails": 3, + # "unreadEmails": 1, + # "totalThreads": 3, + # "unreadThreads": 1, + + for my $folder (@{$res->[0][1]{list}}) { + my $want = $expect{$folder->{name}}; + next unless $want; + $self->_check_one_count($want->[0], $folder->{totalEmails}, "$folder->{name} totalEmails"); + $self->_check_one_count($want->[1], $folder->{unreadEmails}, "$folder->{name} unreadEmails"); + $self->_check_one_count($want->[2], $folder->{totalThreads}, "$folder->{name} totalThreads"); + $self->_check_one_count($want->[3], $folder->{unreadThreads}, "$folder->{name} unreadThreads"); + } +} + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPMailbox'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPSieve.pm b/cassandane/Cassandane/Cyrus/JMAPSieve.pm new file mode 100644 index 0000000000..919dfa871d --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPSieve.pm @@ -0,0 +1,132 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2019 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPSieve; +use strict; +use warnings; +use DateTime; +use JSON; +use JSON::XS; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use File::Basename; +use IO::File; +use Cwd qw(abs_path getcwd); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj == 3 && $min == 0) { + # need to explicitly add 'body' to sieve_extensions for 3.0 + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags mailbox mboxmetadata servermetadata variables " . + "body"); + } + elsif ($maj < 3) { + # also for 2.5 (the earliest Cyrus that Cassandane can test) + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags body"); + } + + $config->set(caldav_realm => 'Cassandane', + conversations => 'yes', + httpmodules => 'carddav caldav jmap', + httpallowcompress => 'no', + jmap_nonstandard_extensions => 'yes'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + deliver => 1, + adminstore => 1, + services => [ 'imap', 'sieve', 'http' ] + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:sieve', + 'https://cyrusimap.org/ns/jmap/sieve', # for SieveScript/test + 'https://cyrusimap.org/ns/jmap/blob', + ]); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub download +{ + my ($self, $accountid, $blobid) = @_; + my $jmap = $self->{jmap}; + + my $uri = $jmap->downloaduri($accountid, $blobid); + my %Headers; + $Headers{'Authorization'} = $jmap->auth_header(); + my %getopts = (headers => \%Headers); + my $res = $jmap->ua->get($uri, \%getopts); + xlog $self, "JMAP DOWNLOAD @_ " . Dumper($res); + return $res; +} + +use Cassandane::Tiny::Loader 'tiny-tests/JMAPSieve'; + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPTestSuite.pm b/cassandane/Cassandane/Cyrus/JMAPTestSuite.pm new file mode 100644 index 0000000000..609471c86c --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPTestSuite.pm @@ -0,0 +1,320 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPTestSuite; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use JSON::XS qw(encode_json); +use File::Find; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Cassini; + +my $basedir; +my $binary; +my $testdir; +my $authortestdir; +my %suppressed; + +# Tests for behaviour that has changed over time, where older releases +# contain the older behaviour and can no longer pass the updated test. +# The value is the first version number that is supposed to work. +# Same as the :min_version annotations on native Cassandane tests, but +# dot-separated. See skip_before() below for implementation details. +my %notbefore = ( + 't:Email:get:header-header-field-name' => '3.5', + 't:Email:import:good-imports' => '3.8', + 't:Email:import:one-fails-another-succeeds' => '3.8', +); + +# Tests which JMAP-TestSuite will skip anyway when it detects it's +# talking to Cyrus. +my %notcyrus = map { $_ => 1 } ( + 't:Mailbox:get:no-existing-entities', + 't:Mailbox:query:no-existing-entities', +); + +sub cyrus_version_supports_jmap +{ + my ($maj, $min) = Cassandane::Instance->get_version(); + + return 0 if ($maj < 3); # not supported before 3.x + return 0 if ($maj == 3 && $min == 0); # not supported in 3.0.x + + # not supported if configured out + my $buildinfo = Cassandane::BuildInfo->new(); + return 0 if not $buildinfo->get('component', 'jmap'); + + return 1; # supported in everything newer +} + +sub init +{ + my $cassini = Cassandane::Cassini->instance(); + $basedir = $cassini->val('jmaptestsuite', 'basedir'); + return unless defined $basedir; + $basedir = abs_path($basedir); + + my $supp = $cassini->val('jmaptestsuite', 'suppress', ''); + map { $suppressed{$_} = 1; } split(/\s+/, $supp); + + $testdir = "$basedir/t"; + $authortestdir = "$basedir/xt"; +} +init; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(servername => "127.0.0.1"); # urlauth needs matching servername + $config->set(virtdomains => 'userid'); + $config->set(caldav_realm => 'Cassandane'); + $config->set(httpallowcompress => 'no'); + $config->set(conversations => 'yes'); + + $config->set(search_engine => 'xapian'); + $config->set(search_index_headers => 'no'); + $config->set(search_batchsize => 8192); + $config->set(defaultpartition => 'default'); + $config->set(defaultsearchtier => 't1'); + + $config->set('sync_log' => 'on'); + $config->set('sync_log_channels' => 'squatter'); + + if (cyrus_version_supports_jmap()) { + $config->set(httpmodules => 'jmap'); + + return $class->SUPER::new({ + config => $config, + adminstore => 1, + squatter => 1, + services => ['imap', 'http'], + }, @_); + } + else { + return $class->SUPER::new({}, @_); + } +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# n.b. similar to _skip_version() in Cassandane::Unit::TestCase +sub skip_before +{ + my ($lim) = @_; + + my ($lim_major, $lim_minor, $lim_revision, $lim_commits) + = map { 0 + $_ } split /\./, $lim; + return if not defined $lim_major; + + my ($major, $minor, $revision, $commits) = + Cassandane::Instance->get_version(); + + return 1 if $major < $lim_major; # too old, skip! + return if $major > $lim_major; # definitely new enough + + return if not defined $lim_minor; # don't check deeper if caller doesn't care + return 1 if $minor < $lim_minor; + return if $minor > $lim_minor; + + return if not defined $lim_revision; + return 1 if $revision < $lim_revision; + + return if not defined $lim_commits; + return 1 if $commits < $lim_commits; + + return; +} + +sub find_tests +{ + my ($dir) = @_; + + my @tests; + + find( + sub { + my $file = $File::Find::name; + + return unless $file =~ s/\.t$//; + return unless -f "$file.t"; + $file =~ s/^$basedir\/?//; + $file =~ s{/}{:}g; + return if $notcyrus{$file}; + return if $suppressed{$file}; + if (exists $notbefore{$file} and skip_before($notbefore{$file})) { + return; + } + push @tests, "test_$file"; + }, + $dir, + ); + + return @tests; +} + +sub list_tests +{ + my @tests; + + if (!cyrus_version_supports_jmap()) + { + return ( 'test_jmaptest_disabled' ); + } + + if (!defined $basedir) + { + return ( 'test_warning_jmaptestsuite_is_not_installed' ); + } + + @tests = find_tests($testdir); + + if ($ENV{AUTHOR_TESTING}) { + push @tests, find_tests($authortestdir); + } + + return @tests; +} + +sub run_test +{ + my ($self) = @_; + + if (!defined $basedir) + { + xlog $self, "JMAP Tests are not enabled. To enabled them, please"; + xlog $self, "install JMAP-TestSuite from https://github.com/fastmail/JMAP-TestSuite"; + xlog $self, "and edit [jmaptestsuite]basedir in cassandane.ini"; + xlog $self, "This is not a failure"; + return; + } + + if (!cyrus_version_supports_jmap()) + { + xlog $self, "The version of Cyrus being tested does not support JMAP"; + xlog $self, "JMAP-TestSuite tests skipped"; + return; + } + + my $name = $self->name(); + $name =~ s/^test_//; + + my $configfile = "$self->{instance}->{basedir}/testerconfig.json"; + my $errfile = $self->{instance}->{basedir} . "/$name.errors"; + my $outfile = $self->{instance}->{basedir} . "/$name.stdout"; + + my $service = $self->{instance}->get_service("http"); + my $imap = $self->{instance}->get_service("imap"); + + local $ENV{JMAP_SERVER_ADAPTER_FILE} = $configfile; + + open(FH, ">$configfile"); + + print FH encode_json({ + adapter => 'Cyrus', + base_uri => 'http://' . $service->host() . ':' . $service->port() . '/', + cyrus_host => $imap->host(), + cyrus_port => $imap->port(), + cyrus_admin_user => 'admin', + cyrus_admin_pass => 'testpw', + no_sasl => 1, + credentials => [ + { + username => 'cassandane', + password => 'pass', + }, + ], + cyrus_hierarchy_separator => '.', + cyrus_prefix => $self->{instance}->{cyrus_prefix} + }); + close(FH); + + my $status = 0; + + $name =~ s{:}{/}g; + + local $ENV{JMTS_TEST_OUTPUT_TO_STDERR} = 1 if get_verbose; + local $ENV{JMTS_TELEMETRY} = 1 if get_verbose >= 3; + local $ENV{JMTS_USE_WEBSOCKETS} = 0; + + $self->{instance}->run_command({ + redirects => { stderr => $errfile, stdout => $outfile }, + workingdir => $basedir, + handlers => { + exited_normally => sub { $status = 1; }, + exited_abnormally => sub { $status = 0; }, + }, + }, + "perl", '-I' => "$basedir/lib", + "$basedir/$name.t", + ); + + if ((!$status || get_verbose)) { + if (-f $errfile) { + open FH, '<', $errfile + or die "Cannot open $errfile for reading: $!"; + while (readline FH) { + chomp; + xlog $_; + } + close FH; + } + } + + $self->assert($status); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPTestSuiteWS.pm b/cassandane/Cassandane/Cyrus/JMAPTestSuiteWS.pm new file mode 100644 index 0000000000..72f4a621df --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPTestSuiteWS.pm @@ -0,0 +1,337 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPTestSuiteWS; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use JSON::XS qw(encode_json); +use File::Find; +use Module::Load::Conditional qw(check_install); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Cassini; + +my $basedir; +my $binary; +my $testdir; +my $authortestdir; +my %suppressed; + +# Tests for behaviour that has changed over time, where older releases +# contain the older behaviour and can no longer pass the updated test. +# The value is the first version number that is supposed to work. +# Same as the :min_version annotations on native Cassandane tests, but +# dot-separated. See skip_before() below for implementation details. +my %notbefore = ( + 't:Email:get:header-header-field-name' => '3.5', + 't:Email:import:good-imports' => '3.8', + 't:Email:import:one-fails-another-succeeds' => '3.8', +); + +# Tests which JMAPTestSuite will skip anyway when it detects it's +# talking to Cyrus. +my %notcyrus = map { $_ => 1 } ( + 't:Mailbox:get:no-existing-entities', + 't:Mailbox:query:no-existing-entities', +); + +sub cyrus_version_supports_jmap +{ + my ($maj, $min) = Cassandane::Instance->get_version(); + + return 0 if ($maj < 3); # not supported before 3.x + return 0 if ($maj == 3 && $min == 0); # not supported in 3.0.x + + # not supported if configured out + my $buildinfo = Cassandane::BuildInfo->new(); + return 0 if not $buildinfo->get('component', 'jmap'); + + return 1; # supported in everything newer +} + +sub have_jmap_tester_websocket +{ + # not supported if feature wasn't compiled in + my $buildinfo = Cassandane::BuildInfo->new(); + return 0 if not $buildinfo->get('dependency', 'wslay'); + + return defined check_install(module => 'JMAP::Tester::WebSocket'); +} + +sub init +{ + my $cassini = Cassandane::Cassini->instance(); + $basedir = $cassini->val('jmaptestsuite', 'basedir'); + return unless defined $basedir; + $basedir = abs_path($basedir); + + my $supp = $cassini->val('jmaptestsuite', 'suppress', ''); + map { $suppressed{$_} = 1; } split(/\s+/, $supp); + + $testdir = "$basedir/t"; + $authortestdir = "$basedir/xt"; +} +init; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(servername => "127.0.0.1"); # urlauth needs matching servername + $config->set(virtdomains => 'userid'); + $config->set(caldav_realm => 'Cassandane'); + $config->set(httpallowcompress => 'no'); + $config->set(conversations => 'yes'); + + $config->set(search_engine => 'xapian'); + $config->set(search_index_headers => 'no'); + $config->set(search_batchsize => 8192); + $config->set(defaultpartition => 'default'); + $config->set(defaultsearchtier => 't1'); + + $config->set('sync_log' => 'on'); + $config->set('sync_log_channels' => 'squatter'); + + if (cyrus_version_supports_jmap()) { + $config->set(httpmodules => 'jmap'); + + return $class->SUPER::new({ + config => $config, + adminstore => 1, + squatter => 1, + services => ['imap', 'http'], + }, @_); + } + else { + return $class->SUPER::new({}, @_); + } +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# n.b. similar to _skip_version() in Cassandane::Unit::TestCase +sub skip_before +{ + my ($lim) = @_; + + my ($lim_major, $lim_minor, $lim_revision, $lim_commits) + = map { 0 + $_ } split /\./, $lim; + return if not defined $lim_major; + + my ($major, $minor, $revision, $commits) = + Cassandane::Instance->get_version(); + + return 1 if $major < $lim_major; # too old, skip! + return if $major > $lim_major; # definitely new enough + + return if not defined $lim_minor; # don't check deeper if caller doesn't care + return 1 if $minor < $lim_minor; + return if $minor > $lim_minor; + + return if not defined $lim_revision; + return 1 if $revision < $lim_revision; + + return if not defined $lim_commits; + return 1 if $commits < $lim_commits; + + return; +} + +sub find_tests +{ + my ($dir) = @_; + + my @tests; + + find( + sub { + my $file = $File::Find::name; + + return unless $file =~ s/\.t$//; + return unless -f "$file.t"; + $file =~ s/^$basedir\/?//; + $file =~ s{/}{:}g; + return if $notcyrus{$file}; + return if $suppressed{$file}; + if (exists $notbefore{$file} and skip_before($notbefore{$file})) { + return; + } + push @tests, "test_$file"; + }, + $dir, + ); + + return @tests; +} + +sub list_tests +{ + my @tests; + + if (!cyrus_version_supports_jmap() || !have_jmap_tester_websocket()) + { + return ( 'test_jmaptest_websocket_disabled' ); + } + + if (!defined $basedir) + { + return ( 'test_warning_jmaptestsuite_is_not_installed' ); + } + + @tests = find_tests($testdir); + + if ($ENV{AUTHOR_TESTING}) { + push @tests, find_tests($authortestdir); + } + + return @tests; +} + +sub run_test +{ + my ($self) = @_; + + if (!defined $basedir) + { + xlog $self, "JMAP Tests are not enabled. To enabled them, please"; + xlog $self, "install JMAP-TestSuite from https://github.com/fastmail/JMAP-TestSuite"; + xlog $self, "and edit [jmaptestsuite]basedir in cassandane.ini"; + xlog $self, "This is not a failure"; + return; + } + + if (!cyrus_version_supports_jmap()) + { + xlog $self, "The version of Cyrus being tested does not support JMAP"; + xlog $self, "JMAP-TestSuite WebSockets tests skipped"; + return; + } + + if (!have_jmap_tester_websocket()) + { + xlog $self, "The JMAP::Tester::WebSockets module is not available"; + xlog $self, "JMAP-TestSuite WebSockets tests skipped"; + return; + } + + my $name = $self->name(); + $name =~ s/^test_//; + + my $configfile = "$self->{instance}->{basedir}/testerconfig.json"; + my $errfile = $self->{instance}->{basedir} . "/$name.errors"; + my $outfile = $self->{instance}->{basedir} . "/$name.stdout"; + + my $service = $self->{instance}->get_service("http"); + my $imap = $self->{instance}->get_service("imap"); + + local $ENV{JMAP_SERVER_ADAPTER_FILE} = $configfile; + + open(FH, ">$configfile"); + + print FH encode_json({ + adapter => 'Cyrus', + base_uri => 'http://' . $service->host() . ':' . $service->port() . '/', + cyrus_host => $imap->host(), + cyrus_port => $imap->port(), + cyrus_admin_user => 'admin', + cyrus_admin_pass => 'testpw', + no_sasl => 1, + credentials => [ + { + username => 'cassandane', + password => 'pass', + }, + ], + cyrus_hierarchy_separator => '.', + cyrus_prefix => $self->{instance}->{cyrus_prefix} + }); + close(FH); + + my $status = 0; + + $name =~ s{:}{/}g; + + local $ENV{JMTS_TEST_OUTPUT_TO_STDERR} = 1 if get_verbose; + local $ENV{JMTS_TELEMETRY} = 1 if get_verbose >= 3; + local $ENV{JMTS_USE_WEBSOCKETS} = 1; + + $self->{instance}->run_command({ + redirects => { stderr => $errfile, stdout => $outfile }, + workingdir => $basedir, + handlers => { + exited_normally => sub { $status = 1; }, + exited_abnormally => sub { $status = 0; }, + }, + }, + "perl", '-I' => "$basedir/lib", + "$basedir/$name.t", + ); + + if ((!$status || get_verbose)) { + if (-f $errfile) { + open FH, '<', $errfile + or die "Cannot open $errfile for reading: $!"; + while (readline FH) { + chomp; + xlog $_; + } + close FH; + } + } + + $self->assert($status); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/JMAPVacationResponse.pm b/cassandane/Cassandane/Cyrus/JMAPVacationResponse.pm new file mode 100644 index 0000000000..f734ec5f0e --- /dev/null +++ b/cassandane/Cassandane/Cyrus/JMAPVacationResponse.pm @@ -0,0 +1,212 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2023 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::JMAPVacationResponse; +use strict; +use warnings; +use DateTime; +use JSON; +use JSON::XS; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use File::Basename; +use IO::File; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj == 3 && $min == 0) { + # need to explicitly add 'body' to sieve_extensions for 3.0 + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags mailbox mboxmetadata servermetadata variables " . + "body"); + } + elsif ($maj < 3) { + # also for 2.5 (the earliest Cyrus that Cassandane can test) + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags body"); + } + + $config->set(caldav_realm => 'Cassandane', + conversations => 'yes', + httpmodules => 'jmap', + httpallowcompress => 'no'); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + deliver => 1, + adminstore => 1, + services => [ 'imap', 'sieve', 'http' ] + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:vacationresponse' + ]); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_vacation_get_none + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "get vacation"; + my $res = $jmap->CallMethods([ + ['VacationResponse/get', { + properties => ['isEnabled'] + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('VacationResponse/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('singleton', $res->[0][1]{list}[0]{id}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]{isEnabled}); + $self->assert(not exists $res->[0][1]{list}[0]{subject}); +} + +sub test_vacation_set + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "attempt to create a new vacation response"; + my $res = $jmap->CallMethods([ + ['VacationResponse/set', { + create => { + "1" => { + textBody => "Gone fishing" + } + } + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('VacationResponse/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_str_equals('singleton', $res->[0][1]{notCreated}{1}{type}); + + xlog "enable the vacation response"; + $res = $jmap->CallMethods([ + ['VacationResponse/set', { + update => { + "singleton" => { + isEnabled=> JSON::true, + textBody => "Gone fishing" + } + } + }, "R1"], + ['VacationResponse/get', { + }, "R2"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('VacationResponse/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{singleton}); + $self->assert_str_equals('VacationResponse/get', $res->[1][0]); + $self->assert_str_equals('R2', $res->[1][2]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_str_equals('singleton', $res->[1][1]{list}[0]{id}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{isEnabled}); + $self->assert_str_equals('Gone fishing', $res->[1][1]{list}[0]{textBody}); + + xlog "disable the vacation response"; + $res = $jmap->CallMethods([ + ['VacationResponse/set', { + update => { + "singleton" => { + isEnabled=> JSON::false + } + } + }, "R1"], + ['VacationResponse/get', { + }, "R2"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('VacationResponse/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{singleton}); + $self->assert_str_equals('VacationResponse/get', $res->[1][0]); + $self->assert_str_equals('R2', $res->[1][2]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_str_equals('singleton', $res->[1][1]{list}[0]{id}); + $self->assert_equals(JSON::false, $res->[1][1]{list}[0]{isEnabled}); + $self->assert_str_equals('Gone fishing', $res->[1][1]{list}[0]{textBody}); + + xlog "attempt to destroy the vacation response"; + $res = $jmap->CallMethods([ + ['VacationResponse/set', { + destroy => ["singleton"] + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('VacationResponse/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_str_equals('singleton', + $res->[0][1]{notDestroyed}{singleton}{type}); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/LDAP.pm b/cassandane/Cassandane/Cyrus/LDAP.pm new file mode 100644 index 0000000000..80c7e4b4e6 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/LDAP.pm @@ -0,0 +1,354 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2018 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +# XXX Most of these tests are tagged with :min_version_3_0_8, as +# XXX the architecture used for testing LDAP depends on the fix +# XXX for https://github.com/cyrusimap/cyrus-imapd/issues/2282 + +package Cassandane::Cyrus::LDAP; +use strict; +use warnings; +use Cwd qw(realpath); +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set( + ldap_base => "o=cyrus", + ldap_group_base => "ou=groups,o=cyrus", + ldap_domain_base_dn => "ou=domains,o=cyrus", + ldap_user_attribute => "uid", + ldap_member_attribute => "memberof", + ldap_group_hasmember_attribute => "hasmember", + ldap_sasl => "no", + auth_mech => 'pts', + pts_module => 'ldap', + ptloader_sock => '@basedir@/conf/ptsock', + ); + + my $self = $class->SUPER::new({ + config => $config, + adminstore => 1, + services => [qw( imap ptloader )], + start_instances => 0, + }, @args); + + return $self; +} + +sub set_up +{ + my ($self) = @_; + + $self->SUPER::set_up(); + + $self->{ldapport} = Cassandane::PortManager::alloc("localhost"); + + $self->{instance}->{config}->set( + ldap_uri => "ldap://localhost:$self->{ldapport}/", + ); + + # arrange for the fakeldapd to be started + my ($maj, $min) = Cassandane::Instance->get_version($self->{installation}); + if ($maj < 3 || ($maj == 3 && $min < 4)) { + $self->{instance}->add_start( + name => 'fakeldapd', + argv => [ + realpath('utils/fakeldapd'), + '-p', $self->{ldapport}, + '-l', realpath('data/directory.ldif'), + ], + ); + } + elsif (not exists $self->{daemons}->{fakeldapd}) { + $self->{instance}->add_daemon( + name => 'fakeldapd', + argv => [ + realpath('utils/fakeldapd'), + '-p', $self->{ldapport}, + '-l', realpath('data/directory.ldif'), + ], + wait => 'y', + ); + } + + $self->_start_instances(); + $self->{instance}->create_user("otheruser"); +} + +sub tear_down +{ + my ($self) = @_; + + $self->SUPER::tear_down(); +} + +sub test_alternate_ptscache_db_path + :needs_dependency_ldap :min_version_3_0_8 :AltPTSDBPath +{ + my ($self) = @_; + + # just interact with the store, and it should work + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->list('user.cassandane', '*'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $confdir = $self->{instance}->{basedir} . "/conf"; + $self->assert_file_test($confdir . "/non-default-ptscache.db"); + $self->assert_not_file_test($confdir . "/ptclient/ptscache.db"); +} + +sub test_setacl_groupid + :needs_dependency_ldap :min_version_3_0_8 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.cassandane.groupid"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.groupid", + "group:foo", + "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); +} + +sub test_setacl_groupid_spaces + :needs_dependency_ldap :min_version_3_0_8 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.groupid_spaces", + "group:this group name has spaces", + "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $data = $admintalk->getacl("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $self->assert(scalar @{$data} % 2 == 0); + my %acl = @{$data}; + $self->assert_str_equals($acl{"group:this group name has spaces"}, + "lrswipkxtecdan"); + + $admintalk->select("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); +} + +sub test_list_groupaccess_noracl + :needs_dependency_ldap :min_version_3_0_8 :NoAltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $admintalk->create("user.otheruser.groupaccess"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.otheruser.groupaccess", + "group:group co", "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $list = $imaptalk->list("", "*"); + my @boxes = sort map { $_->[2] } @{$list}; + + $self->assert_deep_equals(\@boxes, + ['INBOX', 'user.otheruser.groupaccess']); +} + +sub test_list_groupaccess_racl + :needs_dependency_ldap :ReverseACLs :min_version_3_1 :NoAltNamespace :Conversations +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $admintalk->create("user.otheruser.groupaccess"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $precounters = $self->{store}->get_counters(); + + $admintalk->setacl("user.otheruser.groupaccess", + "group:group co", "lrswipkxtecdn"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $postcounters = $self->{store}->get_counters(); + $self->assert_num_not_equals($precounters->{raclmodseq}, $postcounters->{raclmodseq}, "RACL modseq changed"); + + if (get_verbose()) { + $self->{instance}->run_command( + { cyrus => 1, }, + 'cyr_dbtool', + "$self->{instance}->{basedir}/conf/mailboxes.db", + 'twoskip', + 'show' + ); + } + + my $list = $imaptalk->list("", "*"); + my @boxes = sort map { $_->[2] } @{$list}; + + $self->assert_deep_equals(\@boxes, + ['INBOX', 'user.otheruser.groupaccess']); +} + +sub do_test_list_order +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.zzz"); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); + + $imaptalk->create("INBOX.aaa"); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); + + my %adminfolders = ( + 'user.otheruser.order-user' => 'cassandane', + 'user.otheruser.order-co' => 'group:group co', + 'user.otheruser.order-c' => 'group:group c', + 'user.otheruser.order-o' => 'group:group o', + 'shared.order-co' => 'group:group co', + 'shared.order-c' => 'group:group c', + 'shared.order-o' => 'group:group o', + ); + + while (my ($folder, $identifier) = each %adminfolders) { + $admintalk->create($folder); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "created folder $folder successfully"); + + $admintalk->setacl($folder, $identifier, "lrswipkxtecdn"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "setacl folder $folder for $identifier successfully"); + + if ($folder =~ m/^shared/) { + # subvert default permissions on shared namespace for + # purpose of testing ordering + $admintalk->setacl($folder, "anyone", "p"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "setacl folder $folder for anyone successfully"); + } + } + + if (get_verbose()) { + $self->{instance}->run_command( + { cyrus => 1, }, + 'cyr_dbtool', + "$self->{instance}->{basedir}/conf/mailboxes.db", + 'twoskip', + 'show' + ); + } + + my $list = $imaptalk->list("", "*"); + my @boxes = map { $_->[2] } @{$list}; + + # Note: order is + # * mine, alphabetically, + # * other users', alphabetically, + # * shared, alphabetically + # ... which is not the order we created them ;) + # Also, the "order-o" folders are not returned, because cassandane + # is not a member of that group + my @expect = qw( + INBOX + INBOX.aaa + INBOX.zzz + user.otheruser.order-c + user.otheruser.order-co + user.otheruser.order-user + ); + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min > 4)) { + push @expect, qw(shared); + } + push @expect, qw( shared.order-c shared.order-co ); + $self->assert_deep_equals(\@boxes, \@expect); +} + +sub test_list_order_noracl + :needs_dependency_ldap :min_version_3_0_8 :NoAltNamespace +{ + my $self = shift; + return $self->do_test_list_order(@_); +} + +sub test_list_order_racl + :needs_dependency_ldap :ReverseACLs :min_version_3_1 :NoAltNamespace +{ + my $self = shift; + return $self->do_test_list_order(@_); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/List.pm b/cassandane/Cassandane/Cyrus/List.pm new file mode 100644 index 0000000000..1a02c04de5 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/List.pm @@ -0,0 +1,159 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::List; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Generator; +use Cassandane::MessageStoreFactory; +use Cassandane::Instance; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + + return $class->SUPER::new({ config => $config, adminstore => 1 }, @args); +} + +sub set_up +{ + my ($self) = @_; + + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + + $self->SUPER::tear_down(); +} + +# tests based on rfc 5258 examples: +# https://tools.ietf.org/html/rfc5258#section-5 + +# TODO not sure how to set up test data for remote mailboxes... +#sub test_rfc5258_ex04_remote_children +#{ +# my ($self) = @_; +# $self->assert(0, 'FIXME test not implemented'); +#} + +#sub test_rfc5258_ex05_remote_subscribed +#{ +# my ($self) = @_; +# $self->assert(0, 'FIXME test not implemented'); +#} + +#sub test_rfc5258_ex06_remote_return_subscribed +#{ +# my ($self) = @_; +# $self->assert(0, 'FIXME test not implemented'); +#} + +#sub test_rfc5258_ex09_childinfo +#{ +# my ($self) = @_; +# $self->assert(0, 'FIXME test not implemented'); +#} + +#sub test_rfc5258_ex10_multiple_mailbox_patterns_childinfo +#{ +# my ($self) = @_; +# $self->assert(0, 'FIXME test not implemented'); +#} + +#sub test_rfc5258_ex11_missing_hierarchy_elements +#{ +# my ($self) = @_; +# $self->assert(0, 'FIXME test not implemented'); +#} + +# tests based on rfc 6154 examples: +# https://tools.ietf.org/html/rfc6154#section-5 + +# "An IMAP server that supports this extension MAY include any or all of the +# following attributes in responses to the non-extended IMAP LIST command." +# +# Cyrus does not (at least, not at the moment), so this test is disabled. +sub bogus_test_rfc6154_ex01_list_non_extended + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( ToDo Projects Projects/Foo SentMail MyDrafts Trash) ] ], + ]); + + $imaptalk->setmetadata("SentMail", "/private/specialuse", "\\Sent"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("MyDrafts", "/private/specialuse", "\\Drafts"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("Trash", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + my $alldata = $imaptalk->list("", "%"); + + $self->assert_mailbox_structure($alldata, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'ToDo' => [qw( \\HasNoChildren )], + 'Projects' => [qw( \\HasChildren )], + 'SentMail' => [qw( \\Sent \\HasNoChildren )], + 'MyDrafts' => [qw( \\Drafts \\HasNoChildren )], + 'Trash' => [qw( \\Trash \\HasNoChildren )], + }); +} + +use Cassandane::Tiny::Loader 'tiny-tests/List'; + +1; diff --git a/cassandane/Cassandane/Cyrus/Lsub.pm b/cassandane/Cassandane/Cyrus/Lsub.pm new file mode 100644 index 0000000000..4ec07e561e --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Lsub.pm @@ -0,0 +1,224 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Lsub; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + my $admintalk = $self->{adminstore}->get_client(); + + # Right - let's create ourselves some users and subscriptions + # sub folders of the main user + $admintalk->create("user.cassandane.asub"); + $admintalk->create("user.cassandane.asub.deeper"); + + # sub folders of another user - one is subscribable + $self->{instance}->create_user("other", + subdirs => [ 'sub', ['sub', 'folder'] ]); + $admintalk->setacl("user.other.sub.folder", "cassandane", "lrs"); + + my $usertalk = $self->{store}->get_client(); + $usertalk->subscribe("INBOX"); + $usertalk->subscribe("INBOX.asub"); + $usertalk->subscribe("user.other.sub.folder"); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test LSUB behaviour +# +sub test_lsub_toplevel + :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $alldata = $imaptalk->lsub("", "*"); + $self->assert_deep_equals($alldata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX' + ], + [ + [], + '.', + 'INBOX.asub' + ], + [ + [], + '.', + 'user.other.sub.folder' + ] + ], "LSUB all data mismatch: " . Dumper($alldata)); + + my $topdata = $imaptalk->lsub("", "%"); + $self->assert_deep_equals($topdata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX' + ], + [ + [ + '\\Noselect', + '\\HasChildren' + ], + '.', + 'user' + ], + ], "LSUB top data mismatch:" . Dumper($topdata)); +} + +sub test_lsub_delete +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.deltest") || die; + $imaptalk->create("INBOX.deltest.sub1") || die; + $imaptalk->create("INBOX.deltest.sub2") || die; + $imaptalk->subscribe("INBOX.deltest") || die; + $imaptalk->subscribe("INBOX.deltest.sub2") || die; + my $subdata = $imaptalk->lsub("INBOX.deltest", "*"); + $self->assert_deep_equals($subdata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX.deltest' + ], + [ + [], + '.', + 'INBOX.deltest.sub2' + ], + ], "LSUB deltest setup mismatch: " . Dumper($subdata)); + + $imaptalk->delete("INBOX.deltest.sub2"); + my $onedata = $imaptalk->lsub("INBOX.deltest", "*"); + $self->assert_deep_equals($onedata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX.deltest' + ], + ], "LSUB deltest.sub2 after delete mismatch: " . Dumper($onedata)); +} + +sub test_lsub_extrachild + :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Test") || die; + $imaptalk->create("INBOX.Test.Sub") || die; + $imaptalk->create("INBOX.Test Foo") || die; + $imaptalk->create("INBOX.Test Bar") || die; + $imaptalk->subscribe("INBOX.Test") || die; + $imaptalk->subscribe("INBOX.Test.Sub") || die; + $imaptalk->subscribe("INBOX.Test Foo") || die; + $imaptalk->delete("INBOX.Test.Sub") || die; + my $subdata = $imaptalk->lsub("", "*"); + $self->assert_deep_equals($subdata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX' + ], + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX.Test' + ], + [ + [], + '.', + 'INBOX.Test Foo' + ], + [ + [], + '.', + 'INBOX.asub' + ], + [ + [], + '.', + 'user.other.sub.folder' + ], + ], "LSUB extrachild mismatch: " . Dumper($subdata)); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Master.pm b/cassandane/Cassandane/Cyrus/Master.pm new file mode 100644 index 0000000000..eca56b1be3 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Master.pm @@ -0,0 +1,1405 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Master; +use strict; +use warnings; +use File::stat; +use POSIX qw(getcwd); +use DateTime; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Wait; +use Cassandane::Util::Socket; +use Cassandane::Util::Sample; +use Cassandane::Util::Metronome; +use Cassandane::Instance; +use Cassandane::Service; +use Cassandane::Config; + +my $lemming_bin = getcwd() . '/utils/lemming'; + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new({ instance => 0 }, @_); + + return $self; +} + +sub set_up +{ + my ($self) = @_; + die "No lemming binary $lemming_bin. Did you run \"make\" in the Cassandane directory?" + unless (-f $lemming_bin); + $self->SUPER::set_up(); + $self->{instance} = Cassandane::Instance->new(setup_mailbox => 0, + authdaemon => 0); +} + +sub tear_down +{ + my ($self) = @_; + $self->lemming_cull(); + $self->SUPER::tear_down(); +} + +sub lemming_connect +{ + my ($srv, $address_family) = @_; + + my $sock = create_client_socket( + defined($address_family) ? $address_family : $srv->address_family(), + $srv->host(), $srv->port()) + or die "Cannot connect to lemming " . $srv->address() . ": $@"; + + # The lemming sends us his PID so we can later wait for him to die + # properly. It's easiest for synchronisation purposes to encode + # this as a fixed sized field. + my $pid; + $sock->sysread($pid, 4) + or die "Cannot read from lemming: " . $srv->address() . " $!"; + $pid = unpack("L", $pid); + die "Cannot read from lemming: $!" + unless defined $pid; + + return { sock => $sock, pid => $pid }; +} + +sub lemming_push +{ + my ($lemming, $mode) = @_; + +# xlog $self, "Pushing mode=$mode to pid=$lemming->{pid}"; + + # Push the lemming over the metaphorical cliff. + $lemming->{sock}->syswrite($mode . "\r\n"); + $lemming->{sock}->close(); + + # Wait for the master process to wake up and reap the lemming. + return timed_wait(sub { kill(0, $lemming->{pid}) == 0 }, + description => "master to reap lemming $lemming->{pid}"); +} + +sub lemming_census +{ + my ($self) = @_; + my $coresdir = $self->{instance}->{basedir} . '/conf/cores'; + + my %pids; + opendir LEMM,$coresdir + or die "cannot open $coresdir for reading: $!"; + while ($_ = readdir LEMM) + { + my ($tag, $pid) = m/^lemming\.(\w+).(\d+)$/; + next + unless defined $pid; + xlog $self, "found lemming tag=$tag pid=$pid"; + $pids{$tag} = [] + unless defined $pids{$tag}; + push (@{$pids{$tag}}, $pid); + } + closedir LEMM; + + my %actual; + foreach my $tag (keys %pids) + { + my $ntotal = scalar @{$pids{$tag}}; + my $nlive = kill(0, @{$pids{$tag}}); + $actual{$tag} = { + live => $nlive, + dead => $ntotal - $nlive, + }; + } + return \%actual; +} + +sub lemming_cull +{ + my ($self) = @_; + return unless defined $self->{instance}; + my $coresdir = $self->{instance}->{basedir} . '/conf/cores'; + + return unless -d $coresdir; + opendir LEMM,$coresdir + or die "cannot open $coresdir for reading: $!"; + while ($_ = readdir LEMM) + { + my ($tag, $pid) = m/^lemming\.(\w+).(\d+)$/; + next + unless defined $pid; + xlog $self, "culled lemming tag=$tag pid=$pid" + if kill(9, $pid); + } + closedir LEMM; +} + +sub _lemming_args +{ + my (%params) = @_; + + my $tag = delete $params{tag} || 'A'; + my $mode = delete $params{mode} || 'serve'; + my $delay = delete $params{delay}; + + my @argv = ( $lemming_bin, '-t', $tag, '-m', $mode ); + push(@argv, '-d', $delay) if defined $delay; + + return (name => $tag, argv => \@argv, %params); +} + +sub lemming_service +{ + my ($self, %params) = @_; + return $self->{instance}->add_service(_lemming_args(%params)); +} + +sub lemming_start +{ + my ($self, %params) = @_; + return $self->{instance}->add_start(_lemming_args(%params)); +} + +sub lemming_event +{ + my ($self, %params) = @_; + return $self->{instance}->add_event(_lemming_args(%params)); +} + +sub lemming_wait +{ + my ($self, %expected_census) = @_; + + timed_wait( + sub + { + my $census = $self->lemming_census(); + map { + my $service_name = $_; + return 0 if !defined $census->{$service_name}; + my $expected = $expected_census{$service_name}; + map { + return 0 if $census->{$service_name}->{$_} != $expected->{$_}; + } keys %$expected; + } keys %expected_census; + return 1; + }, + description => "lemmings to reach the expected census"); +} + +sub start +{ + my ($self) = @_; + $self->{instance}->start(); +} + +# +# Test a single running programs in SERVICES +# +sub test_service +{ + my ($self) = @_; + + xlog $self, "single successful service"; + my $srv = $self->lemming_service(); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + my $lemm = lemming_connect($srv); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 1 } }, + $self->lemming_census()); +} + +# +# Test multiple connections to a single running program in SERVICES +# +sub test_multi_connections +{ + my ($self) = @_; + + xlog $self, "multiple connections to a single successful service"; + my $srv = $self->lemming_service(); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + my $lemm1 = lemming_connect($srv); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + my $lemm2 = lemming_connect($srv); + + xlog $self, "two connected so two lemmings forked"; + $self->assert_deep_equals({ A => { live => 2, dead => 0 } }, + $self->lemming_census()); + + my $lemm3 = lemming_connect($srv); + + xlog $self, "three connected so three lemmings forked"; + $self->assert_deep_equals({ A => { live => 3, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemm1, 'success'); + lemming_push($lemm2, 'success'); + lemming_push($lemm3, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 3 } }, + $self->lemming_census()); +} + +# +# Test multiple running programs in SERVICES +# +sub test_multi_services +{ + my ($self) = @_; + + xlog $self, "multiple successful services"; + my $srvA = $self->lemming_service(tag => 'A'); + my $srvB = $self->lemming_service(tag => 'B'); + my $srvC = $self->lemming_service(tag => 'C'); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + my $lemmA = lemming_connect($srvA); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + my $lemmB = lemming_connect($srvB); + + xlog $self, "two connected so two lemmings forked"; + $self->assert_deep_equals({ + A => { live => 1, dead => 0 }, + B => { live => 1, dead => 0 }, + }, $self->lemming_census()); + + my $lemmC = lemming_connect($srvC); + + xlog $self, "three connected so three lemmings forked"; + $self->assert_deep_equals({ + A => { live => 1, dead => 0 }, + B => { live => 1, dead => 0 }, + C => { live => 1, dead => 0 }, + }, $self->lemming_census()); + + lemming_push($lemmA, 'success'); + lemming_push($lemmB, 'success'); + lemming_push($lemmC, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ + A => { live => 0, dead => 1 }, + B => { live => 0, dead => 1 }, + C => { live => 0, dead => 1 }, + }, $self->lemming_census()); +} + +# +# Test a preforked single running program in SERVICES +# +sub test_prefork +{ + my ($self) = @_; + + xlog $self, "single successful service"; + my $srv = $self->lemming_service(prefork => 1); + $self->start(); + $self->lemming_wait(A => { live => 1 }); + + xlog $self, "preforked, so one lemming running already"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + my $lemm1 = lemming_connect($srv); + $self->lemming_wait(A => { live => 2 }); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 2, dead => 0 } }, + $self->lemming_census()); + + my $lemm2 = lemming_connect($srv); + $self->lemming_wait(A => { live => 3 }); + + xlog $self, "connected again so two additional lemmings forked"; + $self->assert_deep_equals({ A => { live => 3, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemm1, 'success'); + lemming_push($lemm2, 'success'); + + xlog $self, "always at least one live lemming"; + $self->assert_deep_equals({ A => { live => 1, dead => 2 } }, + $self->lemming_census()); +} + +# +# Test multiple running programs in SERVICES, some preforked. +# +sub test_multi_prefork +{ + my ($self) = @_; + + xlog $self, "multiple successful service some preforked"; + my $srvA = $self->lemming_service(tag => 'A', prefork => 2); + my $srvB = $self->lemming_service(tag => 'B'); # no preforking + my $srvC = $self->lemming_service(tag => 'C', prefork => 3); + $self->start(); + + # wait for lemmings to be preforked + $self->lemming_wait(A => { live => 2 }, C => { live => 3 }); + + my @lemmings; + my $lemm; + + xlog $self, "connect to A once"; + $lemm = lemming_connect($srvA); + $self->lemming_wait(A => { live => 3 }); + push(@lemmings, $lemm); + $self->assert_deep_equals({ + A => { live => 3, dead => 0 }, + C => { live => 3, dead => 0 }, + }, $self->lemming_census()); + + xlog $self, "connect to A again"; + $lemm = lemming_connect($srvA); + $self->lemming_wait(A => { live => 4 }); + push(@lemmings, $lemm); + $self->assert_deep_equals({ + A => { live => 4, dead => 0 }, + C => { live => 3, dead => 0 }, + }, $self->lemming_census()); + + xlog $self, "connect to A a third time"; + $lemm = lemming_connect($srvA); + $self->lemming_wait(A => { live => 5 }); + push(@lemmings, $lemm); + $self->assert_deep_equals({ + A => { live => 5, dead => 0 }, + C => { live => 3, dead => 0 }, + }, $self->lemming_census()); + + xlog $self, "connect to B"; + $lemm = lemming_connect($srvB); + push(@lemmings, $lemm); + $self->assert_deep_equals({ + A => { live => 5, dead => 0 }, + B => { live => 1, dead => 0 }, + C => { live => 3, dead => 0 }, + }, $self->lemming_census()); + + foreach $lemm (@lemmings) + { + lemming_push($lemm, 'success'); + } + + xlog $self, "our lemmings are gone, others have replaced them"; + $self->assert_deep_equals({ + A => { live => 2, dead => 3 }, + B => { live => 0, dead => 1 }, + C => { live => 3, dead => 0 }, + }, $self->lemming_census()); +} + +# +# Test a single program in SERVICES which fails after connect +# +sub test_exit_after_connect +{ + my ($self) = @_; + + xlog $self, "single service will exit after connect"; + my $srv = $self->lemming_service(); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + my $lemm = lemming_connect($srv); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + xlog $self, "push the lemming off the cliff"; + lemming_push($lemm, 'exit'); + $self->assert_deep_equals({ A => { live => 0, dead => 1 } }, + $self->lemming_census()); + + xlog $self, "can connect again"; + $lemm = lemming_connect($srv); + $self->assert_deep_equals({ A => { live => 1, dead => 1 } }, + $self->lemming_census()); + + xlog $self, "push the lemming off the cliff"; + lemming_push($lemm, 'exit'); + $self->assert_deep_equals({ A => { live => 0, dead => 2 } }, + $self->lemming_census()); +} + +# +# Test a single program in SERVICES which fails during startup +# +sub test_service_exit_during_start +{ + my ($self) = @_; + my $lemm; + + xlog $self, "single service will exit during startup"; + my $srv = $self->lemming_service(mode => 'exit', delay => 100); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + xlog $self, "connection fails due to dead lemming"; + eval + { + $lemm = lemming_connect($srv); + }; + $self->assert_null($lemm); + + xlog $self, "expect 5 dead lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 5 } }, + $self->lemming_census()); + + xlog $self, "connections should fail because service disabled"; + eval + { + $lemm = lemming_connect($srv); + }; + $self->assert_null($lemm); + $self->assert_deep_equals({ A => { live => 0, dead => 5 } }, + $self->lemming_census()); +} + +sub test_startup +{ + my ($self) = @_; + + xlog $self, "Test a program in the START section"; + $self->lemming_start(tag => 'A', delay => 100, mode => 'success'); + $self->lemming_start(tag => 'B', delay => 200, mode => 'success'); + # This service won't be used + my $srv = $self->lemming_service(tag => 'C'); + $self->start(); + + xlog $self, "expect 2 dead lemmings"; + $self->assert_deep_equals({ + A => { live => 0, dead => 1 }, + B => { live => 0, dead => 1 }, + }, $self->lemming_census()); +} + +sub test_startup_exits +{ + my ($self) = @_; + + xlog $self, "Test a program in the START section which fails"; + $self->lemming_start(tag => 'A', delay => 100, mode => 'exit'); + $self->lemming_start(tag => 'B', delay => 200, mode => 'exit'); + # This service won't be used + my $srv = $self->lemming_service(tag => 'C'); + eval + { + $self->start(); + }; + xlog $self, "start failed (as expected): $@" if $@; + + xlog $self, "master should have exited when first startup failed"; + $self->assert(!$self->{instance}->is_running()); + + xlog $self, "expect 1 dead lemming"; + $self->assert_deep_equals({ + A => { live => 0, dead => 1 }, + }, $self->lemming_census()); +} + +# TODO: test exit during startup with prefork= + +sub test_service_ipv6 +{ + my ($self) = @_; + + xlog $self, "single successful service on IPv6"; + my $srv = $self->lemming_service(host => '::1'); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + my $lemm = lemming_connect($srv); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 1 } }, + $self->lemming_census()); +} + +sub test_service_unix +{ + my ($self) = @_; + + xlog $self, "single successful service on UNIX domain socket"; + my $srv = $self->lemming_service( + host => undef, + port => '@basedir@/conf/socket/lemming.sock'); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + my $lemm = lemming_connect($srv); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 1 } }, + $self->lemming_census()); +} + +sub test_service_nohost +{ + my ($self) = @_; + + xlog $self, "single successful service with a port-only listen="; + my $srv = $self->lemming_service(host => undef); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + my $lemm = lemming_connect($srv); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 1 } }, + $self->lemming_census()); +} + +sub test_service_dup_port +{ + my ($self) = @_; + + xlog $self, "successful two services with listen= "; + xlog $self, "parameters which reference the same IPv4 port"; + my $srvA = $self->lemming_service(tag => 'A'); + my $srvB = $self->lemming_service(tag => 'B', + port => $srvA->port()); + + # master should emit a syslog message like this + # + # Dec 31 14:40:57 enki 0340541/master[26085]: unable to create B + # listener socket: Address already in use + # + # and struggle on. + $self->start(); + + if ($self->{instance}->{have_syslog_replacement}) { + # check syslog for the expected error + my $pat = qr/unable to create (?:A|B) listener socket:/; + my @lines = $self->{instance}->getsyslog($pat); + $self->assert_num_equals(1, scalar @lines); + $self->assert_matches(qr/Address already in use/, $lines[0]); + } + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + my $lemmA = lemming_connect($srvA); + + my $census = $self->lemming_census(); + my ($winner) = keys %$census; # either could be the one that runs + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ $winner => { live => 1, dead => 0 } }, + $self->lemming_census()); + + my $lemmB = lemming_connect($srvB); + + xlog $self, "the port is owned by service A"; + $self->assert_deep_equals({ $winner => { live => 2, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemmA, 'success'); + lemming_push($lemmB, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ $winner => { live => 0, dead => 2 } }, + $self->lemming_census()); +} + +sub test_service_noexe +{ + my ($self) = @_; + + xlog $self, "single service with a non-existant executable"; + my $srvA = $self->lemming_service(tag => 'A'); + my $srvB = $self->{instance}->add_service( + name => 'B', + argv => ['/usr/bin/no-such-exe','--foo','--bar']); + + # master should exit while adding services, with a message + # to syslog like this + # + # Dec 31 15:03:26 enki 0403231/master[26825]: cannot find executable + # for service 'B' + eval + { + $self->start(); + }; + xlog $self, "start failed (as expected): $@" if $@; + + # XXX can't currently check syslog in this case because start() bailed + # XXX out before we would have started reading it... + + xlog $self, "master should have exited when service verification failed"; + $self->assert(!$self->{instance}->is_running()); +} + +sub test_reap_rate +{ + my ($self) = @_; + + xlog $self, "Testing latency after which cyrus reaps dead children"; + + my $max_latency = 1.0; # seconds + + my $srv = $self->lemming_service(tag => 'A'); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + xlog $self, "Build a vast flock of lemmings"; + my @lemmings; + for (1..100) + { + push(@lemmings, lemming_connect($srv)); + } + $self->assert_deep_equals({ + A => { live => 100, dead => 0 }, + }, $self->lemming_census()); + + # This technique avoids having new connections at the + # same time as we're trying to measure reaping latency, + # which can hide racy bugs in the main select() loop. + xlog $self, "Killing all the lemmings one by one"; + my $ss = new Cassandane::Util::Sample; + while (my $lemm = shift @lemmings) + { + my $t = lemming_push($lemm, 'success'); + $self->assert($t < $max_latency, + "Child reap latency is >= $max_latency sec"); + $ss->add($t); + } + xlog $self, "Reap times: $ss"; + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ + A => { live => 0, dead => 100 }, + }, $self->lemming_census()); +} + +sub measure_fork_rate +{ + my ($self, $srv, $rate) = @_; + + my $metronome = Cassandane::Util::Metronome->new(rate => $rate); + my @lemmings; + for (1..100) + { + my $lemm = lemming_connect($srv); + push(@lemmings, $lemm); + $metronome->tick(); + } + + foreach my $lemm (@lemmings) + { + lemming_push($lemm, 'success'); + } + + return $metronome->actual_rate(); +} + +sub test_maxforkrate +{ + my ($self) = @_; + + xlog $self, "Testing enforcement of the maxforkrate= parameter"; + + # A very loose error factor. We don't care too much if the + # enforcement is slightly off, it's a rough resource limit and + # fairness measure not a precise QoS issue. Also, even modest fork + # rates may be difficult to achieve when running under Valgrind, and + # we don't want that to cause the test to fail spuriously. + my $epsilon = 0.2; + my $fast = 10.0; # forks/sec + my $slow = 5.0; # forks/sec + + my $srvA = $self->lemming_service(tag => 'A'); + my $srvB = $self->lemming_service(tag => 'B', maxforkrate => int($slow)); + $self->start(); + + xlog $self, "not preforked, so no lemmings running yet"; + $self->assert_deep_equals({}, + $self->lemming_census()); + + xlog $self, "Test that we can achieve the fast forks rate on the unlimited service"; + my $r = $self->measure_fork_rate($srvA, $fast); + xlog $self, "Actual rate: $r"; + $self->assert($r >= (1.0-$epsilon)*$fast, + "Fork rate too slow, for $r wanted $fast"); + $self->assert($r <= (1.0+$epsilon)*$fast, + "Fork rate too fast, for $r wanted $fast"); + + xlog $self, "Test that the fork rate is limited on the limited service"; + $r = $self->measure_fork_rate($srvB, $fast); + xlog $self, "Actual rate: $r"; + $self->assert($r >= (1.0-$epsilon)*$slow, + "Fork rate too slow, got $r wanted $slow"); + $self->assert($r <= (1.0+$epsilon)*$slow, + "Fork rate too fast, got $r wanted $slow"); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ + A => { live => 0, dead => 100 }, + B => { live => 0, dead => 100 }, + }, $self->lemming_census()); +} + +sub test_periodic_event_slow +{ + my ($self) = @_; + + xlog $self, "Testing regular events"; + + my $srv = $self->lemming_service(tag => 'A'); + # This is the fastest we can schedule events - every 1 minute + # so in the absence of a per-process time machine our test will + # need to run for several real minutes. + $self->lemming_event(tag => 'B', mode => 'success', period => 1); + $self->start(); + + xlog $self, "periodic events run immediately"; + + xlog $self, "waiting 5 mins for events to fire, plus some slop"; + sleep(5*60 + 5); + + $self->assert_deep_equals({ + B => { live => 0, dead => 6 }, + }, $self->lemming_census()); +} + +sub test_service_bad_name +{ + my ($self) = @_; + + xlog $self, "services with bad names (Bug 3654)"; + $self->lemming_service(tag => 'foo'); + $self->lemming_service(tag => 'foo_bar'); + $self->lemming_service(tag => 'foo-baz'); + $self->lemming_service(tag => 'foo&baz'); + + # master should exit while adding services, with a message + # to syslog like this + # + # Mar 21 19:53:21 gnb-desktop 0853201/master[8789]: configuration + # file /var/tmp/cass/0853201/conf/cyrus.conf: bad character '-' in + # name on line 2 + # + eval + { + $self->start(); + }; + xlog $self, "start failed (as expected): $@" if $@; + + # XXX can't currently check syslog in this case because start() bailed + # XXX out before we would have started reading it... + + xlog $self, "master should have exited when service verification failed"; + $self->assert(!$self->{instance}->is_running()); +} + +sub test_service_associate +{ + my ($self) = @_; + + xlog $self, "sending a SIGHUP to a master process with services"; + xlog $self, "whose listen= parameters give more than one result in"; + xlog $self, "getaddrinfo(), such as an IPv4 and IPv6 (Bug 3771)"; + + my $host = 'localhost'; + + $self->lemming_service(tag => 'foo', host => undef); + + $self->{instance}->start(); + $self->{instance}->send_sighup(); + $self->{instance}->stop(); +} + +sub XXX_test_service_primary_fail +{ + my ($self) = @_; + + my $host = 'localhost'; + + my $srv = $self->lemming_service(tag => 'foo', host => undef, mode => 'exit-ipv4/serve'); + + $self->start(); + + xlog $self, "connection fails due to dead IPv4 lemming"; + my $lemm; + eval + { + $lemm = lemming_connect($srv, 'inet'); + }; + $self->assert_null($lemm); + + xlog $self, "expect 5 dead lemmings"; + $self->assert_deep_equals({ foo => { live => 0, dead => 5 } }, + $self->lemming_census()); + + xlog $self, "check the IPv4 service is really dead"; + eval + { + $lemm = lemming_connect($srv, 'inet'); + }; + $self->assert_null($lemm); + $self->assert_deep_equals({ foo => { live => 0, dead => 5 } }, + $self->lemming_census()); + + xlog $self, "breed one IPv6 lemming"; + $lemm = lemming_connect($srv, 'inet6'); + $self->assert_deep_equals({ foo => { live => 1, dead => 5 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ foo => { live => 0, dead => 6 } }, + $self->lemming_census()); + + xlog $self, "revive the dead IPv4 service"; + $self->{instance}->send_sighup(); + + xlog $self, "connection fails again due to dead IPv4 lemming"; + $lemm = undef; + eval + { + $lemm = lemming_connect($srv, 'inet'); + }; + $self->assert_null($lemm); + + xlog $self, "expect 5 more dead lemmings"; + $self->assert_deep_equals({ foo => { live => 0, dead => 11 } }, + $self->lemming_census()); +} + +sub XXX_test_service_associate_fail +{ + my ($self) = @_; + + my $host = 'localhost'; + + my $srv = $self->lemming_service(tag => 'foo', host => undef, mode => 'exit-ipv6/serve'); + + $self->start(); + + xlog $self, "connection fails due to dead IPv6 lemming"; + my $lemm; + eval + { + $lemm = lemming_connect($srv, 'inet6'); + }; + $self->assert_null($lemm); + + xlog $self, "expect 5 dead lemmings"; + $self->assert_deep_equals({ foo => { live => 0, dead => 5 } }, + $self->lemming_census()); + + xlog $self, "check the IPv6 service is really dead"; + eval + { + $lemm = lemming_connect($srv, 'inet6'); + }; + $self->assert_null($lemm); + $self->assert_deep_equals({ foo => { live => 0, dead => 5 } }, + $self->lemming_census()); + + xlog $self, "breed one IPv4 lemming"; + $lemm = lemming_connect($srv, 'inet'); + $self->assert_deep_equals({ foo => { live => 1, dead => 5 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ foo => { live => 0, dead => 6 } }, + $self->lemming_census()); + + xlog $self, "revive the dead IPv6 service"; + $self->{instance}->send_sighup(); + + xlog $self, "connection fails again due to dead IPv6 lemming"; + $lemm = undef; + eval + { + $lemm = lemming_connect($srv, 'inet6'); + }; + $self->assert_null($lemm); + + xlog $self, "expect 5 dead lemmings"; + $self->assert_deep_equals({ foo => { live => 0, dead => 11 } }, + $self->lemming_census()); +} + +sub test_sighup_recycling +{ + my ($self) = @_; + + my $host = 'localhost'; + + my $srv = $self->lemming_service(tag => 'foo', prefork => 1); + $self->start(); + $self->lemming_wait(foo => { live => 1 }); + + xlog $self, "preforked, so one lemming running already"; + $self->assert_deep_equals({ foo => { live => 1, dead => 0 } }, + $self->lemming_census()); + + my $lemm = lemming_connect($srv); + $self->lemming_wait(foo => { live => 2 }); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ foo => { live => 2, dead => 0 } }, + $self->lemming_census()); + + $self->{instance}->send_sighup(); + $self->lemming_wait(foo => { live => 2, dead => 1 }); + + xlog $self, "recycled, so expect one dead lemming"; + $self->assert_deep_equals({ foo => { live => 2, dead => 1 } }, + $self->lemming_census()); + + $self->{instance}->send_sighup(); + $self->lemming_wait(foo => { live => 2, dead => 2 }); + + xlog $self, "recycled, again so expect one more dead lemming"; + $self->assert_deep_equals({ foo => { live => 2, dead => 2 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "always at least one live lemming"; + $self->assert_deep_equals({ foo => { live => 1, dead => 3 } }, + $self->lemming_census()); +} + +sub test_sighup_reloading +{ + my ($self) = @_; + + my $host = 'localhost'; + + my $srvA = $self->lemming_service(tag => 'A'); + $self->start(); + my $srvB = $self->lemming_service(tag => 'B'); + + + my $lemmA = lemming_connect($srvA); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemmA, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 1 } }, + $self->lemming_census()); + + xlog $self, "connection fails due to unexisting lemming"; + my $lemmB; + eval + { + $lemmB = lemming_connect($srvB); + }; + $self->assert_null($lemmB); + + $self->assert_deep_equals({ A => { live => 0, dead => 1 } }, + $self->lemming_census()); + + + xlog $self, "add service in cyrus.conf and reload"; + $self->{instance}->_generate_master_conf(); + $self->{instance}->send_sighup(); + + $lemmA = lemming_connect($srvA); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 1, dead => 1 } }, + $self->lemming_census()); + + lemming_push($lemmA, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 2 } }, + $self->lemming_census()); + + $lemmB = lemming_connect($srvB); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 0, dead => 2 }, + B => { live => 1, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemmB, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 2 }, + B => { live => 0, dead => 1 } }, + $self->lemming_census()); + + + xlog $self, "remove service in cyrus.conf and reload"; + $self->{instance}->remove_service('A'); + $self->{instance}->_generate_master_conf(); + $self->{instance}->send_sighup(); + + # wait a moment for the sighup to be processed + # XXX next test does something tricky with prefork/wait, + # XXX but i'm not sure if that can be used here. + sleep 1; + + xlog $self, "connection fails due to unexisting lemming"; + $lemmA = undef; + eval + { + $lemmA = lemming_connect($srvA); + }; + $self->assert_null($lemmA); + + $self->assert_deep_equals({ A => { live => 0, dead => 2 }, + B => { live => 0, dead => 1 } }, + $self->lemming_census()); + + $lemmB = lemming_connect($srvB); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 0, dead => 2 }, + B => { live => 1, dead => 1 } }, + $self->lemming_census()); + + lemming_push($lemmB, 'success'); + + xlog $self, "no more live lemmings"; + $self->assert_deep_equals({ A => { live => 0, dead => 2 }, + B => { live => 0, dead => 2 } }, + $self->lemming_census()); +} + +sub test_sighup_reloading_listen +{ + my ($self) = @_; + + my $host = 'localhost'; + + # Note: we need to wait for SIGHUP to be processed; prefork can do the trick + # to help us check that + my $srv = $self->lemming_service(tag => 'A', prefork => 1); + $self->start(); + $self->lemming_wait(A => { live => 1 }); + + xlog $self, "preforked, so one lemming running already"; + $self->assert_deep_equals({ A => { live => 1, dead => 0 } }, + $self->lemming_census()); + + my $lemm = lemming_connect($srv); + $self->lemming_wait(A => { live => 2 }); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 2, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "always at least one live lemming"; + $self->assert_deep_equals({ A => { live => 1, dead => 1 } }, + $self->lemming_census()); + + + xlog $self, "change service listen port in cyrus.conf and reload"; + my $port1 = $srv->port(); + $srv->set_port(); + my $port2 = $srv->port(); + $self->assert_not_equals($port1, $port2); + $self->{instance}->_generate_master_conf(); + $self->{instance}->send_sighup(); + # Here is the trick with prefork: wait for the previously forked A instance + # to die and be replaced by a new one + $self->lemming_wait(A => { live => 1, dead => 2 }); + + $self->assert_deep_equals({ A => { live => 1, dead => 2 } }, + $self->lemming_census()); + + $lemm = lemming_connect($srv); + $self->lemming_wait(A => { live => 2 }); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 2, dead => 2 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "always at least one live lemming"; + $self->assert_deep_equals({ A => { live => 1, dead => 3 } }, + $self->lemming_census()); +} + +sub test_sighup_reloading_proto +{ + my ($self) = @_; + + my $host = 'localhost'; + + # Note: we need to wait for SIGHUP to be processed; prefork can do the trick + # to help us check that + # Note: since we are listening on IPv4 *and* IPv6, there will be 2 preforked + # instances + my $srv = $self->lemming_service(tag => 'A', host => undef, prefork => 1); + $self->start(); + $self->lemming_wait(A => { live => 2 }); + + xlog $self, "preforked, so two lemmings running already"; + $self->assert_deep_equals({ A => { live => 2, dead => 0 } }, + $self->lemming_census()); + + # check IPv4 + my $lemm = lemming_connect($srv, 'inet'); + $self->lemming_wait(A => { live => 3 }); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 3, dead => 0 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "always at least two live lemmings"; + $self->assert_deep_equals({ A => { live => 2, dead => 1 } }, + $self->lemming_census()); + + # check IPv6 + $lemm = lemming_connect($srv, 'inet6'); + $self->lemming_wait(A => { live => 3 }); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 3, dead => 1 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "always at least two live lemmings"; + $self->assert_deep_equals({ A => { live => 2, dead => 2 } }, + $self->lemming_census()); + + + xlog $self, "change service listen proto in cyrus.conf and reload"; + $srv->set_master_param('proto', 'tcp4'); + $self->{instance}->_generate_master_conf(); + $self->{instance}->send_sighup(); + # Here is the trick with prefork: wait for the previously forked A instances + # to die and be replaced by a new one + $self->lemming_wait(A => { live => 1, dead => 4 }); + + $self->assert_deep_equals({ A => { live => 1, dead => 4 } }, + $self->lemming_census()); + + # check IPv4 + $lemm = lemming_connect($srv, 'inet'); + $self->lemming_wait(A => { live => 2 }); + + xlog $self, "connected so one lemming forked"; + $self->assert_deep_equals({ A => { live => 2, dead => 4 } }, + $self->lemming_census()); + + lemming_push($lemm, 'success'); + + xlog $self, "always at least one live lemming"; + $self->assert_deep_equals({ A => { live => 1, dead => 5 } }, + $self->lemming_census()); + + # check IPv6 + xlog $self, "connection fails due to unexisting IPv6 lemming"; + $lemm = undef; + eval + { + $lemm = lemming_connect($srv, 'inet6'); + }; + $self->assert_null($lemm); + + xlog $self, "always at least one live lemming"; + $self->assert_deep_equals({ A => { live => 1, dead => 5 } }, + $self->lemming_census()); +} + +sub test_ready_file_new +{ + my ($self) = @_; + + my $ready_file = $self->{instance}->get_basedir() . '/conf/master.ready'; + my $pid_file = $self->{instance}->_pid_file(); + + # pid file should not already exist + my $pid_sb = stat($pid_file); + $self->assert_null($pid_sb); + + # ready file should not already exist + my $ready_sb = stat($ready_file); + $self->assert_null($ready_sb); + + # start cyrus + $self->start(); + + # pid file should exist now + $pid_sb = stat($pid_file); + $self->assert_not_null($pid_sb); + + # ready file should exist soon... + timed_wait(sub { $ready_sb = stat($ready_file) }, + description => "$ready_file to exist"); + $self->assert_not_null($ready_sb); + + # ready file should be newer than pid file + $self->assert_num_gte($pid_sb->mtime, $ready_sb->mtime); +} + +sub test_ready_file_exists +{ + my ($self) = @_; + + # force basedir to be computed + $self->{instance}->get_basedir(); + + # cannot be under basedir because it'll be blown away at startup + my $ready_file = "/tmp/cassandane-$$-master.ready"; + $self->{instance}->{config}->set('master_ready_file', $ready_file); + + # must be after the get_basedir() call above + my $pid_file = $self->{instance}->_pid_file(); + + system("touch", $ready_file) == 0 or die "touch $ready_file: $?"; + sleep 3; + + # pid file should not already exist + my $pid_sb = stat($pid_file); + $self->assert_null($pid_sb); + + # ready file should already exist + my $ready_sb = stat($ready_file); + $self->assert_not_null($ready_sb); + my $orig_mtime = $ready_sb->mtime; + + # start cyrus + $self->start(); + + # pid file should exist now + $pid_sb = stat($pid_file); + $self->assert_not_null($pid_sb); + + # ready file should be touched soon + timed_wait(sub { + $ready_sb = stat($ready_file); + return 1 if $ready_sb->mtime >= $pid_sb->mtime; + return undef; + }, + description => "$ready_file to be newer than $pid_file"); + $self->assert_not_null($ready_sb); + + # ready file should be newer than pid file + $self->assert_num_gte($pid_sb->mtime, $ready_sb->mtime); + $self->assert_num_gt($orig_mtime, $ready_sb->mtime); + + # don't pollute /tmp + unlink $ready_file; +} + +1; diff --git a/cassandane/Cassandane/Cyrus/MaxMessages.pm b/cassandane/Cassandane/Cyrus/MaxMessages.pm new file mode 100644 index 0000000000..08b97748cf --- /dev/null +++ b/cassandane/Cassandane/Cyrus/MaxMessages.pm @@ -0,0 +1,545 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2024 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::MaxMessages; +use strict; +use warnings; +use v5.10; +use Data::Dumper; +use Net::DAVTalk 0.14; +use Net::CardDAVTalk 0.05; +use Net::CardDAVTalk::VCard; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Generator; +use Cassandane::Util::Log; + +my $LOTS = 20; +my $LIMITED = 5; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set( + caldav_realm => 'Cassandane', + calendar_user_address_set => 'example.com', + caldav_historical_age => -1, + conversations => 1, + httpmodules => 'caldav carddav jmap', + httpallowcompress => 'no', + icalendar_max_size => 100000, + jmap_nonstandard_extensions => 'yes', + sieve_maxscripts => $LOTS, + vcard_max_size => 100000, + ); + + return $class->SUPER::new({ + config => $config, + jmap => 1, + services => ['imap', 'http', 'sieve'], + smtpdaemon => 1, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + $ENV{DEBUGDAV} = 1; + $ENV{JMAP_ALWAYS_FULL} = 1; +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub _random_vevent +{ + my ($self) = @_; + state $counter = 1; + + my $uuid = $self->{caldav}->genuuid(); + + my $ics = <<"EOF"; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150701T234327Z +UID:$uuid +DTEND;TZID=Australia/Melbourne:20160601T183000 +TRANSP:OPAQUE +DTSTART;TZID=Australia/Melbourne:20160601T153000 +DTSTAMP:20150806T234327Z +DESCRIPTION:event $counter +END:VEVENT +END:VCALENDAR +EOF + + $counter ++; + + return $ics, $uuid; +} + +sub _random_vcard +{ + my $fn = Cassandane::Generator::make_random_address()->name(); + my ($first, $middle, $last) = split /[\s\.]+/, $fn; + my $n = "$last;$first;$middle;;"; + my $str = <<"EOF"; +BEGIN:VCARD +VERSION:3.0 +N:$n +FN:$fn +REV:2008-04-24T19:52:43Z +END:VCARD +EOF + return $str; +} + +sub put_vevent +{ + my ($self, $calendarid) = @_; + + my ($ics, $uuid) = $self->_random_vevent(); + my $href = "$calendarid/$uuid.ics"; + + $self->{caldav}->Request('PUT', $href, $ics, + 'Content-Type' => 'text/calendar'); +} + +sub put_vcard +{ + my ($self, $addrbookid) = @_; + + my $vcard = Net::CardDAVTalk::VCard->new_fromstring(_random_vcard()); + + $self->{carddav}->NewContact($addrbookid, $vcard); +} + +sub put_script +{ + my ($self) = @_; + state $counter = 1; + + my $name = "script $counter"; + my $script = "# $name\r\nkeep;\r\n"; + $counter ++; + + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/sieve', + 'https://cyrusimap.org/ns/jmap/blob', + ]); + + my $res = $self->{jmap}->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => $script}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => $name, + blobId => "#A" + }, + }, + }, "R1"], + ]); + + $self->assert_not_null($res); + $self->assert_not_null($res->[1][1]{created}{"1"}{id}); +} + +# XXX lots of copies of getinbox -- dedup them! +sub getinbox +{ + my ($self, $args) = @_; + + $args = {} unless $args; + + my $jmap = $self->{jmap}; + + xlog $self, "get existing mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', $args, "R1"]]); + $self->assert_not_null($res); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + return $m{"Inbox"}; +} + +sub _submissions_mailbox +{ + my ($self, $counter) = @_; + + my $jmap = $self->{jmap}; + my $folder = "submission $counter"; + + my $inboxId = $self->getinbox()->{id}; + $self->assert_not_null($inboxId); + + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + "m$counter" => { + parentId => $inboxId, + name => $folder, + }, + }, + }, "R1"]]); + + $self->assert_not_null($res); + $self->assert_not_null($res->[0][1]{created}{"m$counter"}{id}); + + return $res->[0][1]{created}{"m$counter"}{id}; +} + +sub put_submission +{ + my ($self) = @_; + state $counter = 0; + + $counter ++; + + my $jmap = $self->{jmap}; + $jmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + ]); + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityId = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityId); + + # upload each draft to its own mailbox so that we don't accidentally + # exceed mailbox_maxmessages_email + my $mailboxId = $self->_submissions_mailbox($counter); + $self->assert_not_null($mailboxId); + + my $rcpt = Cassandane::Generator::make_random_address(); + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + "m$counter" => { + mailboxIds => { + $mailboxId => JSON::true, + }, + from => [{ + name => 'cassandane', + email => 'cassandane@local', + }], + to => [{ + name => $rcpt->name(), + email => $rcpt->address(), + }], + subject => "message $counter", + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'hello world', + } + }, + }, + }, + }, 'R1'], + [ 'EmailSubmission/set', { + create => { + "s$counter" => { + identityId => $identityId, + emailId => "#m$counter", + envelope => { + mailFrom => { + email => 'cassandane@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [{ + email => $rcpt->address(), + }], + }, + } + }, + }, 'R2' ], + ]); + + $self->assert_not_null($res); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar keys %{$res->[0][1]{notCreated}}); + $self->assert_num_equals(1, scalar keys %{$res->[1][1]{created}}); + $self->assert_num_equals(0, scalar keys %{$res->[1][1]{notCreated}}); +} + +sub put_email +{ + my ($self) = @_; + state $counter = 0; + + $counter ++; + + $self->make_message("message $counter"); +} + +sub test_maxmsg_addressbook_limited + :needs_component_sieve :needs_component_jmap + :JMAPExtensions + :NoStartInstances +{ + my ($self) = @_; + + my $mailbox_maxmessages_addressbook = $LIMITED; + $self->{instance}->{config}->set( + mailbox_maxmessages_addressbook => $mailbox_maxmessages_addressbook, + ); + $self->_start_instances(); + $self->_setup_http_service_objects(); + + my $carddav = $self->{carddav}; + my $addrbookid = $carddav->NewAddressBook('foo'); + $self->assert_not_null($addrbookid); + + # should be able to upload 5 + foreach my $i (1..$mailbox_maxmessages_addressbook) { + $self->put_vcard($addrbookid); + } + + # but any more should be rejected + eval { + $self->put_vcard($addrbookid); + }; + my $e = $@; + $self->assert_not_null($e); + $self->assert_matches(qr{quota-not-exceeded}, $e); + + # should have syslogged about it too + $self->assert_syslog_matches($self->{instance}, + qr{client hit per-addressbook exists limit}); + + # should be able to upload lots of calendar events + my $caldav = $self->{caldav}; + my $calendarid = $caldav->NewCalendar({name => 'mycalendar'}); + $self->assert_not_null($calendarid); + foreach my $i (1..$LOTS) { + $self->put_vevent($calendarid); + } + + # should be able to upload lots of sieve scripts + foreach my $i (1..$LOTS) { + $self->put_script(); + } + + # should be able to upload lots of jmap submissions + foreach my $i (1..$LOTS) { + $self->put_submission(); + } + + # should be able to upload lots of regular emails + foreach my $i (1..$LOTS) { + $self->put_email(); + } +} + +sub test_maxmsg_calendar_limited + :needs_component_sieve :needs_component_jmap + :JMAPExtensions + :NoStartInstances +{ + my ($self) = @_; + + my $mailbox_maxmessages_calendar = $LIMITED; + $self->{instance}->{config}->set( + mailbox_maxmessages_calendar => $mailbox_maxmessages_calendar, + ); + $self->_start_instances(); + $self->_setup_http_service_objects(); + + my $caldav = $self->{caldav}; + my $calendarid = $caldav->NewCalendar({name => 'mycalendar'}); + $self->assert_not_null($calendarid); + + # should be able to upload 5 + foreach my $i (1..$mailbox_maxmessages_calendar) { + $self->put_vevent($calendarid); + } + + # but any more should be rejected + eval { + $self->put_vevent($calendarid); + }; + my $e = $@; + $self->assert_not_null($e); + $self->assert_matches(qr{quota-not-exceeded}, $e); + + # should have syslogged about it too + $self->assert_syslog_matches($self->{instance}, + qr{client hit per-calendar exists limit}); + + # should be able to upload lots of contacts + my $carddav = $self->{carddav}; + my $addrbookid = $carddav->NewAddressBook('foo'); + $self->assert_not_null($addrbookid); + foreach my $i (1..$LOTS) { + $self->put_vcard($addrbookid); + } + + # should be able to upload lots of sieve scripts + foreach my $i (1..$LOTS) { + $self->put_script(); + } + + # should be able to upload lots of jmap submissions + foreach my $i (1..$LOTS) { + $self->put_submission(); + } + + # should be able to upload lots of regular emails + foreach my $i (1..$LOTS) { + $self->put_email(); + } +} + +sub test_maxmsg_email_limited + :needs_component_sieve :needs_component_jmap + :JMAPExtensions + :NoStartInstances +{ + my ($self) = @_; + + my $mailbox_maxmessages_email = $LIMITED; + $self->{instance}->{config}->set( + mailbox_maxmessages_email => $mailbox_maxmessages_email, + ); + $self->_start_instances(); + $self->_setup_http_service_objects(); + + # should be able to upload 5 + foreach my $i (1..$mailbox_maxmessages_email) { + $self->put_email(); + } + + # but any more should be rejected + eval { + $self->put_email(); + }; + my $e = $@; + $self->assert_not_null($e); + $self->assert_matches(qr{Over quota}, $e); + + # should have syslogged about it too + $self->assert_syslog_matches($self->{instance}, + qr{client hit per-mailbox exists limit}); + + # should be able to upload lots of contacts + my $carddav = $self->{carddav}; + my $addrbookid = $carddav->NewAddressBook('foo'); + $self->assert_not_null($addrbookid); + foreach my $i (1..$LOTS) { + $self->put_vcard($addrbookid); + } + + # should be able to upload lots of calendar events + my $caldav = $self->{caldav}; + my $calendarid = $caldav->NewCalendar({name => 'mycalendar'}); + $self->assert_not_null($calendarid); + foreach my $i (1..$LOTS) { + $self->put_vevent($calendarid); + } + + # should be able to upload lots of sieve scripts + foreach my $i (1..$LOTS) { + $self->put_script(); + } + + # should be able to upload lots of jmap submissions + foreach my $i (1..$LOTS) { + $self->put_submission(); + } +} + +sub test_maxmsg_unlimited + :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + + # should be able to upload lots of contacts + my $carddav = $self->{carddav}; + my $addrbookid = $carddav->NewAddressBook('foo'); + $self->assert_not_null($addrbookid); + foreach my $i (1..$LOTS) { + $self->put_vcard($addrbookid); + } + + # should be able to upload lots of calendar events + my $caldav = $self->{caldav}; + my $calendarid = $caldav->NewCalendar({name => 'mycalendar'}); + $self->assert_not_null($calendarid); + foreach my $i (1..$LOTS) { + $self->put_vevent($calendarid); + } + + # should be able to upload lots of sieve scripts + foreach my $i (1..$LOTS) { + $self->put_script(); + } + + # should be able to upload lots of jmap submissions + foreach my $i (1..$LOTS) { + $self->put_submission(); + } + + # should be able to upload lots of regular emails + foreach my $i (1..$LOTS) { + $self->put_email(); + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/MboxEvent.pm b/cassandane/Cassandane/Cyrus/MboxEvent.pm new file mode 100644 index 0000000000..ad01405d21 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/MboxEvent.pm @@ -0,0 +1,136 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2020 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::MboxEvent; +use strict; +use warnings; +use Data::Dumper; +use JSON; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Generator; +use Cassandane::MessageStoreFactory; +use Cassandane::Instance; + +sub new +{ + my ($class, @args) = @_; + + # all of them! + my @event_groups = qw( + message + quota + flags + access + mailbox + subscription + calendar + applepushservice + ); + + my $config = Cassandane::Config->default()->clone(); + $config->set(event_groups => join(' ', @event_groups)); + + return $class->SUPER::new({ + config => $config, + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_tls_login_event + :TLS :min_version_3_0 +{ + my ($self) = @_; + + my $instance = $self->{instance}; + + my $svc = $instance->get_service('imaps'); + $self->assert_not_null($svc); + + my $store = $svc->create_store(); + $self->assert_not_null($store); + + # discard unwanted events from setup_mailbox + $self->{instance}->getnotify(); + + # we're just gonna log in, but not do anything else + my $client = $store->get_client(); + + my $events = $self->{instance}->getnotify(); + my %event_counts; + + foreach my $e (@{$events}) { + my $message = decode_json($e->{MESSAGE}); + $event_counts{$message->{event}}++; + } + + # client should still be connected + # XXX on an ssl socket (which must be blocking), is_open blocks! + # XXX prefer to do this: + # my $still_connected = $client->is_open(); + # $self->assert_not_null($still_connected, "connection dropped"); + # XXX but instead, check if a select succeeds + $client->select('INBOX'); + $self->assert_str_equals('ok', $client->get_last_completion_response()); + + # we should have gotten one Login event and no others + $self->assert_equals(1, $event_counts{'Login'}); + + # XXX more correct, but may race against setup_mailbox finishing up + #$self->assert_deep_equals({ Login => 1 }, \%event_counts); + + # XXX explicitly log out to work around Mail::IMAPTalk destructor + # XXX calling is_open() + $client->logout(); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Mboxgroups.pm b/cassandane/Cassandane/Cyrus/Mboxgroups.pm new file mode 100644 index 0000000000..c52fb8f353 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Mboxgroups.pm @@ -0,0 +1,301 @@ +#!/usr/bin/perl +# +# Copyright (c) 2024 Fastmail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Mboxgroups; +use strict; +use warnings; +use Cwd qw(realpath); +use JSON; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set( + auth_mech => 'mboxgroups', + ); + + my $self = $class->SUPER::new({ + config => $config, + adminstore => 1, + services => [qw( imap )], + start_instances => 0, + }, @args); + + return $self; +} + +sub set_up +{ + my ($self) = @_; + + $self->SUPER::set_up(); + + $self->_start_instances(); + + $self->{instance}->create_user("otheruser"); + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->_imap_cmd('SETUSERGROUP', 0, '', 'cassandane', 'group:group c'); + $admintalk->_imap_cmd('SETUSERGROUP', 0, '', 'cassandane', 'group:group co'); + $admintalk->_imap_cmd('SETUSERGROUP', 0, '', 'otheruser', 'group:group co'); + $admintalk->_imap_cmd('SETUSERGROUP', 0, '', 'otheruser', 'group:group o'); +} + +sub tear_down +{ + my ($self) = @_; + + # clean this up as soon as we're done with it, cause it's holding a + # port open! + delete $self->{server}; + + $self->SUPER::tear_down(); +} + +sub test_setacl_groupid +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.cassandane.groupid"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.groupid", + "group:foo", + "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); +} + +sub test_setacl_groupid_spaces +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.cassandane.groupid_spaces", + "group:this group name has spaces", + "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $data = $admintalk->getacl("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $self->assert(scalar @{$data} % 2 == 0); + my %acl = @{$data}; + $self->assert_str_equals($acl{"group:this group name has spaces"}, + "lrswipkxtecdan"); + + $admintalk->select("user.cassandane.groupid_spaces"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); +} + +sub test_list_groupaccess_noracl + :NoAltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $admintalk->create("user.otheruser.groupaccess"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.otheruser.groupaccess", + "group:group co", "lrswipkxtecdan"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $list = $imaptalk->list("", "*"); + my @boxes = sort map { $_->[2] } @{$list}; + + $self->assert_deep_equals(\@boxes, + ['INBOX', 'user.otheruser.groupaccess']); +} + +sub test_list_groupaccess_racl + :ReverseACLs :NoAltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $admintalk->create("user.otheruser.groupaccess"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setacl("user.otheruser.groupaccess", + "group:group co", "lrswipkxtecdn"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + if (get_verbose()) { + $self->{instance}->run_command( + { cyrus => 1, }, + 'cyr_dbtool', + "$self->{instance}->{basedir}/conf/mailboxes.db", + 'twoskip', + 'show' + ); + } + + my $list = $imaptalk->list("", "*"); + my @boxes = sort map { $_->[2] } @{$list}; + + $self->assert_deep_equals(\@boxes, + ['INBOX', 'user.otheruser.groupaccess']); +} + +sub do_test_list_order +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.zzz"); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); + + $imaptalk->create("INBOX.aaa"); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); + + my %adminfolders = ( + 'user.otheruser.order-user' => 'cassandane', + 'user.otheruser.order-co' => 'group:group co', + 'user.otheruser.order-c' => 'group:group c', + 'user.otheruser.order-o' => 'group:group o', + 'shared.order-co' => 'group:group co', + 'shared.order-c' => 'group:group c', + 'shared.order-o' => 'group:group o', + ); + + while (my ($folder, $identifier) = each %adminfolders) { + $admintalk->create($folder); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "created folder $folder successfully"); + + $admintalk->setacl($folder, $identifier, "lrswipkxtecdn"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "setacl folder $folder for $identifier successfully"); + + if ($folder =~ m/^shared/) { + # subvert default permissions on shared namespace for + # purpose of testing ordering + $admintalk->setacl($folder, "anyone", "p"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response(), + "setacl folder $folder for anyone successfully"); + } + } + + if (get_verbose()) { + $self->{instance}->run_command( + { cyrus => 1, }, + 'cyr_dbtool', + "$self->{instance}->{basedir}/conf/mailboxes.db", + 'twoskip', + 'show' + ); + } + + my $list = $imaptalk->list("", "*"); + my @boxes = map { $_->[2] } @{$list}; + + # Note: order is + # * mine, alphabetically, + # * other users', alphabetically, + # * shared, alphabetically + # ... which is not the order we created them ;) + # Also, the "order-o" folders are not returned, because cassandane + # is not a member of that group + my @expect = qw( + INBOX + INBOX.aaa + INBOX.zzz + user.otheruser.order-c + user.otheruser.order-co + user.otheruser.order-user + ); + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min > 4)) { + push @expect, qw(shared); + } + push @expect, qw( shared.order-c shared.order-co ); + $self->assert_deep_equals(\@boxes, \@expect); +} + +sub test_list_order_noracl + :NoAltNamespace +{ + my $self = shift; + return $self->do_test_list_order(@_); +} + +sub test_list_order_racl + :ReverseACLs :NoAltNamespace +{ + my $self = shift; + return $self->do_test_list_order(@_); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Mbpath.pm b/cassandane/Cassandane/Cyrus/Mbpath.pm new file mode 100644 index 0000000000..70b43b728c --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Mbpath.pm @@ -0,0 +1,86 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Mbpath; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_mbpath_8bit +{ + my ($self) = @_; + + xlog $self, "Test mbpath 8 bit name parsing"; + + # create and prepare the user + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create('A & B'); + + xlog "check with 8bit name"; + my $mbpath = $self->{instance}->run_mbpath('user.cassandane.A & B'); + $self->assert_str_equals($mbpath->{mbname}{intname}, 'user.cassandane.A &- B'); + + xlog "check with 7bit name"; + $mbpath = $self->{instance}->run_mbpath("-7", 'user.cassandane.A &- B'); + $self->assert_str_equals($mbpath->{mbname}{intname}, 'user.cassandane.A &- B'); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Metadata.pm b/cassandane/Cassandane/Cyrus/Metadata.pm new file mode 100644 index 0000000000..e617bb1e25 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Metadata.pm @@ -0,0 +1,478 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Metadata; +use strict; +use warnings; +use DateTime; +use File::Temp qw(:POSIX); +use Config; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use lib '../perl/imap'; +use Cyrus::DList; + +sub new +{ + my ($class, @args) = @_; + return $class->SUPER::new({ adminstore => 1 }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Create and save two messages to two stores, according to GUID +# on the messages, so that the first store gets the message with +# the lower GUID and the second store the message with the higher +# GUID. Both cases need to be done in a controlled manner in order +# to exercise some of the more obscure code paths in message +# replication. +# +# Returns: Message, Message in the order they went to Stores +# +sub make_message_pair +{ + my ($self, $store0, $store1) = @_; + + # Generate two messages and detect their resulting GUIDs + my $msg0 = $self->{gen}->generate(subject => 'Message Zero'); + my $msg1 = $self->{gen}->generate(subject => 'Message One'); + my $guid0 = $msg0->get_guid(); + my $guid1 = $msg1->get_guid(); + xlog $self, "Message 'Message Zero' has GUID $guid0"; + xlog $self, "Message 'Message One' has GUID $guid1"; + + # choose ordering of messages + $self->assert_str_not_equals($guid0, $guid1); + if ($guid0 gt $guid1) + { + # swap + my $t = $msg0; + $msg0 = $msg1; + $msg1 = $t; + } + + # Save and return the messages + $self->_save_message($msg0, $store0); + $self->_save_message($msg1, $store1); + return ($msg0, $msg1); +} + +# List annotations actually stored in the database. +sub list_annotations +{ + my ($self, %params) = @_; + + my $scope = delete $params{scope} || 'global'; + my $mailbox = delete $params{mailbox} || 'user.cassandane'; + my $tombstones = delete $params{tombstones}; + my $withmdata = delete $params{withmdata}; + my $instance = delete $params{instance} || $self->{instance}; + my $uids = delete $params{uids}; + die "Unknown parameters: " . join(' ', map { $_ . '=' . $params{$_}; } keys %params) + if scalar %params; + + my $basedir = $instance->{basedir}; + + my $mailbox_db; + if ($scope eq 'global' || $scope eq 'mailbox') + { + $mailbox_db = "$basedir/conf/annotations.db"; + } + elsif ($scope eq 'message') + { + my $mb = $mailbox; + my $datadir = $self->{instance}->folder_to_directory($mailbox); + $mailbox_db = "$datadir/cyrus.annotations"; + } + else + { + die "Unknown scope: $scope"; + } + + my $format = $instance->{config}->get('annotation_db'); + $format = $format // 'twoskip'; + + my @annots; + + my $res = $instance->run_dbcommand_cb(sub { + my ($key, $value) = @_; + my ($uid, $item, $userid, @rest) = split '\0', $key; + my ($data, $modseq, $flags); + if (substr($value, 0, 1) eq '%') { + my $dlist = Cyrus::DList->parse_string($value, 0); + my $hash = $dlist->as_perl; + $data = $hash->{V}; + $modseq = $hash->{M}; + $flags = $hash->{F} ? 1 : 0; # XXX - parse more options later + } + else { + my $offset = 0; + my $vallen = unpack('N', substr($value, $offset, 4)); + $offset += 8; # 4 more bytes of rubbish + $data = substr($value, $offset, $vallen); + $offset += $vallen + 1; # trailing null + my $strend = index($value, "\0", $offset); + my $type = substr($value, $offset, ($strend - $offset)); + $offset = $strend + 1; + my $modtime = unpack('N', substr($value, $offset, 4)); + $offset += 8; # 4 more bytes of rubbish again + $modseq = unpack('x[N]N', substr($value, $offset, 8)); + $offset += 8; + $flags = unpack('C', substr($value, $offset, 1)); + } + + if ($flags and not $tombstones) { + return; + } + my $annot = { + uid => ($scope eq 'message' ? $uid : 0), + mboxname => ($scope eq 'message' ? $mailbox : $uid), + entry => $item, + userid => $userid, + data => $data, + }; + + if ($withmdata) { + $annot->{modseq} = $modseq; + $annot->{flags} = $flags; + } + + if ($uids) { + my %wantuids = map { $_ => 1 } $uids; + if ($uids and not exists($wantuids{$annot->{uid}})) { + return; + } + } + + if ($annot->{userid} eq '[.OwNeR.]') { + $annot->{userid} = 'cassandane'; # XXX - strip owner from $mailbox? + } + + push(@annots, $annot); + }, $mailbox_db, $format, ['SHOW']); + + # enforce a stable order so we have some chance of + # comparing the results + @annots = sort { + $a->{mboxname} cmp $b->{mboxname} || + $a->{uid} <=> $b->{uid} || + $a->{userid} cmp $b->{userid} || + $a->{entry} cmp $b->{entry}; + } @annots; + + return \@annots; +} + +sub list_uids +{ + my ($self, $store) = @_; + my @uids; + + $store->read_begin(); + while (my $msg = $store->read_message()) + { + push(@uids, $msg->uid); + } + $store->read_end(); + + return \@uids; +} + +sub check_msg_annotation_replication +{ + my ($self, $master_store, $replica_store, %params) = @_; + + my $master_annots = $self->list_annotations((%params, + scope => 'message', + instance => $self->{instance}, + withmdata => 1, + tombstones => 1, + uids => $self->list_uids($master_store), + )); + my $replica_annots = $self->list_annotations((%params, + scope => 'message', + instance => $self->{replica}, + withmdata => 1, + tombstones => 1, + uids => $self->list_uids($replica_store), + )); + + $self->assert_deep_equals($master_annots, $replica_annots); +} + +sub set_msg_annotation +{ + my ($self, $store, $uid, $entry, $attrib, $value) = @_; + + $store ||= $self->{store}; + $store->connect(); + $store->_select(); + my $talk = $store->get_client(); + # Note $value might have no whitespace so we have to + # convince Mail::IMAPTalk to quote it anyway + $talk->store('' . $uid, 'annotation', [$entry, [$attrib, { Quote => $value }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); +} + +# Not sure if this cases can even work... +# sub test_msg_replication_mod_bot_mse + +# Get the highestmodseq of the folder +sub get_highestmodseq +{ + my ($self) = @_; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $stat = $talk->status($store->{folder}, '(highestmodseq)'); + return undef unless defined $stat; + return undef unless ref $stat eq 'HASH'; + return undef unless defined $stat->{highestmodseq}; + return 0 + $stat->{highestmodseq}; +} + +# sub test_mbox_replication_new_rep +# sub test_mbox_replication_new_bot +# sub test_mbox_replication_mod_mas +# sub test_mbox_replication_mod_rep +# sub test_mbox_replication_mod_bot +# sub test_mbox_replication_del_mas +# sub test_mbox_replication_del_rep +# sub test_mbox_replication_del_bot + +sub folder_delete_mboxa_common +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + # data thanks to hipsteripsum.me + my $folder = 'INBOX.williamsburg'; + my $fentry = '/private/comment'; + my $data = $self->make_random_data(0.3, maxreps => 15); + + xlog $self, "create a mailbox"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + xlog $self, "set and then get the same back again"; + $imaptalk->setmetadata($folder, $fentry, $data) + or die "Cannot setmetadata: $@"; + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + my $res = $imaptalk->getmetadata($folder, $fentry) + or die "Cannot getmetadata: $@"; + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => $data } + }, $res); + + xlog $self, "delete the mailbox"; + $imaptalk->delete($folder) + or die "Cannot delete mailbox $folder: $@"; + + xlog $self, "create a new mailbox with the same name"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + xlog $self, "new mailbox reports NIL for the per-mailbox metadata"; + $res = $imaptalk->getmetadata($folder, $fentry) + or die "Cannot getmetadata: $@"; + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => undef } + }, $res); +} + +sub folder_delete_mboxm_common +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + # data thanks to hipsteripsum.me + my $folder = 'INBOX.williamsburg'; + my $fentry = '/private/comment'; + my $data = $self->make_random_data(0.3, maxreps => 15); + + xlog $self, "create a mailbox"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + xlog $self, "set and then get the same back again"; + $imaptalk->setmetadata($folder, $fentry, $data) + or die "Cannot setmetadata: $@"; + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + my $res = $imaptalk->getmetadata($folder, $fentry) + or die "Cannot getmetadata: $@"; + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => $data } + }, $res); + + xlog $self, "delete the mailbox"; + $imaptalk->delete($folder) + or die "Cannot delete mailbox $folder: $@"; + + xlog $self, "cannot get metadata for deleted mailbox"; + $res = $imaptalk->getmetadata($folder, $fentry); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert($imaptalk->get_last_error() =~ m/does not exist/i); + + xlog $self, "create a new mailbox with the same name"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + xlog $self, "new mailbox reports NIL for the per-mailbox metadata"; + $res = $imaptalk->getmetadata($folder, $fentry) + or die "Cannot getmetadata: $@"; + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => undef } + }, $res); +} + +sub folder_delete_msg_common +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + # data thanks to hipsteripsum.me + my $folder = 'INBOX.williamsburg'; + my $mentry = '/comment'; + my $mattrib = 'value.priv'; + $self->{store}->set_fetch_attributes('uid', "annotation ($mentry $mattrib)"); + $self->{store}->set_folder($folder); + + xlog $self, "create a mailbox"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + xlog $self, "add some messages"; + my $uid = 1; + my %exp; + for (1..10) + { + my $msg = $self->make_message("Message $_"); + $exp{$uid} = $msg; + $msg->set_attribute('uid', $uid); + my $data = $self->make_random_data(0.3, maxreps => 15); + $msg->set_annotation($mentry, $mattrib, $data); + $imaptalk->store('' . $uid, 'annotation', + [$mentry, [$mattrib, $data]]); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $uid++; + } + + xlog $self, "Check the messages are all there"; + $self->check_messages(\%exp); + + xlog $self, "delete the mailbox"; + $imaptalk->unselect(); + $imaptalk->delete($folder) + or die "Cannot delete mailbox $folder: $@"; + + xlog $self, "create a new mailbox with the same name"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + xlog $self, "create some new messages"; + %exp = (); + $uid = 1; + for (1..10) + { + my $msg = $self->make_message("Message NEW $_"); + $exp{$uid} = $msg; + $msg->set_attribute('uid', $uid); + # Note: no annotation on the new message + $uid++; + } + + xlog $self, "new mailbox reports NIL for the per-message metadata"; + $self->check_messages(\%exp); +} + +# This is like Mail::IMAPTalk::getmetadata, but +# a) doesn't assume incorrect placement of the options, and +# b) handles the METADATA LONGENTRIES response code +sub getmetadata +{ + my ($talk, @args) = @_; + + my $res = {}; + + my %handlers = + ( + metadata => sub + { + my ($response, $rr, $id) = @_; + if ($rr->[0] =~ m/^longentries/i) + { + $res->{longentries} = 0 + $rr->[1]; + } + else + { + my $f = $talk->_unfix_folder_name($rr->[0]); + my %kv = ( @{$rr->[1]} ); + map { $res->{$f}->{$_} = $kv{$_}; } keys %kv; + } + } + ); + + my $r = $talk->_imap_cmd('getmetadata', 0, \%handlers, @args); + return if !defined $r; + return $res; +} + +use Cassandane::Tiny::Loader 'tiny-tests/Metadata'; + +1; diff --git a/cassandane/Cassandane/Cyrus/Move.pm b/cassandane/Cassandane/Cyrus/Move.pm new file mode 100644 index 0000000000..3f015e6b16 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Move.pm @@ -0,0 +1,108 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty. Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "FastMail" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty. Ltd. +# Level 1, 91 William St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by FastMail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Move; +use strict; +use warnings; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config::default()->clone(); + $config->set("conversations", "yes"); + $config->set("reverseacls", "yes"); + $config->set("annotation_allow_undefined", "yes"); + return $class->SUPER::new({ config => $config, adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + + $self->SUPER::tear_down(); +} + +sub test_move_new_user + :NoAltNameSpace +{ + # test whether the imap_admins setting works correctly + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $admintalk->create("user.user2"); + $admintalk->create("user.user2.sub"); + $admintalk->setacl("user.user2.sub", "cassandane", "lrswited"); + + $talk->enable("QRESYNC"); + $talk->select("INBOX"); + + xlog $self, "create a message and mark it \\Seen"; + $self->make_message("Message foo"); + $talk->store("1", "+flags", "\\Seen"); + + xlog $self, "moving to second user works"; + $talk->move("1", "user.user2.sub"); + $talk->select("user.user2.sub"); + my $res = $talk->fetch("1", "(flags)"); + my $flags = $res->{1}->{flags}; + $self->assert(grep { $_ eq "\\Seen" } @$flags); + + xlog $self, "moving back works"; + $talk->move("1", "INBOX"); + $talk->select("INBOX"); + $res = $talk->fetch("1", "(flags)"); + $flags = $res->{1}->{flags}; + $self->assert(grep { $_ eq "\\Seen" } @$flags); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/MurderDAV.pm b/cassandane/Cassandane/Cyrus/MurderDAV.pm new file mode 100644 index 0000000000..eede5eeeb8 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/MurderDAV.pm @@ -0,0 +1,238 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2013 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::MurderDAV; +use strict; +use warnings; +use URI; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set('conversations' => 'yes'); + $config->set_bits('httpmodules', 'caldav', 'carddav'); + + return $class->SUPER::new({ + config => $config, + httpmurder => 1, + jmap => 1, + adminstore => 1 + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_aaa_setup + :needs_component_murder +{ + my ($self) = @_; + + # does everything set up and tear down cleanly? + $self->assert(1); +} + +# XXX This can't pass because we don't support multiple murder services +# XXX at once, but renaming out the "bogus" and running it, and it failing, +# XXX proves the infrastructure to prevent requesting both works. +sub bogustest_aaa_imapdav_setup + :needs_component_murder + :IMAPMurder +{ + my ($self) = @_; + + # does everything set up and tear down cleanly? + $self->assert(1); +} + +sub test_frontend_commands + :needs_component_murder :needs_component_httpd :min_version_3_5 +{ + my ($self) = @_; + my $result; + + my $frontend_svc = $self->{frontend}->get_service("http"); + my $frontend_host = $frontend_svc->host(); + my $frontend_port = $frontend_svc->port(); + my $proxy_re = qr{ + \b + ( localhost | $frontend_host ) + : $frontend_port + \b + }x; + + my $frontend_caldav = Net::CalDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $frontend_host, + port => $frontend_port, + scheme => 'http', + url => "http://$frontend_host:$frontend_port" + ); + + my $CALDAV = "urn:ietf:params:xml:ns:caldav"; + my $CARDDAV = "urn:ietf:params:xml:ns:carddav"; + my $xml = < + + + + + + +EOF + + xlog $self, "Get current-user-principal"; + my $url = $frontend_caldav->GetCurrentUserPrincipal(); + $self->assert_not_null($url); + + # Copied from Net::DAVTalk::SetURL + my (undef, undef, undef, $cur_princ) = + $url =~ m{^http(s)?://([^/:]+)(?::(\d+))?(.*)?}; + + xlog $self, "PROPFIND for home-sets"; + my $res = $frontend_caldav->Request('PROPFIND', $cur_princ, + $xml, 'Content-Type' => 'text/xml'); + + my $propstat = $res->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]; + my $props = $propstat->{'{DAV:}prop'}; + $self->assert_str_equals('HTTP/1.1 200 OK', + $propstat->{'{DAV:}status'}{content}); + my $cal_home = $props->{"{$CALDAV}calendar-home-set"}{'{DAV:}href'}{content}; + my $card_home = + $props->{"{$CARDDAV}addressbook-home-set"}{'{DAV:}href'}{content}; + $self->assert_not_null($cal_home); + $self->assert_not_null($card_home); + + xlog $self, "Create new calendar"; + $frontend_caldav->SetURL($cal_home); + my $calid1 = $frontend_caldav->NewCalendar({name => 'foo'}); + $self->assert_not_null($calid1); + + xlog $self, "Change calendar name"; + my $newid = $frontend_caldav->UpdateCalendar({ id => $calid1, + name => 'bar'}); + $self->assert_str_equals($calid1, $newid); + + xlog $self, "Create new event"; + my $eventid1 = $frontend_caldav->NewEvent($calid1, { + timeZone => 'Etc/UTC', + start => '2015-01-01T12:00:00', + duration => 'PT1H', + title => 'waterfall', + }); + $self->assert_not_null($eventid1); + + xlog $self, "GET event"; + $res = $frontend_caldav->Request('GET', $cal_home . $eventid1); + $self->assert_matches(qr/SUMMARY:waterfall/, $res->{content}); + + xlog $self, "Get calendars"; + $res = $frontend_caldav->GetCalendars(); + $self->assert_num_equals(2, scalar @{$res}); + + my $sync1; + my $sync2;; + my $calid2; + if ($res->[0]{id} eq $calid1) { + $sync1 = $res->[0]{syncToken}; + $sync2 = $res->[1]{syncToken}; + $calid2 = $res->[1]{id}; + } + else { + $sync1 = $res->[1]{syncToken}; + $sync2 = $res->[0]{syncToken}; + $calid2 = $res->[0]{id}; + } + $self->assert_not_null($calid1); + $self->assert_not_null($sync1); + $self->assert_not_null($sync2); + + xlog $self, "Move event"; + my $eventid2 = $frontend_caldav->MoveEvent($eventid1, $calid2); + $self->assert_not_null($eventid2); + + xlog $self, "Sync Calendars"; + my ($adds, $removes, $errors) = + $frontend_caldav->SyncEvents($calid1, syncToken => $sync1); + $self->assert_num_equals(0, scalar @{$adds}); + $self->assert_num_equals(1, scalar @{$removes}); + $self->assert_str_equals($removes->[0], + $frontend_caldav->fullpath($eventid1)); + + ($adds, $removes, $errors) = + $frontend_caldav->SyncEvents($calid2, syncToken => $sync2); + $self->assert_num_equals(1, scalar @{$adds}); + $self->assert_str_equals($adds->[0]{href}, + $frontend_caldav->fullpath($eventid2)); + $self->assert_str_equals('waterfall', $adds->[0]{title}); + $self->assert_num_equals(0, scalar @{$removes}); + + xlog $self, "Delete event"; + $res = $frontend_caldav->DeleteEvent($eventid2); + $self->assert_num_equals(1, $res); + + xlog $self, "Delete calendar"; + $frontend_caldav->DeleteCalendar($calid1); + $res = $frontend_caldav->GetCalendar($calid1); + $self->assert_null($res); + + # XXX test other commands +} + +1; diff --git a/cassandane/Cassandane/Cyrus/MurderIMAP.pm b/cassandane/Cassandane/Cyrus/MurderIMAP.pm new file mode 100644 index 0000000000..3873ad6d5f --- /dev/null +++ b/cassandane/Cassandane/Cyrus/MurderIMAP.pm @@ -0,0 +1,1227 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::MurderIMAP; +use strict; +use warnings; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Words; +use Cassandane::Instance; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + + $config->set(conversations => 'yes'); + + return $class->SUPER::new({ + imapmurder => 1, adminstore => 1, deliver => 1, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_aaasetup + :needs_component_murder +{ + my ($self) = @_; + + # does everything set up and tear down cleanly? + $self->assert(1); +} + +sub test_frontend_commands + :needs_component_murder +{ + my ($self) = @_; + my $result; + + my $frontend = $self->{frontend_store}->get_client(); + + # should be able to list + $result = $frontend->list("", "*"); + $self->assert_not_null($result); + + # select a folder that doesn't exist yet + $result = $frontend->select('INBOX.newfolder'); + $self->assert_null($result); + $self->assert_matches(qr/Mailbox does not exist/i, + $frontend->get_last_error()); + + # create should be proxied through + $result = $frontend->create('INBOX.newfolder'); + $self->assert_not_null($result); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + # should be able to select it now + $result = $frontend->select('INBOX.newfolder'); + $self->assert_not_null($result); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + # should be able to getmetadata + $result = $frontend->getmetadata('INBOX', + '/shared/vendor/cmu/cyrus-imapd/size'); + $self->assert_not_null($result); + $self->assert(exists $result->{'INBOX'}{'/shared/vendor/cmu/cyrus-imapd/size'}); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + $result = $frontend->getmetadata('(INBOX INBOX.newfolder)', + '/shared/vendor/cmu/cyrus-imapd/size'); + $self->assert_not_null($result); + $self->assert(exists $result->{'INBOX'}{'/shared/vendor/cmu/cyrus-imapd/size'}); + $self->assert(exists $result->{'INBOX.newfolder'}{'/shared/vendor/cmu/cyrus-imapd/size'}); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + # check frontend version for which resource type to use + my $res_mailbox = 'MAILBOX'; + my ($maj, $min) = Cassandane::Instance->get_version('murder'); + if ($maj < 3 || ($maj == 3 && $min < 9)) { + $res_mailbox = 'X-NUM-FOLDERS'; + } + + my $frontend_admin = $self->{frontend_adminstore}->get_client(); + $result = $frontend_admin->setquota('user.cassandane', + "(STORAGE 1024 MESSAGE 5000 $res_mailbox 100)"); + $self->assert_not_null($result); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + # XXX test other commands +} + +sub test_list_specialuse + :needs_component_murder +{ + my ($self) = @_; + + my $frontend = $self->{frontend_store}->get_client(); + my $backend = $self->{backend1_store}->get_client(); + + my %specialuse = map { $_ => 1 } qw( Drafts Junk Sent Trash ); + my %other = map { $_ => 1 } qw( lists personal timesheets ); + + # create some special-use folders + foreach my $f (keys %specialuse) { + $frontend->create("INBOX.$f"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + $frontend->subscribe("INBOX.$f"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + $frontend->setmetadata("INBOX.$f", + '/private/specialuse', "\\$f"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + } + + # create some other non special-use folders (control group) + foreach my $f (keys %other) { + $frontend->create("INBOX.$f"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + $frontend->subscribe("INBOX.$f"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + } + + # ask the backend about them + my $bresult = $backend->list([qw(SPECIAL-USE)], "", "*", + 'RETURN', [qw(SUBSCRIBED)]); + $self->assert_str_equals('ok', $backend->get_last_completion_response()); + xlog $self, Dumper $bresult; + + # check the responses + my %found; + foreach my $r (@{$bresult}) { + my ($flags, $sep, $name) = @{$r}; + # carve out the interesting part of the name + $self->assert_matches(qr/^INBOX$sep/, $name); + $name = substr($name, 6); + $found{$name} = 1; + # only want specialuse folders + $self->assert(exists $specialuse{$name}); + # must be flagged with appropriate flag + $self->assert_equals(1, scalar grep { $_ eq "\\$name" } @{$flags}); + # must be flagged with \subscribed + $self->assert_equals(1, scalar grep { $_ eq '\\Subscribed' } @{$flags}); + } + + # make sure no expected responses were missing + $self->assert_deep_equals(\%specialuse, \%found); + + # ask the frontend about them + my $fresult = $frontend->list([qw(SPECIAL-USE)], "", "*", + 'RETURN', [qw(SUBSCRIBED)]); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + xlog $self, Dumper $fresult; + + # expect the same results as on backend + $self->assert_deep_equals($bresult, $fresult); +} + +sub test_xlist + :needs_component_murder +{ + my ($self) = @_; + + my $frontend = $self->{frontend_store}->get_client(); + my $backend = $self->{backend1_store}->get_client(); + + my %specialuse = map { $_ => 1 } qw( Drafts Junk Sent Trash ); + my %other = map { $_ => 1 } qw( lists personal timesheets ); + + # create some special-use folders + foreach my $f (keys %specialuse) { + $frontend->create("INBOX.$f"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + $frontend->setmetadata("INBOX.$f", + '/private/specialuse', "\\$f"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + } + + # create some other non special-use folders (control group) + foreach my $f (keys %other) { + $frontend->create("INBOX.$f"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + } + + # ask the backend about them + my $bresult = $backend->xlist("", "*"); + $self->assert_str_equals('ok', $backend->get_last_completion_response()); + xlog $self, "backend: " . Dumper $bresult; + + # check the responses + my %found; + foreach my $r (@{$bresult}) { + my ($flags, $sep, $name) = @{$r}; + if ($name eq 'INBOX') { + $found{$name} = 1; + # must be flagged with \Inbox + $self->assert_equals(1, scalar grep { $_ eq '\\Inbox' } @{$flags}); + } + else { + # carve out the interesting part of the name + $self->assert_matches(qr/^INBOX$sep/, $name); + $name = substr($name, 6); + $found{$name} = 1; + $self->assert(exists $specialuse{$name} or exists $other{$name}); + if (exists $specialuse{$name}) { + # must be flagged with appropriate flag + $self->assert_equals(1, scalar grep { $_ eq "\\$name" } @{$flags}); + } + else { + # must not be flagged with name-based flag + $self->assert_equals(0, scalar grep { $_ eq "\\$name" } @{$flags}); + } + } + } + + # make sure no expected responses were missing + $self->assert_deep_equals({ 'INBOX' => 1, %specialuse, %other }, \%found); + + # ask the frontend about them + my $fresult = $frontend->xlist("", "*"); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + xlog $self, "frontend: " . Dumper $fresult; + + # expect the same results as on backend + $self->assert_deep_equals($bresult, $fresult); +} + +sub test_move_to_backend_nonexistent + :needs_component_murder +{ + my ($self) = @_; + + my $dest_folder = 'INBOX.dest'; + + # put some messages into the INBOX + my %exp; + $exp{A} = $self->make_message("Message A", store => $self->{frontend_store}); + $exp{B} = $self->make_message("Message B", store => $self->{frontend_store}); + $exp{C} = $self->make_message("Message C", store => $self->{frontend_store}); + + my $frontend = $self->{frontend_store}->get_client(); + my $backend = $self->{backend1_store}->get_client(); + + # create a destination folder (on both frontend and backend) + $frontend->create($dest_folder); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + # nuke the destination folder (on the backend only) + $backend->localdelete($dest_folder); + $self->assert_str_equals('ok', $backend->get_last_completion_response()); + + my $f_folders = $frontend->list('', '*'); + $self->assert_deep_equals( + [[[ '\\HasChildren' ], '.', 'INBOX' ], + [[ '\\HasNoChildren' ], '.', 'INBOX.dest' ]], + $f_folders); + + my $b_folders = $backend->list('', '*'); + $self->assert_deep_equals( + [[[ '\\HasNoChildren' ], '.', 'INBOX' ]], + $b_folders); + + # try to move a message to dest + $frontend->move($exp{A}->get_attribute('uid'), $dest_folder); + + # it should fail nicely + $self->assert_str_equals('no', $frontend->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/, $frontend->get_last_error()); + + # try to copy a message to dest + $frontend->copy($exp{B}->get_attribute('uid'), $dest_folder); + + # it should fail nicely + $self->assert_str_equals('no', $frontend->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/, $frontend->get_last_error()); +} + +sub test_move_to_nonexistent + :needs_component_murder +{ + my ($self) = @_; + + my $dest_folder = 'INBOX.nonexistent'; + + # put some messages into the INBOX + my %exp; + $exp{A} = $self->make_message("Message A", store => $self->{frontend_store}); + $exp{B} = $self->make_message("Message B", store => $self->{frontend_store}); + $exp{C} = $self->make_message("Message C", store => $self->{frontend_store}); + + my $frontend = $self->{frontend_store}->get_client(); + my $backend = $self->{backend1_store}->get_client(); + + # make sure we don't unexpectedly have the nonexistent folder + my $f_folders = $frontend->list('', '*'); + $self->assert_deep_equals( + [[[ '\\HasNoChildren' ], '.', 'INBOX' ]], + $f_folders); + + my $b_folders = $backend->list('', '*'); + $self->assert_deep_equals( + [[[ '\\HasNoChildren' ], '.', 'INBOX' ]], + $b_folders); + + # try to move a message to dest + $frontend->move($exp{A}->get_attribute('uid'), $dest_folder); + + # it should fail nicely + $self->assert_str_equals('no', $frontend->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/, $frontend->get_last_error()); + + # try to copy a message to dest + $frontend->copy($exp{B}->get_attribute('uid'), $dest_folder); + + # it should fail nicely + $self->assert_str_equals('no', $frontend->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/, $frontend->get_last_error()); +} + +sub test_rename_with_location + :needs_component_murder :AllowMoves +{ + my ($self) = @_; + + my $frontend_adminstore = $self->{frontend_adminstore}->get_client(); + + my $backend2_servername = $self->{backend2}->get_servername(); + + xlog $self, "backend2 servername: $backend2_servername"; + + # not allowed to change mailbox name if location also specified + $frontend_adminstore->rename('user.cassandane', 'user.foo', "$backend2_servername!"); + $self->assert_str_equals('no', $frontend_adminstore->get_last_completion_response()); + + # but can change location if mailbox name remains the same + $frontend_adminstore->rename('user.cassandane', 'user.cassandane', "$backend2_servername!"); + # XXX need to check for "* NO USER cassandane (some error)" untagged response + $self->assert_str_equals('ok', $frontend_adminstore->get_last_completion_response()); + + # verify that it moved + my $backend1_store = $self->{backend1_store}->get_client(); + $backend1_store->select('INBOX'); + $self->assert_str_equals('no', $backend1_store->get_last_completion_response()); + + my $backend2_store = $self->{backend2_store}->get_client(); + $backend2_store->select('INBOX'); + $self->assert_str_equals('ok', $backend2_store->get_last_completion_response()); +} + +sub test_xfer_nonexistent_unixhs + :needs_component_murder :UnixHierarchySep +{ + my ($self) = @_; + + my $admintalk = $self->{backend1_adminstore}->get_client(); + my $backend2_servername = $self->{backend2}->get_servername(); + + # xfer a user that doesn't exist + $admintalk->_imap_cmd('xfer', 0, {}, + 'user/nonexistent', $backend2_servername); + $self->assert_str_equals( + 'no', $admintalk->get_last_completion_response() + ); + + # xfer a mailbox that doesn't exist + $admintalk->_imap_cmd('xfer', 0, {}, + 'user/cassandane/nonexistent', $backend2_servername); + $self->assert_str_equals( + 'no', $admintalk->get_last_completion_response() + ); + + # xfer a pattern that doesn't match anything + $admintalk->_imap_cmd('xfer', 0, {}, + 'user/cassandane/non%', $backend2_servername); + $self->assert_str_equals( + 'no', $admintalk->get_last_completion_response() + ); + + # xfer a partition that doesn't exist + $admintalk->_imap_cmd('xfer', 0, {}, + 'nonexistent', $backend2_servername); + $self->assert_str_equals( + 'no', $admintalk->get_last_completion_response() + ); +} + +sub test_xfer_user_altns_unixhs + :AllowMoves :AltNamespace :UnixHierarchySep + :needs_component_murder :min_version_3_2 +{ + my ($self) = @_; + + # set up some data for cassandane on backend1 + my $expected = $self->populate_user($self->{instance}, + $self->{backend1_store}, + [qw(INBOX Drafts)]); + + my $imaptalk = $self->{backend1_store}->get_client(); + my $admintalk = $self->{backend1_adminstore}->get_client(); + my $backend2_servername = $self->{backend2}->get_servername(); + + # what's the frontend mailboxes.db say before we move? + my $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX before move, frontend mailboxes.db:" . Dumper $mailboxes_db; + + # what's imap LIST say before we move? + # original backend: + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # frontend doesn't know about annotations + my $frontendtalk = $self->{frontend_store}->get_client(); + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren )], + }); + + # ... but if we ask for them it'll proxy the request and find them + $data = $frontendtalk->list("", "*", 'RETURN', [ 'SPECIAL-USE' ]); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # now xfer the cassandane user to backend2 + my $ret = $admintalk->_imap_cmd('xfer', 0, {}, + 'user/cassandane', $backend2_servername); + xlog "XXX xfer returned: " . Dumper $ret; + # XXX 3.2+ with 3.0 target fails here: syntax error in parameters + $self->assert_str_equals('ok', $ret); + # XXX 3.2+ with 2.5 target fails here: mailbox has an invalid format + $self->assert_str_equals( + 'ok', $admintalk->get_last_completion_response() + ); + + # account contents should be on the other store now + $self->check_user($self->{backend2}, $self->{backend2_store}, $expected); + + # frontend should now say the user is on the other store + # XXX is there a better way to discover this? + $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX after move, frontend mailboxes.db: " . Dumper $mailboxes_db; + # XXX 3.0 with 2.5 frontend fails here: server field is blank + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'user.cassandane'}->{server} + ); + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'user.cassandane.Drafts'}->{server} + ); + + # what's imap LIST say after the move? + undef $imaptalk; + $self->{store}->disconnect(); + $imaptalk = $self->{store}->get_client(); + xlog "checking LIST on old backend"; + $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', {}); + + my $backend2talk = $self->{backend2_store}->get_client(); + xlog "checking LIST on new backend"; + $data = $backend2talk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # frontend doesn't know about annotations + $frontendtalk = $self->{frontend_store}->get_client(); + xlog "checking LIST on frontend"; + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren )], + }); + + # ... but if we ask for them it'll proxy the request and find them + $data = $frontendtalk->list("", "*", 'RETURN', [ 'SPECIAL-USE' ]); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); +} + +sub test_xfer_user_noaltns_nounixhs + :AllowMoves :NoAltNamespace + :needs_component_murder :min_version_3_2 +{ + my ($self) = @_; + + # set up some data for cassandane on backend1 + my $expected = $self->populate_user($self->{instance}, + $self->{backend1_store}, + [qw(INBOX INBOX.Drafts)]); + + my $imaptalk = $self->{backend1_store}->get_client(); + my $admintalk = $self->{backend1_adminstore}->get_client(); + my $backend2_servername = $self->{backend2}->get_servername(); + + # what's the frontend mailboxes.db say before we move? + my $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX before move, frontend mailboxes.db:" . Dumper $mailboxes_db; + + # what's imap LIST say before we move? + # original backend: + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # frontend doesn't know about annotations + my $frontendtalk = $self->{frontend_store}->get_client(); + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren )], + }); + + # ... but if we ask for them it'll proxy the request and find them + $data = $frontendtalk->list("", "*", 'RETURN', [ 'SPECIAL-USE' ]); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # now xfer the cassandane user to backend2 + my $ret = $admintalk->_imap_cmd('xfer', 0, {}, + 'user.cassandane', $backend2_servername); + xlog "XXX xfer returned: " . Dumper $ret; + # XXX 3.2+ with 3.0 target fails here: syntax error in parameters + $self->assert_str_equals('ok', $ret); + # XXX 3.2+ with 2.5 target fails here: mailbox has an invalid format + $self->assert_str_equals( + 'ok', $admintalk->get_last_completion_response() + ); + + # account contents should be on the other store now + $self->check_user($self->{backend2}, $self->{backend2_store}, $expected); + + # frontend should now say the user is on the other store + # XXX is there a better way to discover this? + $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX after move, frontend mailboxes.db: " . Dumper $mailboxes_db; + # XXX 3.0 with 2.5 frontend fails here: server field is blank + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'user.cassandane'}->{server} + ); + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'user.cassandane.Drafts'}->{server} + ); + + # what's imap LIST say after the move? + undef $imaptalk; + $self->{store}->disconnect(); + $imaptalk = $self->{store}->get_client(); + xlog "checking LIST on old backend"; + $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', {}); + + my $backend2talk = $self->{backend2_store}->get_client(); + xlog "checking LIST on new backend"; + $data = $backend2talk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # frontend doesn't know about annotations + $frontendtalk = $self->{frontend_store}->get_client(); + xlog "checking LIST on frontend"; + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren )], + }); + + # ... but if we ask for them it'll proxy the request and find them + $data = $frontendtalk->list("", "*", 'RETURN', [ 'SPECIAL-USE' ]); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); +} + +sub test_xfer_user_verify_cleanup + :AllowMoves :NoAltNamespace :Conversations + :needs_component_murder :min_version_3_9 +{ + my ($self) = @_; + + # set up some data for cassandane on backend1 + my $expected = $self->populate_user($self->{instance}, + $self->{backend1_store}, + [qw(INBOX INBOX.Drafts)]); + + my $imaptalk = $self->{backend1_store}->get_client(); + my $admintalk = $self->{backend1_adminstore}->get_client(); + my $backend2_servername = $self->{backend2}->get_servername(); + + xlog $self, "Subscribe to INBOX"; + $imaptalk->subscribe("INBOX"); + + xlog $self, "Install a sieve script"; + $self->{instance}->install_sieve_script(<{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "Verify user mailbox directories exist"; + my $inbox_dir = $self->{instance}->folder_to_directory('INBOX'); + my $drafts_dir = $self->{instance}->folder_to_directory('INBOX.Drafts'); + $self->assert_file_test($inbox_dir, '-d'); + $self->assert_file_test($drafts_dir, '-d'); + + xlog $self, "Verify user data files/directories exist"; + my $data = $self->{instance}->run_mbpath('-u', 'cassandane'); + $self->assert_file_test($data->{user}{'sub'}, '-f'); + $self->assert_file_test($data->{user}{counters}, '-f'); + $self->assert_file_test($data->{user}{conversations}, '-f'); + $self->assert_file_test($data->{user}{xapianactive}, '-f'); + $self->assert_file_test("$data->{user}{sieve}/defaultbc", '-f'); + $self->assert_file_test($data->{xapian}{t1}, '-d'); + + # now xfer the cassandane user to backend2 + my $ret = $admintalk->_imap_cmd('xfer', 0, {}, + 'user.cassandane', $backend2_servername); + + xlog $self, "Verify user mailbox directories have been deleted"; + $self->assert_not_file_test($inbox_dir, '-e'); + $self->assert_not_file_test($drafts_dir, '-e'); + + xlog $self, "Verify user data files/directories have been deleted"; + $self->assert_not_file_test($data->{user}{'sub'}, '-e'); + $self->assert_not_file_test($data->{user}{counters}, '-e'); + $self->assert_not_file_test($data->{user}{conversations}, '-e'); + $self->assert_not_file_test($data->{user}{xapianactive}, '-e'); + $self->assert_not_file_test($data->{user}{sieve}, '-e'); + $self->assert_not_file_test($data->{xapian}{t1}, '-e'); +} + +sub test_xfer_user_altns_unixhs_virtdom + :AllowMoves :AltNamespace :UnixHierarchySep :VirtDomains + :needs_component_murder :min_version_3_2 +{ + my ($self) = @_; + + # set up a user with a domain + my $admintalk = $self->{backend1_adminstore}->get_client(); + $admintalk->create('user/foo@example.com'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $frontend_store = $self->{frontend}->get_service('imap')->create_store( + username => 'foo@example.com'); + my $backend1_store = $self->{instance}->get_service('imap')->create_store( + username => 'foo@example.com'); + my $backend2_store = $self->{backend2}->get_service('imap')->create_store( + username => 'foo@example.com'); + + # set up some data for cassandane on backend1 + my $expected = $self->populate_user($self->{instance}, + $backend1_store, + [qw(INBOX Drafts)]); + + my $imaptalk = $backend1_store->get_client(); + my $backend2_servername = $self->{backend2}->get_servername(); + + # what's the frontend mailboxes.db say before we move? + my $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX before move, frontend mailboxes.db:" . Dumper $mailboxes_db; + + # what's imap LIST say before we move? + # original backend: + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # frontend doesn't know about annotations + my $frontendtalk = $frontend_store->get_client(); + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren )], + }); + + # ... but if we ask for them it'll proxy the request and find them + $data = $frontendtalk->list("", "*", 'RETURN', [ 'SPECIAL-USE' ]); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # now xfer the cassandane user to backend2 + my $ret = $admintalk->_imap_cmd('xfer', 0, {}, + 'user/foo@example.com', + $backend2_servername); + xlog "XXX xfer returned: " . Dumper $ret; + # XXX 3.2+ with 3.0 target fails here: syntax error in parameters + $self->assert_str_equals('ok', $ret); + # XXX 3.2+ with 2.5 target fails here: mailbox has an invalid format + $self->assert_str_equals( + 'ok', $admintalk->get_last_completion_response() + ); + + # account contents should be on the other store now + $self->check_user($self->{backend2}, $backend2_store, $expected); + + # frontend should now say the user is on the other store + # XXX is there a better way to discover this? + $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX after move, frontend mailboxes.db: " . Dumper $mailboxes_db; + # XXX 3.0 with 2.5 frontend fails here: server field is blank + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'example.com!user.foo'}->{server} + ); + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'example.com!user.foo.Drafts'}->{server} + ); + + # what's imap LIST say after the move? + undef $imaptalk; + $backend1_store->disconnect(); + $imaptalk = $backend1_store->get_client(); + xlog "checking LIST on old backend"; + $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', {}); + + my $backend2talk = $backend2_store->get_client(); + xlog "checking LIST on new backend"; + $data = $backend2talk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # frontend doesn't know about annotations + $frontendtalk = $frontend_store->get_client(); + xlog "checking LIST on frontend"; + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren )], + }); + + # ... but if we ask for them it'll proxy the request and find them + $data = $frontendtalk->list("", "*", 'RETURN', [ 'SPECIAL-USE' ]); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); +} + +sub test_xfer_user_noaltns_nounixhs_virtdom + :AllowMoves :NoAltNamespace :VirtDomains + :needs_component_murder :min_version_3_2 +{ + my ($self) = @_; + + # set up a user with a domain + my $admintalk = $self->{backend1_adminstore}->get_client(); + $admintalk->create('user.foo@example.com'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $frontend_store = $self->{frontend}->get_service('imap')->create_store( + username => 'foo@example.com'); + my $backend1_store = $self->{instance}->get_service('imap')->create_store( + username => 'foo@example.com'); + my $backend2_store = $self->{backend2}->get_service('imap')->create_store( + username => 'foo@example.com'); + + # set up some data for cassandane on backend1 + my $expected = $self->populate_user($self->{instance}, + $backend1_store, + [qw(INBOX INBOX.Drafts)]); + + my $imaptalk = $backend1_store->get_client(); + my $backend2_servername = $self->{backend2}->get_servername(); + + # what's the frontend mailboxes.db say before we move? + my $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX before move, frontend mailboxes.db:" . Dumper $mailboxes_db; + + # what's imap LIST say before we move? + # original backend: + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # frontend doesn't know about annotations + my $frontendtalk = $frontend_store->get_client(); + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren )], + }); + + # ... but if we ask for them it'll proxy the request and find them + $data = $frontendtalk->list("", "*", 'RETURN', [ 'SPECIAL-USE' ]); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # now xfer the cassandane user to backend2 + my $ret = $admintalk->_imap_cmd('xfer', 0, {}, + 'user.foo@example.com', + $backend2_servername); + xlog "XXX xfer returned: " . Dumper $ret; + # XXX 3.2+ with 3.0 target fails here: syntax error in parameters + $self->assert_str_equals('ok', $ret); + # XXX 3.2+ with 2.5 target fails here: mailbox has an invalid format + $self->assert_str_equals( + 'ok', $admintalk->get_last_completion_response() + ); + + # account contents should be on the other store now + $self->check_user($self->{backend2}, $backend2_store, $expected); + + # frontend should now say the user is on the other store + # XXX is there a better way to discover this? + $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX after move, frontend mailboxes.db: " . Dumper $mailboxes_db; + # XXX 3.0 with 2.5 frontend fails here: server field is blank + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'example.com!user.foo'}->{server} + ); + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'example.com!user.foo.Drafts'}->{server} + ); + + # what's imap LIST say after the move? + undef $imaptalk; + $backend1_store->disconnect(); + $imaptalk = $backend1_store->get_client(); + xlog "checking LIST on old backend"; + $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', {}); + + my $backend2talk = $backend2_store->get_client(); + xlog "checking LIST on new backend"; + $data = $backend2talk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); + + # frontend doesn't know about annotations + $frontendtalk = $frontend_store->get_client(); + xlog "checking LIST on frontend"; + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren )], + }); + + # ... but if we ask for them it'll proxy the request and find them + $data = $frontendtalk->list("", "*", 'RETURN', [ 'SPECIAL-USE' ]); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.Drafts' => [qw( \\HasNoChildren \\Drafts )], + }); +} + +sub test_xfer_mailbox_altns_unixhs + :AllowMoves :AltNamespace :UnixHierarchySep + :needs_component_murder :min_version_3_2 :max_version_3_4 +{ + my ($self) = @_; + + # what we expect from this test will depend on the cyrus version being + # run on backend2 + my $backend2_permits_single_mailbox = 1; + my ($maj, $min) = Cassandane::Instance->get_version('murder'); + if ($maj > 3 || ($maj == 3 && $min >= 5)) { + $backend2_permits_single_mailbox = 0; + } + + # set up some data for cassandane on backend1 + my $expected_stay = $self->populate_user( + $self->{instance}, + $self->{backend1_store}, + [qw(INBOX Big Big/Red Big/Red/Dog)] + ); + + # we're planning to only XFER "Big/Red" (but not the others!) + my $expected_move->{mailboxes}->{'Big/Red'} + = $expected_stay->{mailboxes}->{'Big/Red'}; + delete $expected_stay->{mailboxes}->{'Big/Red'}; + + my $imaptalk = $self->{backend1_store}->get_client(); + my $admintalk = $self->{backend1_adminstore}->get_client(); + my $backend1_servername = $self->{instance}->get_servername(); + my $backend2_servername = $self->{backend2}->get_servername(); + + # what's the frontend mailboxes.db say before we move? + my $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX before move, frontend mailboxes.db:" . Dumper $mailboxes_db; + + # what's imap LIST say before we move? + # original backend: + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Big' => [qw( \\HasChildren )], + 'Big/Red' => [qw( \\HasChildren )], + 'Big/Red/Dog' => [qw( \\HasNoChildren )], + }); + + my $frontendtalk = $self->{frontend_store}->get_client(); + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Big' => [qw( \\HasChildren )], + 'Big/Red' => [qw( \\HasChildren )], + 'Big/Red/Dog' => [qw( \\HasNoChildren )], + }); + + # now xfer the BigRed folder (only) to backend2 + my $ret = $admintalk->_imap_cmd('xfer', 0, {}, + 'user/cassandane/Big/Red', + $backend2_servername); + + # 3.5+ won't permit receiving just one mid-tree mailbox + if (not $backend2_permits_single_mailbox) { + $self->assert_str_equals( + 'no', $admintalk->get_last_completion_response() + ); + return; # nothing more to test here! + } + + $self->assert_str_equals('ok', $ret); + $self->assert_str_equals( + 'ok', $admintalk->get_last_completion_response() + ); + + # most of the account should have remained on the original backend + $self->check_user($self->{instance}, + $self->{backend1_store}, + $expected_stay); + # but Big/Red should have been moved + $self->check_user($self->{backend2}, + $self->{backend2_store}, + $expected_move); + + # frontend should now say the new mailbox locations + # XXX is there a better way to discover this? + $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX after move, frontend mailboxes.db: " . Dumper $mailboxes_db; + # XXX 3.0 with 2.5 frontend fails here: server field is blank + $self->assert_str_equals( + $backend1_servername, + $mailboxes_db->{'user.cassandane'}->{server} + ); + $self->assert_str_equals( + $backend1_servername, + $mailboxes_db->{'user.cassandane.Big'}->{server} + ); + $self->assert_str_equals( + $backend2_servername, + $mailboxes_db->{'user.cassandane.Big.Red'}->{server} + ); + $self->assert_str_equals( + $backend1_servername, + $mailboxes_db->{'user.cassandane.Big.Red.Dog'}->{server} + ); + + # what's imap LIST say after the move? + undef $imaptalk; + $self->{store}->disconnect(); + $imaptalk = $self->{store}->get_client(); + xlog "checking LIST on old backend"; + $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Big' => [qw( \\HasChildren )], + 'Big/Red/Dog' => [qw( \\HasNoChildren )], + }); + + my $backend2talk = $self->{backend2_store}->get_client(); + xlog "checking LIST on new backend"; + $data = $backend2talk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'Big/Red' => [qw( \\HasNoChildren )], + }); + + $frontendtalk = $self->{frontend_store}->get_client(); + xlog "checking LIST on frontend"; + $data = $frontendtalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Big' => [qw( \\HasChildren )], + 'Big/Red' => [qw( \\HasChildren )], + 'Big/Red/Dog' => [qw( \\HasNoChildren )], + }); +} + +sub test_xfer_no_user_intermediates + :AllowMoves :AltNamespace :UnixHierarchySep + :needs_component_murder :min_version_3_5 +{ + my ($self) = @_; + + # set up some data for cassandane on backend1 + my $expected = $self->populate_user( + $self->{instance}, + $self->{backend1_store}, + [qw(INBOX Big Big/Red Big/Red/Dog)] + ); + + my $admintalk = $self->{backend1_adminstore}->get_client(); + my $backend2_servername = $self->{backend2}->get_servername(); + + # what's the frontend mailboxes.db say before we move? + my $mailboxes_db = $self->{frontend}->read_mailboxes_db(); + xlog "XXX before move, frontend mailboxes.db:" . Dumper $mailboxes_db; + + # try to xfer individual non-INBOX mailboxes, all should be refused + foreach my $folder (qw(Big Big/Red Big/Red/Dog)) { + $admintalk->_imap_cmd('xfer', 0, {}, + "user/cassandane/$folder", + $backend2_servername); + $self->assert_str_equals( + 'no', $admintalk->get_last_completion_response() + ); + $self->assert_matches( + qr{Operation is not supported on mailbox}, + $admintalk->get_last_error() + ); + } + + # everything should still be on the original backend + $self->check_user($self->{instance}, $self->{backend1_store}, $expected); +} + +# XXX test_xfer_partition +# XXX test_xfer_mboxpattern +# XXX shared mailboxes! + +sub test_copy_across_backends + :needs_component_murder :NoAltNamespace +{ + my ($self) = @_; + + my $shared = 'shared'; + + my $admintalk = $self->{backend2_adminstore}->get_client(); + + # create a shared folder (on backend2) + $admintalk->create($shared); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl($shared, 'anyone', 'lrswi'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # put some messages into the INBOX + my %exp; + $self->make_message("Message A", store => $self->{frontend_store}); + $exp{B} = $self->make_message("Message B", store => $self->{frontend_store}); + $self->make_message("Message C", store => $self->{frontend_store}); + $exp{D} = $self->make_message("Message D", store => $self->{frontend_store}); + + my $frontend = $self->{frontend_store}->get_client(); + + my $res = $frontend->select('INBOX'); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + # expunge the some messages so that seqno != uid + $frontend->store('1,3', '+flags', '(\\Deleted)'); + $frontend->expunge(); + + $res = $frontend->copy('1:*', $shared); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + $exp{B}->set_attribute('uid', 1); + $exp{D}->set_attribute('uid', 2); + $self->{frontend_store}->set_folder($shared); + $self->check_messages(\%exp, store => $self->{frontend_store}); +} + +sub test_replace_same_backend + :needs_component_murder :NoAltNamespace :min_version_3_9 +{ + # :min_version_3_9 checks backend1 version. The test below checks frontend + my ($maj, $min) = Cassandane::Instance->get_version('murder'); + if ($maj < 3 || ($maj == 3 && $min < 9)) { + return; + } + + my ($self) = @_; + + my $talk = $self->{frontend_store}->get_client(); + + my %exp; + $exp{A} = $self->make_message("Message A", store => $self->{store}); + $self->check_messages(\%exp); + + $talk->select('INBOX'); + + %exp = (); + $exp{B} = $self->{gen}->generate(subject => "Message B"); + + $talk->_imap_cmd('REPLACE', 0, '', "1", "INBOX", + { Literal => $exp{B}->as_string() }); + $self->check_messages(\%exp); +} + +sub test_replace_across_backends + :needs_component_murder :NoAltNamespace :min_version_3_9 +{ + # :min_version_3_9 checks backend1 version. The test below checks frontend + my ($maj, $min) = Cassandane::Instance->get_version('murder'); + if ($maj < 3 || ($maj == 3 && $min < 9)) { + return; + } + + my ($self) = @_; + + my $shared = 'shared'; + + my $admintalk = $self->{backend2_adminstore}->get_client(); + + # create a shared folder (on backend2) + $admintalk->create($shared); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl($shared, 'anyone', 'lrswi'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # put some messages into the INBOX + $self->make_message("Message A", store => $self->{frontend_store}); + $self->make_message("Message B", store => $self->{frontend_store}); + + my $frontend = $self->{frontend_store}->get_client(); + + my $res = $frontend->select('INBOX'); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + # expunge the first message so that seqno != uid + $frontend->store('1', '+flags', '(\\Deleted)'); + $frontend->expunge(); + + my %exp; + $exp{C} = $self->{gen}->generate(subject => "Message C", uid => 1); + + $res = $frontend->_imap_cmd('REPLACE', 0, '', "1", "shared", + { Literal => $exp{C}->as_string() }); + $self->assert_str_equals('ok', $frontend->get_last_completion_response()); + + $self->check_messages({}); + + $self->{frontend_store}->set_folder($shared); + $self->check_messages(\%exp, store => $self->{frontend_store}); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/MurderJMAP.pm b/cassandane/Cassandane/Cyrus/MurderJMAP.pm new file mode 100644 index 0000000000..ecc33438af --- /dev/null +++ b/cassandane/Cassandane/Cyrus/MurderJMAP.pm @@ -0,0 +1,217 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::MurderJMAP; +use strict; +use warnings; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + $config->set('conversations' => 'yes'); + $config->set_bits('httpmodules', 'jmap'); + + return $class->SUPER::new({ + config => $config, + httpmurder => 1, + jmap => 1, + adminstore => 1 + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_aaa_setup + :needs_component_murder +{ + my ($self) = @_; + + # does everything set up and tear down cleanly? + $self->assert(1); +} + +# XXX This can't pass because we don't support multiple murder services +# XXX at once, but renaming out the "bogus" and running it, and it failing, +# XXX proves the infrastructure to prevent requesting both works. +sub bogustest_aaa_imapjmap_setup + :needs_component_murder + :IMAPMurder +{ + my ($self) = @_; + + # does everything set up and tear down cleanly? + $self->assert(1); +} + +sub test_frontend_commands + :needs_component_murder :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $result; + + my $frontend_svc = $self->{frontend}->get_service("http"); + my $frontend_host = $frontend_svc->host(); + my $frontend_port = $frontend_svc->port(); + my $proxy_re = qr{ + \b + ( localhost | $frontend_host ) + : $frontend_port + \b + }x; + + my $frontend_jmap = Mail::JMAPTalk->new( + user => 'cassandane', + password => 'pass', + host => $frontend_host, + port => $frontend_port, + scheme => 'http', + url => '/jmap/', + ); + + # upload a blob + my ($resp, $data) = $frontend_jmap->Upload("some test", "text/plain"); + + # request should have been proxied + $self->assert_matches($proxy_re, $resp->{headers}{via}); + + # download the same blob + $resp = $frontend_jmap->Download({ accept => 'text/plain' }, + 'cassandane', $data->{blobId}); + + # request should have been proxied + $self->assert_matches($proxy_re, $resp->{headers}{via}); + + # content should match + $self->assert_str_equals('text/plain', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); + + # XXX test other commands +} + +sub test_backend1_commands + :needs_component_murder :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $result; + + my $backend1_svc = $self->{instance}->get_service("http"); + my $backend1_host = $backend1_svc->host(); + my $backend1_port = $backend1_svc->port(); + + my $backend1_jmap = Mail::JMAPTalk->new( + user => 'cassandane', + password => 'pass', + host => $backend1_host, + port => $backend1_port, + scheme => 'http', + url => '/jmap/', + ); + + # upload a blob + my ($resp, $data) = $backend1_jmap->Upload("some test", "text/plain"); + + # request should not have been proxied + $self->assert_null($resp->{headers}{via}); + + # download the same blob + $resp = $backend1_jmap->Download({ accept => 'text/plain' }, + 'cassandane', $data->{blobId}); + + # request should not have been proxied + $self->assert_null($resp->{headers}{via}); + + # content should match + $self->assert_str_equals('text/plain', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); + + # XXX test other commands +} + +sub test_backend2_commands + :needs_component_murder :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $result; + + my $backend2_svc = $self->{backend2}->get_service("http"); + my $backend2_host = $backend2_svc->host(); + my $backend2_port = $backend2_svc->port(); + + my $backend2_jmap = Mail::JMAPTalk->new( + user => 'cassandane', + password => 'pass', + host => $backend2_host, + port => $backend2_port, + scheme => 'http', + url => '/jmap/', + ); + + # try to upload a blob + my ($resp, $data) = $backend2_jmap->Upload("some test", "text/plain"); + + # user doesn't exist on this backend, so upload url should not exist + $self->assert_num_equals(404, $resp->{status}); + $self->assert_str_equals('Not Found', $resp->{reason}); + + $self->assert_null($data); + +# # XXX test other commands +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Nntp.pm b/cassandane/Cassandane/Cyrus/Nntp.pm new file mode 100644 index 0000000000..10b7c6881c --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Nntp.pm @@ -0,0 +1,145 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Nntp; +use strict; +use warnings; +use DateTime; +use News::NNTPClient; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Words; + +sub new +{ + my ($class, @args) = @_; + return $class->SUPER::new({ gen => 0, services => ['nntp'] }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + my $svc = $self->{instance}->get_service('nntp'); + if (defined $svc) + { + my $debug = get_verbose() ? 2 : 0; + $self->{client} = new News::NNTPClient($svc->host(), + $svc->port(), + $debug); + $self->{client}->authinfo('cassandane', 'testpw'); + } +} + +sub tear_down +{ + my ($self) = @_; + + if (defined $self->{client}) + { + $self->{client}->quit(); + $self->{client} = undef; + } + + $self->SUPER::tear_down(); +} + +my $stack_slosh = 256; + +sub test_cve_2011_3208_list_newsgroups + :needs_component_nttpd +{ + my ($self) = @_; + + my $client = $self->{client}; + my $wildmat = ''; + while (length $wildmat < 1024+$stack_slosh) + { + $wildmat .= ($wildmat eq '' ? '' : '.'); + $wildmat .= random_word(); + $client->list('newsgroups', $wildmat); + $self->assert_num_equals(215, $client->code()); + $self->assert($client->message() =~ m/List of newsgroups follows/i); + } +} + +sub test_cve_2011_3208_list_active + :needs_component_nttpd +{ + my ($self) = @_; + + my $client = $self->{client}; + my $wildmat = ''; + while (length $wildmat < 1024+$stack_slosh) + { + $wildmat .= ($wildmat eq '' ? '' : '.'); + $wildmat .= random_word(); + $client->list('active', $wildmat); + $self->assert_num_equals(215, $client->code()); + $self->assert($client->message() =~ m/List of newsgroups follows/i); + } +} + + # The NEWNEWS command is disabled by default. +Cassandane::Cyrus::TestCase::magic(AllowNewNews => sub { + shift->config_set(allownewnews => 1); +}); + +sub test_cve_2011_3208_newnews + :AllowNewNews :needs_component_nttpd +{ + my ($self) = @_; + + my $client = $self->{client}; + my $wildmat = ''; + my $since = time() - 3600; + while (length $wildmat < 1024+$stack_slosh) + { + $wildmat .= ($wildmat eq '' ? '' : '.'); + $wildmat .= random_word(); + $client->newnews($wildmat, $since); + $self->assert_num_equals(230, $client->code()); + $self->assert($client->message() =~ m/List of new articles follows/i); + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Notify.pm b/cassandane/Cassandane/Cyrus/Notify.pm new file mode 100644 index 0000000000..0db087fcbc --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Notify.pm @@ -0,0 +1,695 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2023 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Notify; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config->default()->clone(); + $config->set(imapidlepoll => 2); + return $class->SUPER::new({ + config => $config, + deliver => 1, + start_instances => 0, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_bad + :needs_component_idled :min_version_3_9 +{ + my ($self) = @_; + + xlog $self, "Message test of the NOTIFY command (idled required)"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(); + my $talk = $store->get_client(); + + xlog $self, "The server should report the NOTIFY capability"; + $self->assert($talk->capability()->{notify}); + + xlog $self, "Enable Notify with a missing arg"; + my $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS'); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with an invalid arg"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'FOO'); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with a missing filter"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET'); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with an invalid filter"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', + "(FOO (MessageNew MessageExpunge))"); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with a duplicate filter"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', + "(SELECTED (MessageNew MessageExpunge))", + "(SELECTED-DELAYED (MessageNew MessageExpunge))"); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with another duplicate filter"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', + "(INBOXES (MessageNew MessageExpunge))", + "(INBOXES (MessageNew MessageExpunge FlagChange))"); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with an invalid event"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', + "(INBOXES (MessageNew MessageExpunge Foo))"); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with an invalid event group"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', + "(SELECTED-DELAYED (MessageNew))"); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with another invalid event group"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', + "(PERSONAL (MessageExpunge FlagChange))"); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "Enable Notify with an empty mailbox list"; + $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', + "(MAILBOXES () (MessageNew MessageExpunge))"); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); +} + +sub test_message + :needs_component_idled :min_version_3_9 +{ + my ($self) = @_; + + xlog $self, "Message test of the NOTIFY command (idled required)"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(); + my $talk = $store->get_client(); + + my $otherstore = $svc->create_store(); + my $othertalk = $otherstore->get_client(); + + xlog $self, "The server should report the NOTIFY capability"; + $self->assert($talk->capability()->{notify}); + + xlog $self, "Create two mailboxes"; + $talk->create("INBOX.foo"); + $talk->create("INBOX.bar"); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + xlog $self, "Examine INBOX.foo"; + $talk->examine("INBOX.foo"); + + xlog $self, "Enable Notify"; + my $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', 'STATUS', + "(SELECTED (MessageNew" . + " (UID BODY.PEEK[HEADER.FIELDS (From Subject)])" . + " MessageExpunge FlagChange))", + "(PERSONAL (MessageNew MessageExpunge))"); + + # Should get STATUS responses for unselected mailboxes + my $status = $talk->get_response_code('status'); + $self->assert_num_equals(1, $status->{'INBOX'}{messages}); + $self->assert_num_equals(2, $status->{'INBOX'}{uidnext}); + $self->assert_num_equals(0, $status->{'INBOX.bar'}{messages}); + $self->assert_num_equals(1, $status->{'INBOX.bar'}{uidnext}); + + xlog $self, "Deliver a message"; + $msg = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg); + + # Should get STATUS response for INBOX + $res = $store->idle_response('STATUS', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $status = $talk->get_response_code('status'); + $self->assert_num_equals(2, $status->{'INBOX'}{messages}); + $self->assert_num_equals(3, $status->{'INBOX'}{uidnext}); + + xlog $self, "EXPUNGE message from INBOX in other session"; + $othertalk->select("INBOX"); + $res = $othertalk->store('1', '+flags', '(\\Deleted)'); + $res = $othertalk->expunge(); + + # Should get STATUS response for INBOX + $res = $store->idle_response('STATUS', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $status = $talk->get_response_code('status'); + $self->assert_num_equals(1, $status->{'INBOX'}{messages}); + $self->assert_num_equals(3, $status->{'INBOX'}{uidnext}); + + xlog $self, "Select INBOX"; + $talk->examine("INBOX"); + + xlog $self, "Deliver a message"; + $msg = $self->{gen}->generate(subject => "Message 3"); + $self->{instance}->deliver($msg); + + # Should get EXISTS, RECENT, FETCH response + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response('FETCH', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $self->assert_num_equals(2, $talk->get_response_code('exists')); + $self->assert_num_equals(1, $talk->get_response_code('recent')); + + my $fetch = $talk->get_response_code('fetch'); + $self->assert_num_equals(3, $fetch->{2}{uid}); + $self->assert_str_equals('Message 3', $fetch->{2}{headers}{subject}[0]); + $self->assert_not_null($fetch->{2}{headers}{from}); + + xlog $self, "DELETE message from INBOX in other session"; + $res = $othertalk->store('1', '+flags', '(\\Deleted)'); + + # Should get FETCH response for INBOX + $res = $store->idle_response('FETCH', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $fetch = $talk->get_response_code('fetch'); + $self->assert_num_equals(2, $fetch->{1}{uid}); + $self->assert_str_equals('\\Deleted', $fetch->{1}{flags}[0]); + + xlog $self, "EXPUNGE message from INBOX in other session"; + $res = $othertalk->expunge(); + + # Should get EXPUNGE response for INBOX + $res = $store->idle_response('EXPUNGE', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $self->assert_num_equals(1, $talk->get_response_code('expunge')); + + xlog $self, "Disable Notify"; + $res = $talk->_imap_cmd('NOTIFY', 0, "", "NONE"); + + xlog $self, "Deliver a message"; + $msg = $self->{gen}->generate(subject => "Message 4"); + $self->{instance}->deliver($msg); + + # Should get no unsolicited responses + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no unsolicited responses"); + + # make sure that the connection is ended so that imapd reset happens + $talk->logout(); + undef $talk; + + # we enabled NOTIFY, so we should see it in client behaviors + my $pat = qr/session ended.*notify=<1>/; + $self->assert_syslog_matches($self->{instance}, $pat); +} + +sub test_mailbox + :needs_component_idled :min_version_3_9 +{ + my ($self) = @_; + + xlog $self, "Mailbox test of the NOTIFY command (idled required)"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(); + my $talk = $store->get_client(); + + my $otherstore = $svc->create_store(); + my $othertalk = $otherstore->get_client(); + + xlog $self, "The server should report the NOTIFY capability"; + $self->assert($talk->capability()->{notify}); + + xlog $self, "Enable Notify"; + my $res = $talk->_imap_cmd('NOTIFY', 0, "", "SET", + "(PERSONAL (MailboxName SubscriptionChange))"); + + xlog $self, "Create mailbox in other session"; + $othertalk->create("INBOX.rename-me"); + + # Should get LIST response + $res = $store->idle_response('LIST', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + my $list = $talk->get_response_code('list'); + $self->assert_str_equals('INBOX.rename-me', $list->[0][2]); + + xlog $self, "Subscribe mailbox in other session"; + $othertalk->subscribe("INBOX.rename-me"); + + # Should get LIST response with \Subscribed + $res = $store->idle_response('LIST', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $list = $talk->get_response_code('list'); + $self->assert_str_equals('\\Subscribed', $list->[0][0][0]); + $self->assert_str_equals('INBOX.rename-me', $list->[0][2]); + + xlog $self, "Rename mailbox in other session"; + $othertalk->rename("INBOX.rename-me", "INBOX.delete-me"); + + # Use our own handler since IMAPTalk will lose OLDNAME + my %handlers = + ( + list => sub + { + my (undef, $data) = @_; + $list = [ $data ]; + }, + ); + + # Should get LIST response with OLDNAME + $res = $store->idle_response(\%handlers, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $self->assert_str_equals('INBOX.delete-me', $list->[0][2]); + $self->assert_str_equals('OLDNAME', $list->[0][3][0]); + $self->assert_str_equals('INBOX.rename-me', $list->[0][3][1][0]); + + xlog $self, "Delete mailbox in other session"; + $othertalk->delete("INBOX.delete-me"); + + # Should get LIST response with \NonExistent + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $list = $talk->get_response_code('list'); + $self->assert_str_equals('\\NonExistent', $list->[0][0][0]); + $self->assert_str_equals('INBOX.delete-me', $list->[0][2]); + + xlog $self, "Disable Notify"; + $res = $talk->_imap_cmd('NOTIFY', 0, "", "NONE"); + + xlog $self, "Create mailbox in other session"; + $othertalk->create("INBOX.foo"); + + # Should get no unsolicited responses + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no unsolicited responses"); +} + +sub test_idle + :needs_component_idled :min_version_3_9 +{ + my ($self) = @_; + + xlog $self, "Test of the NOTIFY + IDLE commands (idled required)"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(); + my $talk = $store->get_client(); + + my $otherstore = $svc->create_store(); + my $othertalk = $otherstore->get_client(); + + xlog $self, "The server should report the NOTIFY capability"; + $self->assert($talk->capability()->{notify}); + + xlog $self, "Enable Notify"; + my $res = $talk->_imap_cmd('NOTIFY', 0, "", 'SET', + "(SELECTED (MessageNew" . + " (UID BODY.PEEK[HEADER.FIELDS (From Subject)])" . + " MessageExpunge))", + "(PERSONAL (MessageNew MessageExpunge MailboxName))"); + + # Should NOT get STATUS response for INBOX + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + xlog $self, "Examine INBOX"; + $talk->examine("INBOX"); + $self->assert_num_equals(0, $talk->get_response_code('exists')); + $self->assert_num_equals(0, $talk->get_response_code('recent')); + $self->assert_num_equals(1, $talk->get_response_code('uidnext')); + + xlog $self, "Sending the IDLE command"; + $store->idle_begin() + or die "IDLE failed: $@"; + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + # Should get EXISTS, RECENT, FETCH response + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response('FETCH', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $self->assert_num_equals(1, $talk->get_response_code('exists')); + $self->assert_num_equals(1, $talk->get_response_code('recent')); + + my $fetch = $talk->get_response_code('fetch'); + $self->assert_num_equals(1, $fetch->{1}{uid}); + $self->assert_str_equals('Message 1', $fetch->{1}{headers}{subject}[0]); + + xlog $self, "Create mailbox in other session"; + $othertalk->create("INBOX.foo"); + + # Should get LIST response + $res = $store->idle_response('LIST', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + my $list = $talk->get_response_code('list'); + $self->assert_str_equals('INBOX.foo', $list->[0][2]); + + $othertalk->select("INBOX"); + + xlog $self, "Add \Flagged to message in INBOX in other session"; + $res = $othertalk->store('1', '+flags', '(\\Flagged)'); + + # Should NOT get FETCH response for INBOX + $res = $store->idle_response('FETCH', 1); + $self->assert(!$res, "no more unsolicited responses"); + + xlog $self, "MOVE message from INBOX to INBOX.foo in other session"; + $res = $othertalk->move('1', "INBOX.foo"); + + # Should get STATUS response for INBOX.foo and EXPUNGE response for INBOX + $res = $store->idle_response('STATUS', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + my $status = $talk->get_response_code('status'); + $self->assert_num_equals(1, $status->{'INBOX.foo'}{messages}); + $self->assert_num_equals(2, $status->{'INBOX.foo'}{uidnext}); + $self->assert_num_equals(1, $talk->get_response_code('expunge')); + + xlog $self, "Sending DONE continuation"; + $store->idle_end({}); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Deliver a message"; + $msg = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg); + + # Should get EXISTS, RECENT, FETCH response + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response('FETCH', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $self->assert_num_equals(1, $talk->get_response_code('exists')); + $self->assert_num_equals(1, $talk->get_response_code('recent')); + + $fetch = $talk->get_response_code('fetch'); + $self->assert_num_equals(2, $fetch->{1}{uid}); + $self->assert_str_equals('Message 2', $fetch->{1}{headers}{subject}[0]); + + xlog $self, "Unselect INBOX"; + $talk->unselect(); + + xlog $self, "Deliver a message"; + $msg = $self->{gen}->generate(subject => "Message 3"); + $self->{instance}->deliver($msg); + + # Should get STATUS response for INBOX + $res = $store->idle_response('STATUS', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $status = $talk->get_response_code('status'); + $self->assert_num_equals(2, $status->{'INBOX'}{messages}); + $self->assert_num_equals(4, $status->{'INBOX'}{uidnext}); + + xlog $self, "Delete mailbox in other session"; + $othertalk->delete("INBOX.foo"); + + # Should get LIST response with \NonExistent + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $list = $talk->get_response_code('list'); + $self->assert_str_equals('\\NonExistent', $list->[0][0][0]); + $self->assert_str_equals('INBOX.foo', $list->[0][2]); +} + +sub test_selected_delayed + :needs_component_idled :min_version_3_9 +{ + my ($self) = @_; + + xlog $self, "Selected-delayed test of the NOTIFY command (idled required)"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(); + my $talk = $store->get_client(); + + my $otherstore = $svc->create_store(); + my $othertalk = $otherstore->get_client(); + + xlog $self, "The server should report the NOTIFY capability"; + $self->assert($talk->capability()->{notify}); + + xlog $self, "Enable Notify"; + my $res = $talk->_imap_cmd('NOTIFY', 0, 'STATUS', 'SET', + "(SELECTED-DELAYED (MessageNew MessageExpunge FlagChange))"); + + xlog $self, "Examine INBOX"; + $talk->examine("INBOX"); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + # Should get EXISTS, RECENT response + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $self->assert_num_equals(1, $talk->get_response_code('exists')); + $self->assert_num_equals(1, $talk->get_response_code('recent')); + + xlog $self, "EXPUNGE message from INBOX in other session"; + $othertalk->select("INBOX"); + $res = $othertalk->store('1', '+flags', '(\\Deleted)'); + $res = $othertalk->expunge(); + + # Should get FETCH response, but NO EXPUNGE response + $res = $store->idle_response('FETCH', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + my $fetch = $talk->get_response_code('fetch'); + $self->assert_num_equals(1, $fetch->{1}{uid}); + $self->assert_str_equals('\\Recent', $fetch->{1}{flags}[0]); + $self->assert_str_equals('\\Deleted', $fetch->{1}{flags}[1]); + + xlog $self, "Poll for changes"; + $talk->noop(); + + # Should get EXPUNGE response + $self->assert_num_equals(1, $talk->get_response_code('expunge')); +} + +sub test_change_selected + :needs_component_idled :min_version_3_9 +{ + my ($self) = @_; + + xlog $self, "Test of NOTIFY events following SELECTED mailbox"; + + $self->{instance}->{config}->set(imapidlepoll => '2'); + $self->{instance}->add_start(name => 'idled', + argv => [ 'idled' ]); + $self->{instance}->start(); + + my $svc = $self->{instance}->get_service('imap'); + + my $store = $svc->create_store(); + my $talk = $store->get_client(); + + my $otherstore = $svc->create_store(); + my $othertalk = $otherstore->get_client(); + + xlog $self, "The server should report the NOTIFY capability"; + $self->assert($talk->capability()->{notify}); + + xlog $self, "Create another mailbox"; + $talk->create("INBOX.foo"); + + xlog $self, "Enable Notify"; + my $res = $talk->_imap_cmd('NOTIFY', 0, "", 'SET', + "(SELECTED (MessageNew MessageExpunge))", + "(PERSONAL (MessageNew MessageExpunge))"); + + xlog $self, "Examine INBOX"; + $talk->examine("INBOX"); + $self->assert_num_equals(0, $talk->get_response_code('exists')); + $self->assert_num_equals(0, $talk->get_response_code('recent')); + $self->assert_num_equals(1, $talk->get_response_code('uidnext')); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + # Should get EXISTS, RECENT response + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $self->assert_num_equals(1, $talk->get_response_code('exists')); + $self->assert_num_equals(1, $talk->get_response_code('recent')); + + xlog $self, "Examine INBOX.foo"; + $talk->examine("INBOX.foo"); + $self->assert_num_equals(0, $talk->get_response_code('exists')); + $self->assert_num_equals(0, $talk->get_response_code('recent')); + $self->assert_num_equals(1, $talk->get_response_code('uidnext')); + + xlog $self, "MOVE message from INBOX to INBOX.foo in other session"; + $othertalk->select("INBOX"); + $res = $othertalk->move('1', "INBOX.foo"); + + # Should get EXISTS, RECENT response for INBOX.foo and STATUS response for INBOX + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response('STATUS', 3); + $self->assert($res, "received an unsolicited response"); + $res = $store->idle_response({}, 1); + $self->assert(!$res, "no more unsolicited responses"); + + $self->assert_num_equals(1, $talk->get_response_code('exists')); + $self->assert_num_equals(1, $talk->get_response_code('recent')); + + my $status = $talk->get_response_code('status'); + $self->assert_num_equals(0, $status->{'INBOX'}{messages}); + $self->assert_num_equals(2, $status->{'INBOX'}{uidnext}); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Objectid.pm b/cassandane/Cassandane/Cyrus/Objectid.pm new file mode 100644 index 0000000000..8eb9cced72 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Objectid.pm @@ -0,0 +1,173 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Objectid; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Generator; +use Cassandane::MessageStoreFactory; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({adminstore => 1}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test uniqueid and rename +# +sub test_uniqueid + :AltNamespace :min_version_3_1 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create('foo'); + $talk->create('bar'); + $talk->create('foo'); + my $status1 = $talk->status('foo', "(mailboxid)"); + my $status2 = $talk->status('bar', "(mailboxid)"); + + $talk->rename('foo', 'renamed'); + my $status3 = $talk->status('renamed', "(mailboxid)"); + my $status4 = $talk->status('bar', "(mailboxid)"); + + $self->assert_str_equals($status1->{mailboxid}[0], $status3->{mailboxid}[0]); + $self->assert_str_equals($status2->{mailboxid}[0], $status4->{mailboxid}[0]); + + $talk->list('', '*', 'return', [ "status", [ "mailboxid" ] ]); +} + +# +# Test uniqueid and rename +# +sub test_emailid_threadid + :AltNamespace :Conversations :min_version_3_1 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + $talk->create('foo'); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + my %exp; + + $self->{store}->set_fetch_attributes('uid', 'cid'); + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + $exp{B} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{B}->set_attributes(uid => 2, cid => $exp{A}->make_cid()); + $self->check_messages(\%exp); + + xlog $self, "generating message C"; + $exp{C} = $self->make_message("Message C"); + $exp{C}->set_attributes(uid => 3, cid => $exp{C}->make_cid()); + $self->check_messages(\%exp); + + $talk->select('INBOX'); + my $data = $talk->fetch('1:*', "(emailid threadid)"); + + $talk->search('emailid', $data->{1}{emailid}); + $talk->search('threadid', $data->{1}{threadid}); + + $talk->move("2", "foo"); + + $talk->fetch('1:*', "(emailid threadid)"); + + $talk->select('foo'); + $talk->fetch('1:*', "(emailid threadid)"); + + $talk->select('INBOX'); + + my $email = < + +Body +EOF + + my $email2 = < + +Body2 +EOF + + $email =~ s/\r?\n/\r\n/gs; + $email2 =~ s/\r?\n/\r\n/gs; + + $talk->append("INBOX", "()", " 7-Feb-1994 22:43:04 -0800", { Literal => "$email" }, + "()", " 7-Feb-1994 22:43:04 -0800", { Literal => "$email2" }); + + # XXX and then what??? is this test incomplete? +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Pop3.pm b/cassandane/Cassandane/Cyrus/Pop3.pm new file mode 100644 index 0000000000..f8fa3b9550 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Pop3.pm @@ -0,0 +1,193 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Pop3; +use strict; +use warnings; +use DateTime; +use Net::POP3; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +Cassandane::Cyrus::TestCase::magic(PopSubFolders => sub { + shift->config_set(popsubfolders => 1); +}); + +sub new +{ + my ($class, @args) = @_; + return $class->SUPER::new({ + # We need IMAP to be able to create the mailbox for POP + services => ['imap', 'pop3'], + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + my $svc = $self->{instance}->get_service('pop3'); + if (defined $svc) + { + $self->{pop_store} = $svc->create_store(); + } +} + +sub tear_down +{ + my ($self) = @_; + + if (defined $self->{pop_store}) + { + $self->{pop_store}->disconnect(); + $self->{pop_store} = undef; + } + + $self->SUPER::tear_down(); +} + +sub test_top_args +{ + my ($self) = @_; + + xlog $self, "Testing whether the TOP command checks its arguments [Bug 3641]"; + # Note, the POP client checks its arguments before sending + # them so we have to reach around it to do bad things. + + xlog $self, "Ensure a message exists, before logging in to POP"; + my %exp; + $exp{A} = $self->make_message('Message A'); + + my $client = $self->{pop_store}->get_client(); + + xlog $self, "TOP with no arguments should return an error"; + my $r = $client->command('TOP')->response(); + $self->assert_equals($r, Net::Cmd::CMD_ERROR); + $self->assert_equals($client->code(), 500); + $self->assert_matches(qr/Missing argument/, $client->message()); + + xlog $self, "TOP with 1 argument should return an error"; + $r = $client->command('TOP', 1)->response(); + $self->assert_equals($r, Net::Cmd::CMD_ERROR); + $self->assert_equals($client->code(), 500); + $self->assert_matches(qr/Missing argument/, $client->message()); + + xlog $self, "TOP with 2 correct arguments should actually work"; + $r = $client->command('TOP', 1, 2)->response(); + $self->assert_equals($r, Net::Cmd::CMD_OK); + $self->assert_equals($client->code(), 200); + my $lines = $client->read_until_dot(); + my %actual; + $actual{'Message A'} = Cassandane::Message->new(lines => $lines, + attrs => { uid => 1 }); + $self->check_messages(\%exp, actual => \%actual); + + xlog $self, "TOP with 2 arguments, first one not a number, should return an error"; + $r = $client->command('TOP', '1xyz', 2)->response(); + $self->assert_equals($r, Net::Cmd::CMD_ERROR); + $self->assert_equals($client->code(), 500); + + xlog $self, "TOP with 2 arguments, second one not a number, should return an error"; + $r = $client->command('TOP', 1, '2xyz')->response(); + $self->assert_equals($r, Net::Cmd::CMD_ERROR); + $self->assert_equals($client->code(), 500); + + xlog $self, "TOP with 3 arguments should return an error"; + $r = $client->command('TOP', 1, 2, 3)->response(); + $self->assert_equals($r, Net::Cmd::CMD_ERROR); + $self->assert_equals($client->code(), 500); + $self->assert_matches(qr/Unexpected extra argument/, $client->message()); +} + +sub test_subfolder_login + :PopSubFolders :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing whether + address login gets subfolder"; + + my $imapclient = $self->{store}->get_client(); + + xlog $self, "Ensure a messages exist"; + my %exp; + $exp{A} = $self->make_message('Message A'); + + $imapclient->create('INBOX.sub'); + $self->{store}->set_folder('INBOX.sub'); + # different mailbox, so reset generator's expected uid sequence + $self->{gen}->set_next_uid(1); + + my %subexp; + $subexp{B} = $self->make_message('Message B'); + + my $popclient = $self->{pop_store}->get_client(); + + xlog $self, "Test regular TOP gets the right message"; + my $r = $popclient->command('TOP', 1, 2)->response(); + $self->assert_equals($r, Net::Cmd::CMD_OK); + $self->assert_equals($popclient->code(), 200); + my $lines = $popclient->read_until_dot(); + my %actual; + $actual{'Message A'} = Cassandane::Message->new(lines => $lines, + attrs => { uid => 1 }); + $self->check_messages(\%exp, actual => \%actual); + + my $svc = $self->{instance}->get_service('pop3'); + my $substore = $svc->create_store(folder => 'INBOX.sub'); + + # create a new client + my $subclient = $substore->get_client(); + + + xlog $self, "Test subfolder TOP gets the right message"; + my $subr = $subclient->command('TOP', 1, 2)->response(); + $self->assert_equals($subr, Net::Cmd::CMD_OK); + $self->assert_equals($subclient->code(), 200); + my $sublines = $subclient->read_until_dot(); + my %subactual; + $subactual{'Message B'} = Cassandane::Message->new(lines => $sublines, + attrs => { uid => 1 }); + $self->check_messages(\%subexp, actual => \%subactual); +} + +1; + diff --git a/cassandane/Cassandane/Cyrus/Prometheus.pm b/cassandane/Cassandane/Cyrus/Prometheus.pm new file mode 100644 index 0000000000..f660d99bff --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Prometheus.pm @@ -0,0 +1,486 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty. Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "FastMail" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty. Ltd. +# Level 1, 91 William St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by FastMail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Prometheus; +use strict; +use warnings; +use Data::Dumper; +use File::Slurp; +use HTTP::Tiny; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(prometheus_enabled => "yes"); + $config->set(httpmodules => "prometheus"); + $config->set(prometheus_need_auth => "none"); + $config->set(prometheus_service_update_freq => 2); + $config->set(prometheus_master_update_freq => 2); + $config->set(prometheus_usage_update_freq => 2); + + return $class->SUPER::new( + { adminstore => 1, + config => $config, + services => ['imap', 'http'] }, + @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub _create_instances +{ + my ($self) = @_; + + $self->SUPER::_create_instances(); + # XXX This should really run from the DAEMON section, + # XXX but Cassandane doesn't know about that. + $self->{instance}->add_start(name => 'promstatsd', + argv => [ 'promstatsd' ]); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub http_report +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $url = join(q{}, + q{http://}, $service->host(), + q{:}, $service->port(), + q{/metrics}); + + return HTTP::Tiny->new()->get($url); +} + +sub parse_report +{ + my ($content) = @_; + + my $report = {}; + + foreach my $line (split /\n/, $content) { + next if $line =~ /^\#/; + my ($key, $val, $ts) = split /\s+/, $line; + if ($key =~ m/^([^\{]+)\{([^\}]+)}$/) { + $report->{$1}->{$2} = { value => $val, timestamp => $ts }; + } + else { + $report->{$key} = { value => $val, timestamp => $ts }; + } + } + + return $report; +} + +sub test_aaasetup + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + # does everything set up and tear down cleanly? + $self->assert(1); +} + +sub test_service_reportfile_exists + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + # do something that'll get counted + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + # and wait for a fresh report + sleep 3; + + my $fname = "$self->{instance}->{basedir}/conf/stats/service.txt"; + + $self->assert_file_test($fname, '-f'); + + my $report = parse_report(scalar read_file $fname); + + $self->assert(scalar keys %{$report}); + $self->assert(exists $report->{cyrus_imap_connections_total}); +} + +sub test_httpreport + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + # do something that'll get counted + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + # and wait for a fresh report + sleep 3; + + my $response = $self->http_report(); + + $self->assert($response->{success}); + $self->assert(length $response->{content}); + + my $report = parse_report($response->{content}); + + $self->assert(scalar keys %{$report}); + $self->assert(exists $report->{cyrus_imap_connections_total}); +} + +sub test_disabled + :min_version_3_1 :needs_component_httpd :NoStartInstances +{ + my ($self) = @_; + + my $instance = $self->{instance}; + $instance->{starts} = [ grep { $_->{name} ne 'promstatsd' } @{$instance->{starts}} ]; + $instance->{config}->set(prometheus_enabled => 'no'); + + $self->_start_instances(); + + # no stats directory + my $stats_dir = "$self->{instance}->{basedir}/conf/stats"; + $self->assert_not_file_test($stats_dir, '-d'); + + # no http report + my $response = $self->http_report(); + $self->assert_equals(404, $response->{status}); +} + +# tests for pathological quotaroot/partition subdivisions +sub test_quota_commitments + :min_version_3_1 :needs_component_httpd :Partition2 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $inbox = 'user.cassandane'; # allocate top level quota here + my $child = "$inbox.child"; + my $gchild1 = "$child.cat"; # we'll stick this one on a sep part + my $gchild2 = "$child.dog"; # give this one its own quota + my $gchild3 = "$child.sheep"; # normal, but sorts after weird ones + my $ggchild1 = "$gchild1.manx"; # and give this one its own quota + my $ggchild2 = "$gchild1.siamese"; # and this one back on def part + my $interm = "$inbox.foo.bar.baz"; # contains intermediate folders + my $inbox2 = 'user.cassandane-child'; # hyphen! own quota + + # make some folders + foreach my $f ($child, $gchild1, $gchild2, $gchild3, $ggchild1, + $ggchild2, $interm, $inbox2) { + $admintalk->create($f); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } + + # stick one of them on a different partition + $admintalk->rename($gchild1, $gchild1, 'p2'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # but not one of its children + $admintalk->rename($ggchild2, $ggchild2, 'default'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # create a mess of quotas + $admintalk->setquota($inbox, '(STORAGE 8000)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setquota($gchild2, '(STORAGE 4000)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setquota($ggchild1, '(STORAGE 2000)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setquota($inbox2, '(STORAGE 1000)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $admintalk->logout(); + + sleep 3; + + my $response = $self->http_report(); + $self->assert($response->{success}); + + my $report = parse_report($response->{content}); + $self->assert(scalar keys %{$report}); + + # now we expect default partition to have 8000 + 4000 + 1000 committed + $self->assert_equals(13000, $report->{'cyrus_usage_quota_commitment'}->{'partition="default",resource="STORAGE"'}->{value}); + + # and p2 partition to have 8000 + 2000 committed + $self->assert_equals(10000, $report->{'cyrus_usage_quota_commitment'}->{'partition="p2",resource="STORAGE"'}->{value}); +} + +# tests for pathological quotaroot/partition subdivisions +sub test_quota_commitments_no_improved_mboxlist_sort + :min_version_3_1 :needs_component_httpd :Partition2 :NoStartInstances +{ + my ($self) = @_; + + $self->{instance}->{config}->set('improved_mboxlist_sort', 'no'); + $self->_start_instances(); + + my $admintalk = $self->{adminstore}->get_client(); + + my $inbox = 'user.cassandane'; # allocate top level quota here + my $child = "$inbox.child"; + my $gchild1 = "$child.cat"; # we'll stick this one on a sep part + my $gchild2 = "$child.dog"; # give this one its own quota + my $gchild3 = "$child.sheep"; # normal, but sorts after weird ones + my $ggchild1 = "$gchild1.manx"; # and give this one its own quota + my $ggchild2 = "$gchild1.siamese"; # and this one back on def part + my $interm = "$inbox.foo.bar.baz"; # contains intermediate folders + my $inbox2 = 'user.cassandane-child'; # hyphen! own quota + + # make some folders + foreach my $f ($child, $gchild1, $gchild2, $gchild3, $ggchild1, + $ggchild2, $interm, $inbox2) { + $admintalk->create($f); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } + + # stick one of them on a different partition + $admintalk->rename($gchild1, $gchild1, 'p2'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # but not one of its children + $admintalk->rename($ggchild2, $ggchild2, 'default'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # create a mess of quotas + $admintalk->setquota($inbox, '(STORAGE 8000)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setquota($gchild2, '(STORAGE 4000)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setquota($ggchild1, '(STORAGE 2000)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setquota($inbox2, '(STORAGE 1000)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $admintalk->logout(); + + sleep 3; + + my $response = $self->http_report(); + $self->assert($response->{success}); + + my $report = parse_report($response->{content}); + $self->assert(scalar keys %{$report}); + + # now we expect default partition to have 8000 + 4000 +1000 committed + $self->assert_equals(13000, $report->{'cyrus_usage_quota_commitment'}->{'partition="default",resource="STORAGE"'}->{value}); + + # and p2 partition to have 8000 + 2000 committed + $self->assert_equals(10000, $report->{'cyrus_usage_quota_commitment'}->{'partition="p2",resource="STORAGE"'}->{value}); +} + +sub test_shared_mailbox_namespaces + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $ns1 = 'foo'; + my $ns2 = 'bar'; + my @folders = map { ("$ns1.$_", "$ns2.$_" ) } + qw(cat sheep dog interm.interm.rabbit); + + foreach my $f (@folders) { + $admintalk->create($f); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + } + + sleep 3; + + my $response = $self->http_report(); + $self->assert($response->{success}); + + my $report = parse_report($response->{content}); + $self->assert(scalar keys %{$report}); + + my $num_folders = 4; + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min > 4)) { + $num_folders = 7; + } + + # expect to find $num_folders folders on each of 'foo' and 'bar' namespaces + $self->assert_equals($num_folders, $report->{'cyrus_usage_shared_mailboxes'}->{'partition="default",namespace="bar"'}->{value}); + + $self->assert_equals($num_folders, $report->{'cyrus_usage_shared_mailboxes'}->{'partition="default",namespace="foo"'}->{value}); +} + +sub slowtest_50000_users + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + my $nusers = 50000; + my @subfolders = qw(Drafts Sent Spam Trash); + my $storage = 8000; + + my $admintalk = $self->{adminstore}->get_client(); + + foreach my $n (1..$nusers) { + # reconnect every so often so stuff can flush + if ($n % 5000 == 0) { + $admintalk->logout(); + $self->{adminstore}->disconnect(); + $admintalk = $self->{adminstore}->get_client(); + } + + my $folder = sprintf("user.a%08d", $n); + $admintalk->create($folder); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $admintalk->setquota($folder, "(STORAGE $storage)"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + foreach my $subfolder (@subfolders) { + $admintalk->create("$folder.$subfolder"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + } + } + + # XXX may not be long enough! + sleep 3; + + my $response = $self->http_report(); + $self->assert($response->{success}); + + my $report = parse_report($response->{content}); + $self->assert(scalar keys %{$report}); + + # n.b. user/mailbox counts are +1 cause of user.cassandane! + $self->assert_num_equals(1 + $nusers, + $report->{'cyrus_usage_users'}->{'partition="default"'}->{value}); + $self->assert_num_equals(1 + $nusers + ($nusers * scalar @subfolders), + $report->{'cyrus_usage_mailboxes'}->{'partition="default"'}->{value}); + $self->assert_num_equals($nusers * $storage, + $report->{'cyrus_usage_quota_commitment'}->{'partition="default",resource="STORAGE"'}->{value}); +} + +sub test_connection_setup_failure_imapd + :min_version_3_2 :needs_component_httpd :TLS +{ + my ($self) = @_; + + my $instance = $self->{instance}; + + my $svc = $instance->get_service('imaps'); + $self->assert_not_null($svc); + + # we're gonna try to connect to it unencrypted, so it fails + my $store = $svc->create_store('type' => 'imap'); + $self->assert_not_null($store); + + my $badconns = 2 + int(rand(4)); # between 2-5 tries + for (1 .. $badconns) { + # try to connect to it using a plain text client + # and expect the server to drop the connection + eval { + $store->get_client(); + }; + my $error = $@; + $self->assert_matches(qr{Connection closed by other end}, $error); + } + + # wait a bit for the prometheus report to refresh + sleep 3; + + # check the prom report + my $response = $self->http_report(); + $self->assert($response->{success}); + + my $report = parse_report($response->{content}); + $self->assert(scalar keys %{$report}); + + my $active = $report->{'cyrus_imap_active_connections'}; + $self->assert_not_null($active); + my $ready = $report->{'cyrus_imap_ready_listeners'}; + $self->assert_not_null($ready); + my $total = $report->{'cyrus_imap_connections_total'}; + $self->assert_not_null($total); + my $shutdown = $report->{'cyrus_imap_shutdown_total'}; + $self->assert_not_null($shutdown); + + my $service_label = 'service="imaps"'; + + # number of active connections should definitely not be negative + $self->assert_num_gte(0, $active->{$service_label}->{value}); + + # number of active connections should in fact be zero + $self->assert_num_equals(0, $active->{$service_label}->{value}); + + # number of ready listeners should be zero or one, depending on + # whether it felt like preforking + $self->assert_num_gte(0, $ready->{$service_label}->{value}); + $self->assert_num_lte(1, $ready->{$service_label}->{value}); + + # should not have had any successful connections to imaps + $self->assert(not exists $total->{$service_label}); + + # should be $badconn shutdowns counted (imapd treats this condition + # as an ok shutdown, not an error) + $self->assert_num_equals( + $badconns, + $shutdown->{"$service_label,status=\"ok\""}->{value} + ); + + # XXX someday: expect to find $badconns setup failures counted +} + +1; diff --git a/cassandane/Cassandane/Cyrus/QResync.pm b/cassandane/Cassandane/Cyrus/QResync.pm new file mode 100644 index 0000000000..b87465dfb1 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/QResync.pm @@ -0,0 +1,107 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2020 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::QResync; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::NetString; + + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1, services => ['smmap', 'imap'] }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_qresync_simple +{ + my ($self) = @_; + + xlog $self, "Make some messages"; + my $uid = 1; + my %msgs; + for (1..50) + { + $msgs{$uid} = $self->make_message("Message $uid"); + $msgs{$uid}->set_attribute('uid', $uid); + $uid++; + } + + my $talk = $self->{store}->get_client(); + $talk->select("INBOX"); + my $uidvalidity = $talk->get_response_code('uidvalidity'); + + xlog $self, "Mark some messages \\Deleted"; + $talk->enable("qresync"); + $talk->store('5:10,25:45', '+flags', '(\\Deleted)'); + + xlog $self, "Expunge messages"; + $talk->expunge(); + my @vanished = $talk->get_response_code('vanished'); + $self->assert_equals("5:10,25:45", $vanished[0][0]); + + xlog "QResync mailbox"; + $talk->unselect(); + $talk->select("INBOX", "(QRESYNC ($uidvalidity 0))" => 1); + @vanished = $talk->get_response_code('vanished'); + $self->assert_num_equals(23, $talk->get_response_code('exists')); + $self->assert_equals("5:10,25:45", $vanished[0][1]); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Quota.pm b/cassandane/Cassandane/Cyrus/Quota.pm new file mode 100644 index 0000000000..ece52ea6de --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Quota.pm @@ -0,0 +1,434 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Quota; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::NetString; +use Cassandane::Util::Slurp; + +sub res_mailbox { 'MAILBOX' } +sub res_annot_storage { 'ANNOTATION-STORAGE' } + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1, services => ['smmap', 'imap'] }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj < 3 || ($maj == 3 && $min < 9)) { + $self->res_mailbox = 'X-NUM-FOLDERS'; + $self->res_annot_storage = 'X-ANNOTATION-STORAGE'; + } +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub _set_quotaroot +{ + my ($self, $quotaroot) = @_; + $self->{quotaroot} = $quotaroot; +} + +# Utility function to set quota limits and check that it stuck +sub _set_limits +{ + my ($self, %resources) = @_; + my $admintalk = $self->{adminstore}->get_client(); + + my $quotaroot = delete $resources{quotaroot} || $self->{quotaroot}; + my @quotalist; + foreach my $resource (keys %resources) + { + my $limit = $resources{$resource} + or die "No limit specified for $resource"; + push(@quotalist, uc($resource), $limit); + } + $self->{limits}->{$quotaroot} = { @quotalist }; + $admintalk->setquota($quotaroot, \@quotalist); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); +} + +# Utility function to check that quota's usages +# and limits are where we expect it to be +sub _check_usages +{ + my ($self, %expecteds) = @_; + my $admintalk = $self->{adminstore}->get_client(); + + my $quotaroot = delete $expecteds{quotaroot} || $self->{quotaroot}; + my $limits = $self->{limits}->{$quotaroot}; + + my @result = $admintalk->getquota($quotaroot); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # check actual and expected number of resources do match + $self->assert_num_equals(scalar(keys %$limits) * 3, scalar(@result)); + + # Convert the IMAP result to a conveniently checkable hash. + # By checkable, we mean that a failure in assert_deep_equals() + # will give a human some idea of what went wrong. + my %act; + while (scalar(@result)) { + my ($res, $used, $limit) = splice(@result, 0, 3); + $res = uc($res); + die "Resource $res appears twice in result" + if defined $act{$res}; + $act{$res} = { + used => $used, + limit => $limit, + }; + } + + # Build a conveniently checkable hash from %expecteds + # and limits previously by _set_limits(). + my %exp; + foreach my $res (keys %expecteds) + { + $exp{uc($res)} = { + used => $expecteds{$res}, + limit => $limits->{uc($res)}, + }; + } + + # Now actually compare + $self->assert_deep_equals(\%exp, \%act); +} + +# Reset the recorded usage in the database. Used for testing +# quota -f. Rather hacky. Both _set_quotaroot() and _set_limits() +# can be used to set default values. +sub _zap_quota +{ + my ($self, %params) = @_; + + my $quotaroot = $params{quotaroot} || $self->{quotaroot}; + my $limits = $params{limits} || $self->{limits}->{$quotaroot}; + my $useds = $params{useds} || {}; + $useds = { map { uc($_) => $useds->{$_} } keys %$useds }; + + # double check that some other part of Cassandane didn't + # accidentally futz with the expected quota db backend + my $backend = $self->{instance}->{config}->get('quota_db'); + $self->assert_str_equals('quotalegacy', $backend) + if defined $backend; # the default value is also ok + + my ($uc) = ($quotaroot =~ m/^user[\.\/](.)/); + my ($domain, $dirname); + ($quotaroot, $domain) = split '@', $quotaroot; + if ($domain) { + my ($dc) = ($domain =~ m/^(.)/); + $dirname = $self->{instance}->{basedir} . "/conf/domain/$dc/$domain"; + } + else { + $dirname = $self->{instance}->{basedir} . "/conf"; + } + $dirname .= "/quota/$uc"; + my $qfn = $quotaroot; + $qfn =~ s/\//\./g; + my $filename = "$dirname/$qfn"; + mkpath $dirname; + + open QUOTA,'>',$filename + or die "Failed to open $filename for writing: $!"; + + # STORAGE is special and always present, but -1 if unlimited + my $limit = $limits->{STORAGE} || -1; + my $used = $useds->{STORAGE} || 0; + print QUOTA "$used\n$limit"; + + # other resources have a leading keyword if present + my %keywords = ( MESSAGE => 'M', $self->res_annot_storage => 'AS' ); + foreach my $resource (keys %$limits) + { + my $kw = $keywords{$resource} or next; + $limit = $limits->{$resource}; + $used = $useds->{$resource} || 0; + print QUOTA " $kw $used $limit"; + } + + print QUOTA "\n"; + close QUOTA; + + $self->{instance}->_fix_ownership($self->{instance}{basedir} . "/conf/quota"); +} + +# Utility function to check that there is no quota +sub _check_no_quota +{ + my ($self) = @_; + my $admintalk = $self->{adminstore}->get_client(); + + my @res = $admintalk->getquota($self->{quotaroot}); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); +} + +sub _check_smmap +{ + my ($self, $name, $expected) = @_; + my $service = $self->{instance}->get_service('smmap'); + my $sock = $service->get_socket(); + + print_netstring($sock, "0 $name"); + my $res = get_netstring($sock); + + $self->assert($res =~ m/$expected/); +} + +sub bogus_test_upgrade_v2_4 +{ + my ($self) = @_; + + xlog $self, "test resources usage computing upon upgrading a cyrus v2.4 mailbox"; + + $self->_set_quotaroot('user.cassandane'); + my $talk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "set ourselves a basic limit"; + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => 0); + + xlog $self, "store annotations"; + my $data = $self->make_random_data(10); + my $expected_annotation_storage = length($data); + $talk->setmetadata($self->{store}->{folder}, '/private/comment', { Quote => $data }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->_check_usages($self->res_annot_storage => int($expected_annotation_storage/1024)); + + xlog $self, "restore cyrus v2.4 mailbox content and quota file"; + $self->{instance}->unpackfile(abs_path('data/cyrus/quota_upgrade_v2_4.user.tar.gz'), 'data/user'); + $self->{instance}->unpackfile(abs_path('data/cyrus/quota_upgrade_v2_4.quota.tar.gz'), 'conf/quota/c'); + + xlog $self, "upgrade to version 13 format (v2.5.0)"; + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', '-V' => 13); + + # count messages and size from restored mailbox + my $expected_storage = 0; + my $expected_message = 0; + $talk->select($self->{store}->{folder}); + my $responses = $talk->fetch('1:*', 'RFC822.SIZE'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($responses); + foreach my $response (values(%$responses)) { + $expected_message++; + $expected_storage += $response->{'rfc822.size'}; + } + $talk->close(); + + # check we did restore something + $self->assert_num_not_equals($expected_storage, 0); + $self->assert_num_not_equals($expected_message, 0); + + # set quota limits on resources which did not exist in previous cyrus versions; + # when the mailbox was upgraded, new resources quota usage shall have been + # computed automatically + $self->_set_limits( + storage => 100000, + message => 50000, + $self->res_annot_storage => 10000, + ); + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); +} + +sub XXtest_getset_multiple +{ + my ($self) = @_; + + xlog $self, "testing getting and setting multiple quota resources"; + + my $admintalk = $self->{adminstore}->get_client(); + my $folder = "user.cassandane"; + my @res; + + xlog $self, "checking there are no initial quotas"; + @res = $admintalk->getquota($folder); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert($admintalk->get_last_error() =~ m/Quota root does not exist/i); + + xlog $self, "set both X-ANNOT-COUNT and X-ANNOT-SIZE quotas"; + $admintalk->setquota($folder, "(x-annot-count 20 x-annot-size 16384)"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + xlog $self, "get both resources back, and not STORAGE"; + @res = $admintalk->getquota($folder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals(['X-ANNOT-COUNT', 0, 20, 'X-ANNOT-SIZE', 0, 16384], \@res); + + xlog $self, "set the X-ANNOT-SIZE resource only"; + $admintalk->setquota($folder, "(x-annot-size 32768)"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + xlog $self, "get new -SIZE only and neither STORAGE nor -COUNT"; + @res = $admintalk->getquota($folder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals(['X-ANNOT-SIZE', 0, 32768], \@res); + + xlog $self, "set all of -COUNT -SIZE and STORAGE"; + $admintalk->setquota($folder, "(x-annot-count 123 storage 123456 x-annot-size 65536)"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + xlog $self, "get back all three new values"; + @res = $admintalk->getquota($folder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals(['STORAGE', 0, 123456, 'X-ANNOT-COUNT', 0, 123, 'X-ANNOT-SIZE', 0, 65536], \@res); + + xlog $self, "clear all quotas"; + $admintalk->setquota($folder, "()"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # Note: the RFC does not define what happens if you remove all the + # quotas from a quotaroot. Cyrus leaves the quotaroot around until + # quota -f is run to clean it up. + xlog $self, "get back an empty set of quotas, but the quota root still exists"; + @res = $admintalk->getquota($folder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); +} + +# Magic: the word 'replication' in the name enables a replica +sub XXtest_replication_multiple + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of multiple quotas"; + + my $mastertalk = $self->{master_adminstore}->get_client(); + my $replicatalk = $self->{replica_adminstore}->get_client(); + + my $folder = "user.cassandane"; + my @res; + + xlog $self, "checking there are no initial quotas"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('no', $mastertalk->get_last_completion_response()); + $self->assert($mastertalk->get_last_error() =~ m/Quota root does not exist/i); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('no', $replicatalk->get_last_completion_response()); + $self->assert($replicatalk->get_last_error() =~ m/Quota root does not exist/i); + + xlog $self, "set a X-ANNOT-COUNT and X-ANNOT-SIZE quotas on the master"; + $mastertalk->setquota($folder, "(x-annot-count 20 x-annot-size 16384)"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals(['X-ANNOT-COUNT', 0, 20, 'X-ANNOT-SIZE', 0, 16384], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals(['X-ANNOT-COUNT', 0, 20, 'X-ANNOT-SIZE', 0, 16384], \@res); + + xlog $self, "set the X-ANNOT-SIZE quota on the master"; + $mastertalk->setquota($folder, "(x-annot-size 32768)"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals(['X-ANNOT-SIZE', 0, 32768], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals(['X-ANNOT-SIZE', 0, 32768], \@res); + + xlog $self, "clear all the quotas"; + $mastertalk->setquota($folder, "()"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); +} + +Cassandane::Cyrus::TestCase::magic(Bug3735 => sub { + my ($testcase) = @_; + $testcase->config_set(quota_db => 'quotalegacy'); + $testcase->config_set(hashimapspool => 1); + $testcase->config_set(fulldirhash => 1); + $testcase->config_set(virtdomains => 0); +}); + +use Cassandane::Tiny::Loader 'tiny-tests/Quota'; + +1; diff --git a/cassandane/Cassandane/Cyrus/Reconstruct.pm b/cassandane/Cassandane/Cyrus/Reconstruct.pm new file mode 100644 index 0000000000..155019d117 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Reconstruct.pm @@ -0,0 +1,632 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Reconstruct; +use strict; +use warnings; +use Data::Dumper; +use File::Copy; +use IO::File; +use JSON; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +use lib '../perl/imap'; +use Cyrus::DList; +use Cyrus::HeaderFile; +use Cyrus::IndexFile; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test zeroed out data across the UID +# +sub test_reconstruct_zerouid +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + for (1..10) { + my $msg = $self->{gen}->generate(subject => "subject $_"); + $self->{store}->write_message($msg, flags => ["\\Seen", "\$NotJunk"]); + } + $self->{store}->write_end(); + $imaptalk->select("INBOX") || die; + + my @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct'); + + @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + + # this needs a bit of magic to know where to write... so + # we do some hard-coded cyrus.index handling + my $dir = $self->{instance}->folder_to_directory('user.cassandane'); + my $file = "$dir/cyrus.index"; + my $fh = IO::File->new($file, "+<"); + die "NO SUCH FILE $file" unless $fh; + my $index = Cyrus::IndexFile->new($fh); + + my $offset = $index->header('StartOffset') + (5 * $index->header('RecordSize')); + warn "seeking to offset $offset"; + $fh->seek($offset, 0); + $fh->syswrite("\0\0\0\0\0\0\0\0", 8); + $fh->close(); + + # this time, the reconstruct will fix up the broken record and re-insert later + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', 'user.cassandane'); + + @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(not grep { $_ == 6 } @records); + $self->assert(grep { $_ == 11 } @records); +} + +# +# Test truncated file +# +sub test_reconstruct_truncated +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + for (1..10) { + my $msg = $self->{gen}->generate(subject => "subject $_"); + $self->{store}->write_message($msg, flags => ["\\Seen", "\$NotJunk"]); + } + $self->{store}->write_end(); + $imaptalk->select("INBOX") || die; + + my @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct'); + + @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + + # this needs a bit of magic to know where to write... so + # we do some hard-coded cyrus.index handling + my $dir = $self->{instance}->folder_to_directory('user.cassandane'); + my $file = "$dir/cyrus.index"; + my $fh = IO::File->new($file, "+<"); + die "NO SUCH FILE $file" unless $fh; + my $index = Cyrus::IndexFile->new($fh); + + my $offset = $index->header('StartOffset') + (5 * $index->header('RecordSize')); + $fh->truncate($offset); + $fh->close(); + + # this time, the reconstruct will create the records again + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', 'user.cassandane'); + + # XXX - this actually deletes everything, so we unselect and reselect. A + # too-short cyrus.index is a fatal error, so we don't even try to read it. + $imaptalk->unselect(); + $imaptalk->select("INBOX") || die; + + @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + $self->assert(not grep { $_ == 11 } @records); + + # We should have generated a SYNCERROR or two + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: refreshing index/); +} +# +# Test removed file +# +sub test_reconstruct_removedfile +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + for (1..10) { + my $msg = $self->{gen}->generate(subject => "subject $_"); + $self->{store}->write_message($msg, flags => ["\\Seen", "\$NotJunk"]); + } + $self->{store}->write_end(); + $imaptalk->select("INBOX") || die; + + my @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct'); + + @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + + # this needs a bit of magic to know where to write... so + # we do some hard-coded cyrus.index handling + my $dir = $self->{instance}->folder_to_directory('user.cassandane'); + unlink("$dir/6."); + + # this time, the reconstruct will fix up the broken record and re-insert later + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', 'user.cassandane'); + + @records = $imaptalk->search("all"); + $self->assert_num_equals(9, scalar @records); + $self->assert(not grep { $_ == 6 } @records); +} + +# +# Test snoozed annotation fixup +# +# XXX need to downgrade min version if this is backported to 3.2 +sub test_reconstruct_snoozed + :min_version_3_3 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + for (1..10) { + my $msg = $self->{gen}->generate(subject => "subject $_"); + $self->{store}->write_message($msg, flags => ["\\Seen", "\$NotJunk"]); + } + $self->{store}->write_end(); + $imaptalk->select("INBOX") || die; + + my @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct'); + + @records = $imaptalk->search("all"); + $self->assert_num_equals(10, scalar @records); + $self->assert(grep { $_ == 6 } @records); + + $imaptalk->store('5', 'annotation', ["/vendor/cmu/cyrus-imapd/snoozed", + ['value.shared', { Quote => encode_json({until => '2020-01-01T00:00:00'}) }], + ]); + + # this needs a bit of magic to know where to write... so + # we do some hard-coded cyrus.index handling + my $dir = $self->{instance}->folder_to_directory('user.cassandane'); + my $file = "$dir/cyrus.index"; + my $fh = IO::File->new($file, "+<"); + die "NO SUCH FILE $file" unless $fh; + my $index = Cyrus::IndexFile->new($fh); + + while (my $record = $index->next_record_hash()) { + if ($record->{Uid} == 5) { + $self->assert_str_equals(substr($record->{SystemFlags}, 5, 1), '1'); + } + else { + $self->assert_str_equals(substr($record->{SystemFlags}, 5, 1), '0'); + } + } + close($fh); + + # the reconstruct shouldn't change anything + $self->{instance}->getsyslog(); + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', 'user.cassandane'); + $self->assert_syslog_does_not_match($self->{instance}, qr/mismatch/); + + xlog $self, "update some \\Snoozed flags"; + $fh = IO::File->new($file, "+<"); + die "NO SUCH FILE $file" unless $fh; + $index = Cyrus::IndexFile->new($fh); + + while (my $record = $index->next_record_hash()) { + if ($record->{Uid} == 5) { + # nuke the Snoozed flag + $self->assert_str_equals(substr($record->{SystemFlags}, 5, 1), '1'); + substr($record->{SystemFlags}, 5, 1) = '0'; + $index->rewrite_record($record); + } + elsif ($record->{Uid} == 6) { + # add the Snoozed flag + $self->assert_str_equals(substr($record->{SystemFlags}, 5, 1), '0'); + substr($record->{SystemFlags}, 5, 1) = '1'; + $index->rewrite_record($record); + } + else { + $self->assert_str_equals(substr($record->{SystemFlags}, 5, 1), '0'); + } + } + close($fh); + + # this reconstruct should change things back! + $self->{instance}->getsyslog(); + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', 'user.cassandane'); + if ($self->{instance}->{have_syslog_replacement}) { + my @lines = $self->{instance}->getsyslog(); + $self->assert_matches(qr/uid 5 snoozed mismatch/, "@lines"); + $self->assert_matches(qr/uid 6 snoozed mismatch/, "@lines"); + } + + xlog $self, "check that the values are changed back"; + $fh = IO::File->new($file, "+<"); + die "NO SUCH FILE $file" unless $fh; + $index = Cyrus::IndexFile->new($fh); + + while (my $record = $index->next_record_hash()) { + if ($record->{Uid} == 5) { + $self->assert_str_equals(substr($record->{SystemFlags}, 5, 1), '1'); + } + else { + $self->assert_str_equals(substr($record->{SystemFlags}, 5, 1), '0'); + } + } + close($fh); +} + +sub test_reconstruct_uniqueid_from_header_path_legacymb + :min_version_3_7 :MailboxLegacyDirs :NoStartInstances +{ + my ($self) = @_; + my $entry = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + + # first start will set up cassandane user + $self->_start_instances(); + my $basedir = $self->{instance}->get_basedir(); + my $mailboxes_db = "$basedir/conf/mailboxes.db"; + $self->assert(-f $mailboxes_db, "$mailboxes_db not present"); + + # find out the uniqueid of the inbox + my $imaptalk = $self->{store}->get_client(); + my $res = $imaptalk->getmetadata("INBOX", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $uniqueid = $res->{INBOX}{$entry}; + $self->assert_not_null($uniqueid); + $imaptalk->logout(); + undef $imaptalk; + + # stop service while tinkering + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + + # get header path + my $cyrus_header = $self->{instance}->folder_to_directory('INBOX') + . '/cyrus.header'; + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + + # lose uniqueid from cyrus.header + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + copy($cyrus_header, "$cyrus_header.OLD"); + my $hf = Cyrus::HeaderFile->new_file("$cyrus_header.OLD"); + my $dlist = Cyrus::DList->parse_string($hf->{dlistheader}); + my $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + $hash->{I} = undef; + $dlist = Cyrus::DList->new_perl('', $hash); + my $out = IO::File->new($cyrus_header, 'w'); + $hf->write_newheader($out, $dlist->as_string()); + + # reconstruct -P should find and fix the missing uniqueid + $self->{instance}->getsyslog(); + $self->{instance}->run_command({ cyrus => 1 }, + 'reconstruct', '-P', $cyrus_header); + + # should not have existed in cyrus.header, get from mbentry + $self->assert_syslog_matches( + $self->{instance}, + qr{mailbox header had no uniqueid, setting from mbentry} + ); + + # bring service back up + $self->{instance}->start(); + + # header should have the same uniqueid as before + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + $hf = Cyrus::HeaderFile->new_file($cyrus_header); + $self->assert_str_equals($uniqueid, $hf->{header}->{UniqueId}); +} + +sub test_reconstruct_uniqueid_from_header_path_uuidmb + :min_version_3_7 :NoStartInstances +{ + my ($self) = @_; + my $entry = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + + # first start will set up cassandane user + $self->_start_instances(); + my $basedir = $self->{instance}->get_basedir(); + my $mailboxes_db = "$basedir/conf/mailboxes.db"; + $self->assert(-f $mailboxes_db, "$mailboxes_db not present"); + + # find out the uniqueid of the inbox + my $imaptalk = $self->{store}->get_client(); + my $res = $imaptalk->getmetadata("INBOX", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $uniqueid = $res->{INBOX}{$entry}; + $self->assert_not_null($uniqueid); + $imaptalk->logout(); + undef $imaptalk; + + # stop service while tinkering + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + + # get header path + my $cyrus_header = $self->{instance}->folder_to_directory('INBOX') + . '/cyrus.header'; + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + + # lose that uniqueid from mailboxes.db + my $I = "I$uniqueid"; + my $N = "Nuser\x1fcassandane"; + $self->{instance}->run_dbcommand($mailboxes_db, "twoskip", + [ 'DELETE', $I ]); + my (undef, $mbentry) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $N]); + my $dlist = Cyrus::DList->parse_string($mbentry); + my $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + $hash->{I} = undef; + $dlist = Cyrus::DList->new_perl('', $hash); + $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + [ 'SET', $N, $dlist->as_string() ]); + + my %updated = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", ['SHOW']); + xlog "updated mailboxes.db: " . Dumper \%updated; + + # lose it from cyrus.header too + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + copy($cyrus_header, "$cyrus_header.OLD"); + my $hf = Cyrus::HeaderFile->new_file("$cyrus_header.OLD"); + $dlist = Cyrus::DList->parse_string($hf->{dlistheader}); + $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + $hash->{I} = undef; + $dlist = Cyrus::DList->new_perl('', $hash); + my $out = IO::File->new($cyrus_header, 'w'); + $hf->write_newheader($out, $dlist->as_string()); + + # reconstruct -P should find and fix the missing uniqueid + $self->{instance}->getsyslog(); + $self->{instance}->run_command({ cyrus => 1 }, + 'reconstruct', '-P', $cyrus_header); + + # should not have existed in cyrus.header, get from path + $self->assert_syslog_matches( + $self->{instance}, + qr{mailbox header had no uniqueid, setting from path} + ); + + # bring service back up + $self->{instance}->start(); + + # header should have the same uniqueid as before + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + $hf = Cyrus::HeaderFile->new_file($cyrus_header); + $self->assert_str_equals($uniqueid, $hf->{header}->{UniqueId}); + + # mbentry should have the same uniqueid as before + (undef, $mbentry) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $N]); + $dlist = Cyrus::DList->parse_string($mbentry); + $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + + # $I entry should be back + my ($key, $value) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $I]); + $self->assert_str_equals($I, $key); + $dlist = Cyrus::DList->parse_string($value); + $hash = $dlist->as_perl(); + $self->assert_str_equals("user\x1fcassandane", $hash->{N}); +} + +sub test_reconstruct_uniqueid_from_header_uuidmb + :min_version_3_7 :NoStartInstances +{ + my ($self) = @_; + my $entry = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + + # first start will set up cassandane user + $self->_start_instances(); + my $basedir = $self->{instance}->get_basedir(); + my $mailboxes_db = "$basedir/conf/mailboxes.db"; + $self->assert(-f $mailboxes_db, "$mailboxes_db not present"); + + # find out the uniqueid of the inbox + my $imaptalk = $self->{store}->get_client(); + my $res = $imaptalk->getmetadata("INBOX", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $uniqueid = $res->{INBOX}{$entry}; + $self->assert_not_null($uniqueid); + $imaptalk->logout(); + undef $imaptalk; + + # stop service while tinkering + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + + # get header path + my $cyrus_header = $self->{instance}->folder_to_directory('INBOX') + . '/cyrus.header'; + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + + # lose that uniqueid from mailboxes.db + my $I = "I$uniqueid"; + my $N = "Nuser\x1fcassandane"; + $self->{instance}->run_dbcommand($mailboxes_db, "twoskip", + [ 'DELETE', $I ]); + my (undef, $mbentry) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $N]); + my $dlist = Cyrus::DList->parse_string($mbentry); + my $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + $hash->{I} = undef; + $dlist = Cyrus::DList->new_perl('', $hash); + $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + [ 'SET', $N, $dlist->as_string() ]); + + my %updated = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", ['SHOW']); + xlog "updated mailboxes.db: " . Dumper \%updated; + + # reconstruct -P should find and fix the missing uniqueid + $self->{instance}->getsyslog(); + $self->{instance}->run_command({ cyrus => 1 }, + 'reconstruct', '-P', $cyrus_header); + if ($self->{instance}->{have_syslog_replacement}) { + my $syslog = join(q{}, $self->{instance}->getsyslog()); + + # should have still existed in cyrus.header + $self->assert_does_not_match(qr{mailbox header had no uniqueid}, + $syslog); + + # expect to find the log line + $self->assert_matches(qr{setting mbentry uniqueid from header}, + $syslog); + } + + # bring service back up + $self->{instance}->start(); + + # header should have the same uniqueid as before + $self->assert(-f $cyrus_header, "couldn't find cyrus.header file"); + my $hf = Cyrus::HeaderFile->new_file($cyrus_header); + $self->assert_str_equals($uniqueid, $hf->{header}->{UniqueId}); + + # mbentry should have the same uniqueid as before + (undef, $mbentry) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $N]); + $dlist = Cyrus::DList->parse_string($mbentry); + $hash = $dlist->as_perl(); + $self->assert_str_equals($uniqueid, $hash->{I}); + + # $I entry should be back + my ($key, $value) = $self->{instance}->run_dbcommand( + $mailboxes_db, "twoskip", + ['SHOW', $I]); + $self->assert_str_equals($I, $key); + $dlist = Cyrus::DList->parse_string($value); + $hash = $dlist->as_perl(); + $self->assert_str_equals("user\x1fcassandane", $hash->{N}); +} + +sub test_downgrade_upgrade +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add two messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{A}->set_attributes(id => 1, + uid => 1, + flags => []); + $msg{B} = $self->make_message('Message B'); + $msg{B}->set_attributes(id => 2, + uid => 2, + flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \\Seen on message A"; + my $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + xlog $self, "Clear \\Seen on message A"; + $res = $talk->store('1', '-flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [] }}, $res); + $msg{A}->set_attribute(flags => []); + $self->check_messages(\%msg); + + xlog $self, "Set \\Seen on message A again"; + $res = $talk->store('1', '+flags', '(\\Seen)'); + $self->assert_deep_equals({ '1' => { 'flags' => [ '\\Seen' ] }}, $res); + $msg{A}->set_attribute(flags => ['\\Seen']); + $self->check_messages(\%msg); + + for my $version (12, 14, 16, 'max') { + xlog $self, "Set to version $version"; + $self->{instance}->run_command({ cyrus => 1 }, 'reconstruct', '-V', $version); + + xlog $self, "Reconnect, \\Seen should still be on message A"; + $self->{store}->disconnect(); + $self->{store}->connect(); + $self->{store}->_select(); + $self->check_messages(\%msg); + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/RelocateById.pm b/cassandane/Cassandane/Cyrus/RelocateById.pm new file mode 100644 index 0000000000..711462d1c5 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/RelocateById.pm @@ -0,0 +1,290 @@ +#!/usr/bin/perl +# +# Copyright (c) 2022-2022 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::RelocateById; +use strict; +use warnings; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; + +sub new +{ + my ($class, @args) = @_; + my $config = Cassandane::Config->default()->clone(); + $config->set( + conversations => 'yes', + mailbox_legacy_dirs => 'yes', + delete_mode => 'delayed', + unixhierarchysep => 'no', # XXX need a :NoUnixHierarchySep + ); + return $class->SUPER::new({ + config => $config, + adminstore => 1, + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_user_nomatch + :min_version_3_6 +{ + my ($self) = @_; + + my $errfile = $self->{instance}->get_basedir() . '/relocate.err'; + + my $user = 'nonexistent'; + + $self->{instance}->run_command({ + cyrus => 1, + redirects => { + stderr => $errfile, + }, + }, 'relocate_by_id', '-u', $user); + + my $output = slurp_file($errfile); + + # better complain if the user requested doesn't exist! + $self->assert_matches(qr{$user: user not found}, $output); +} + +sub test_mailbox_nomatch + :min_version_3_6 +{ + my ($self) = @_; + + my $errfile = $self->{instance}->get_basedir() . '/relocate.err'; + + my $mailbox = 'user.nonexistent'; + + $self->{instance}->run_command({ + cyrus => 1, + redirects => { + stderr => $errfile, + }, + }, 'relocate_by_id', $mailbox); + + my $output = slurp_file($errfile); + + # better complain if the mailbox requested doesn't exist! + $self->assert_matches(qr{$mailbox: mailbox not found}, $output); +} + +sub test_mailbox_inbox_domain + :min_version_3_6 :NoAltNamespace :VirtDomains +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser\@example.com"; + my $subfolder = "user.magicuser.foo\@example.com"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, + 'relocate_by_id', "user.magicuser\@example.com" ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); +} + +sub test_mailbox_inbox_nodomain + :min_version_3_6 +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser"; + my $subfolder = "user.magicuser.foo"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, + 'relocate_by_id', "user.magicuser" ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); +} + +sub test_mailbox_shared_domain + :min_version_3_6 :NoAltNamespace :VirtDomains +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $mbox = "shared.magic\@example.com"; + my $subfolder = "shared.magic.foo\@example.com"; + + $admintalk->create($mbox); + $admintalk->setacl($mbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magic/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, + 'relocate_by_id', $mbox ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magic/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this hierarchy"; + $self->assert_equals(0, scalar @files); +} + +sub test_mailbox_shared_nodomain + :min_version_3_6 +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $mbox = "shared.magic"; + my $subfolder = "shared.magic.foo"; + + $admintalk->create($mbox); + $admintalk->setacl($mbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magic/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, + 'relocate_by_id', $mbox ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magic/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Rename.pm b/cassandane/Cassandane/Cyrus/Rename.pm new file mode 100644 index 0000000000..0b9132b884 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Rename.pm @@ -0,0 +1,1001 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Rename; +use strict; +use warnings; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +Cassandane::Cyrus::TestCase::magic(MetaPartition => sub { + shift->config_set( + 'metapartition-default' => '@basedir@/meta', + 'metapartition_files' => 'header index' + ); +}); + + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test LSUB behaviour +# +sub test_rename_asuser +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.user-src") || die; + $self->{store}->set_folder("INBOX.user-src"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + $imaptalk->select("INBOX.user-src") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $imaptalk->rename("INBOX.user-src", "INBOX.user-dst") || die; + $imaptalk->select("INBOX.user-dst") || die; + my @postdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(1, scalar @postdata); +} + +sub test_xrename + :min_version_3_8_2 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.src") || die; + $self->{store}->set_folder("INBOX.src"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + $imaptalk->select("INBOX.src") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $imaptalk->_imap_cmd('XRENAME', 0, '', "INBOX.src", "INBOX.dst"); + $imaptalk->select("INBOX.dst") || die; + my @postdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(1, scalar @postdata); +} + +# +# Test Bug #3586 - rename subfolders +# +sub test_rename_subfolder +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.user-src.subdir") || die; + $self->{store}->set_folder("INBOX.user-src.subdir"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + $imaptalk->select("INBOX.user-src.subdir") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $imaptalk->rename("INBOX.user-src", "INBOX.user-dst") || die; + $imaptalk->select("INBOX.user-dst.subdir") || die; + my @postdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(1, scalar @postdata); +} + +# +# Test Deep rename (intermediates) +# +sub test_rename_deep_subfolder +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.user-src.a.b.c.subdir") || die; + $self->{store}->set_folder("INBOX.user-src.a.b.c.subdir"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + $imaptalk->select("INBOX.user-src.a.b.c.subdir") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $imaptalk->rename("INBOX.user-src", "INBOX.user-dst") || die; + $imaptalk->select("INBOX.user-dst.a.b.c.subdir") || die; + my @postdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(1, scalar @postdata); +} + +# +# Test Deep rename inside a user (intermediates) +# +sub test_rename_user_deep_subfolder +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.user-src.a.b.c.subdir") || die; + $self->{store}->set_folder("INBOX.user-src.a.b.c.subdir"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + $imaptalk->select("INBOX.user-src.a.b.c.subdir") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $imaptalk->rename("INBOX.user-src.a", "INBOX.user-src.z") || die; + $imaptalk->select("INBOX.user-src.z.b.c.subdir") || die; + my @postdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(1, scalar @postdata); +} + +# +# Test big conversation rename +# +sub test_rename_user_bigconversation + :AllowMoves :Conversations :min_version_3_0 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Test user rename with a big conversation"; + + my %exp; + + $admintalk->create("user.cassandane.foo") || die; + $admintalk->create("user.cassandane.bar") || die; + $admintalk->create("user.cassandane.foo.sub") || die; + + $self->{store}->set_folder("INBOX.foo"); + $self->{store}->set_fetch_attributes('uid'); + + $exp{A} = $self->make_message("Message A"); + + for (1..200) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + } + + $self->check_conversations(); + + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert(not $admintalk->get_last_error()); + + $res = $admintalk->select("user.newuser.foo.sub"); + $self->assert(not $admintalk->get_last_error()); + $self->check_conversations(); +} + +# +# Test big conversation rename +# +sub test_rename_user_midsizeconversation + :AllowMoves :Conversations :min_version_3_0 :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Test user rename with a big conversation"; + + my %exp; + + $admintalk->create("user.cassandane.foo") || die; + $admintalk->create("user.cassandane.bar") || die; + $admintalk->create("user.cassandane.foo.sub") || die; + + $self->{store}->set_folder("INBOX.foo"); + $self->{store}->set_fetch_attributes('uid', 'cid'); + + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + for (1..80) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + + $self->check_conversations(); + + $self->check_messages(\%exp, keyed_on => 'uid'); + + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert(not $admintalk->get_last_error()); + + $res = $admintalk->select("user.newuser.foo.sub"); + $self->assert(not $admintalk->get_last_error()); + + $self->{adminstore}->set_folder("user.newuser.foo"); + $self->{adminstore}->set_fetch_attributes('uid', 'cid'); + $self->check_messages(\%exp, keyed_on => 'uid', store => $self->{adminstore}); + + $self->check_conversations(); +} + +# +# Test big conversation rename +# +sub test_rename_bigconversation + :Conversations :min_version_3_0 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my %exp; + + $imaptalk->create("INBOX.user-src.subdir") || die; + $self->{store}->set_folder("INBOX.user-src.subdir"); + $self->{store}->set_fetch_attributes('uid'); + + $exp{A} = $self->make_message("Message A"); + + for (1..200) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + } + + $imaptalk->select("INBOX.user-src.subdir") || die; + + $self->check_conversations(); + + $imaptalk->rename("INBOX.user-src", "INBOX.user-dst") || die; + $imaptalk->select("INBOX.user-dst.subdir") || die; + + $self->check_conversations(); +} + +# +# Test mid-sized conversation rename +# +sub test_rename_midsizeconversation + :Conversations :min_version_3_0 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my %exp; + + $imaptalk->create("INBOX.user-src.subdir") || die; + $self->{store}->set_folder("INBOX.user-src.subdir"); + $self->{store}->set_fetch_attributes('uid', 'cid'); + + $exp{A} = $self->make_message("Message A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + for (1..80) { + $exp{"A$_"} = $self->make_message("Re: Message A", references => [ $exp{A} ]); + $exp{"A$_"}->set_attributes(uid => 1+$_, cid => $exp{A}->make_cid()); + } + $self->check_messages(\%exp, keyed_on => 'uid'); + + $self->check_conversations(); + + $imaptalk->select("INBOX.user-src.subdir") || die; + + $imaptalk->rename("INBOX.user-src", "INBOX.user-dst") || die; + $imaptalk->select("INBOX.user-dst.subdir") || die; + + $self->{store}->set_folder("INBOX.user-dst.subdir"); + $self->check_messages(\%exp, keyed_on => 'uid'); + + $self->check_conversations(); +} + +# +# Test Bug #3634 - rename inbox -> inbox.sub +# +sub test_rename_inbox + :Conversations :min_version_3_0 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->{store}->set_folder("INBOX"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + + $imaptalk->select("INBOX") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $self->check_conversations(); + + my $res = $imaptalk->status("INBOX", ['mailboxid']); + my $oldInboxId = $res->{mailboxid}[0]; + + $imaptalk->rename("INBOX", "INBOX.dst") || die; + + $imaptalk->select("INBOX") || die; + my @postinboxdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(0, scalar @postinboxdata); + + $imaptalk->select("INBOX.dst") || die; + my @postdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(1, scalar @postdata); + + $self->check_conversations(); + + # older cyrus may not support mailboxid, we don't need to test + # for ID change in that case + if ($oldInboxId) { + $res = $imaptalk->status("INBOX", ['mailboxid']); + my $newInboxId = $res->{mailboxid}[0]; + + $res = $imaptalk->status("INBOX.dst", ['mailboxid']); + my $dstId = $res->{mailboxid}[0]; + + $self->assert_str_equals($oldInboxId, $newInboxId); + $self->assert_str_not_equals($oldInboxId, $dstId); + } +} + +sub test_rename_inbox_unselected + :Conversations :min_version_3_7 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->{store}->set_folder("INBOX"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + + $imaptalk->select("INBOX") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $self->check_conversations(); + + my $res = $imaptalk->status("INBOX", ['mailboxid']); + my $oldInboxId = $res->{mailboxid}[0]; + + # Unselect the mailbox to release Cyrus index state and test different code path + $imaptalk->unselect() || die; + + $imaptalk->rename("INBOX", "INBOX.dst") || die; + + $imaptalk->select("INBOX") || die; + my @postinboxdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(0, scalar @postinboxdata); + + $imaptalk->select("INBOX.dst") || die; + my @postdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(1, scalar @postdata); + + $self->check_conversations(); + + # older cyrus may not support mailboxid, we don't need to test + # for ID change in that case + if ($oldInboxId) { + $res = $imaptalk->status("INBOX", ['mailboxid']); + my $newInboxId = $res->{mailboxid}[0]; + + $res = $imaptalk->status("INBOX.dst", ['mailboxid']); + my $dstId = $res->{mailboxid}[0]; + + $self->assert_str_equals($oldInboxId, $newInboxId); + $self->assert_str_not_equals($oldInboxId, $dstId); + } +} + +# +# Test evil INBOX rename possibilities +# +sub test_rename_inbox_intermediate + :Conversations :min_version_3_1 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->{store}->set_folder("INBOX"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + + $imaptalk->select("INBOX") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $imaptalk->create("INBOX.foo.bar"); + $imaptalk->rename("INBOX.foo", "INBOX") && die "rename should fail"; + + $imaptalk->select("INBOX") || die; + my @postinboxdata = $imaptalk->search("KEYWORD" => "\$NotJunk"); + $self->assert_num_equals(1, scalar @postinboxdata); +} + +# +# Test rename a folder with subfolders +# +sub test_rename_withsub_dom +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.a"); + $imaptalk->create("INBOX.b"); + $imaptalk->create("INBOX.c"); + + $self->{store}->set_folder("INBOX.c"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + + $imaptalk->select("INBOX.c") || die; + my @predata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $imaptalk->rename("INBOX.c", "INBOX.b.c") || die; + $imaptalk->rename("INBOX.b", "INBOX.a.b") || die; + + $imaptalk->select("INBOX.a.b.c") || die; + my @postdata = $imaptalk->search("SEEN"); + $self->assert_num_equals(1, scalar @postdata); +} + +# +# Test rename a folder with subfolders, domain user +# +sub test_rename_withsub + :VirtDomains +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $self->{instance}->create_user("renameuser\@example.com"); + my $domstore = $self->{instance}->get_service('imap')->create_store(username => "renameuser\@example.com"); + my $domtalk = $domstore->get_client(); + + $domtalk->create("INBOX.a"); + $domtalk->create("INBOX.b"); + $domtalk->create("INBOX.c"); + + $domstore->set_folder("INBOX.c"); + $domstore->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $domstore->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $domstore->write_end(); + + $domtalk->select("INBOX.c") || die; + my @predata = $domtalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $domtalk->rename("INBOX.c", "INBOX.b.c") || die; + $domtalk->rename("INBOX.b", "INBOX.a.b") || die; + + $domtalk->select("INBOX.a.b.c") || die; + my @postdata = $domtalk->search("SEEN"); + $self->assert_num_equals(1, scalar @postdata); +} + +sub test_rename_conversations + :Conversations :VirtDomains :min_version_3_0 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $self->{instance}->create_user("renameuser\@example.com"); + my $domstore = $self->{instance}->get_service('imap')->create_store(username => "renameuser\@example.com"); + my $domtalk = $domstore->get_client(); + + $domtalk->create("INBOX.a"); + $domtalk->create("INBOX.b"); + $domtalk->create("INBOX.c"); + + $domstore->set_folder("INBOX.c"); + $domstore->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $domstore->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $domstore->write_end(); + + $domtalk->select("INBOX.c") || die; + my @predata = $domtalk->search("SEEN"); + $self->assert_num_equals(1, scalar @predata); + + $domtalk->rename("INBOX.c", "INBOX.b.c") || die; + $domtalk->rename("INBOX.b", "INBOX.a.b") || die; + + $domtalk->select("INBOX.a.b.c") || die; + my @postdata = $domtalk->search("SEEN"); + $self->assert_num_equals(1, scalar @postdata); +} + +sub get_partition +{ + my ($talk, $folder) = @_; + + my $key = '/shared/vendor/cmu/cyrus-imapd/partition'; + my $md = $talk->getmetadata($folder, $key); + + return undef if $talk->get_last_completion_response() ne 'ok'; + return $md->{$folder}->{$key}; +} + +sub test_rename_user + :Partition2 :AllowMoves +{ + my ($self) = @_; + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Test Cyrus extension which renames a user to a different partition"; + + # set up a sub mailbox + $admintalk->create('user.cassandane.submailbox'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + # rename to same name (only) should fail + $admintalk->rename('user.cassandane', 'user.cassandane'); + $self->assert_str_equals('no', + $admintalk->get_last_completion_response()); + $self->assert_matches(qr{Mailbox already exists}, + $admintalk->get_last_error()); + + # rename to same name with new partition should succeed + $admintalk->rename('user.cassandane', 'user.cassandane', 'p2'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + # rename to same name with same partition should fail + $admintalk->rename('user.cassandane', 'user.cassandane', 'p2'); + $self->assert_str_equals('no', + $admintalk->get_last_completion_response()); + $self->assert_matches(qr{Mailbox already exists}, + $admintalk->get_last_error()); + + # rename to new name with new partition should fail + $admintalk->rename('user.cassandane', 'user.bob', 'default'); + $self->assert_str_equals('no', + $admintalk->get_last_completion_response()); + $self->assert_matches(qr{Cross-server or cross-partition move w/rename not supported}, + $admintalk->get_last_error()); + + # rename to new name without partition should not change partition + my $before_partition = get_partition($admintalk, 'user.cassandane'); + $self->assert_not_null($before_partition); + $admintalk->rename('user.cassandane', 'user.bob'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + my $after_partition = get_partition($admintalk, 'user.bob'); + $self->assert_equals($before_partition, $after_partition); + my $sub_partition = get_partition($admintalk, 'user.bob.submailbox'); + $self->assert_equals($after_partition, $sub_partition); + + # XXX rename to new name with explicit current partition should succeed + # XXX not implemented, but would be nice :) +# $before_partition = get_partition($admintalk, 'user.bob'); +# $self->assert_not_null($before_partition); +# $admintalk->rename('user.bob', 'user.cassandane', $before_partition); +# $self->assert_str_equals('ok', +# $admintalk->get_last_completion_response()); +# $after_partition = get_partition($admintalk, 'user.cassandane'); +# $self->assert_str_equals($before_partition, $after_partition); +} + +sub test_rename_deepuser + :AllowMoves :Replication :SyncLog :needs_component_replication +{ + my ($self) = @_; + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Test user rename"; + + $admintalk->create("user.cassandane.foo") || die; + $admintalk->create("user.cassandane.bar") || die; + $admintalk->create("user.cassandane.bar.sub folder") || die; + + # replicate and check initial state + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication('cassandane'); + unlink($synclogfname); + + # n.b. run_replication dropped all our store connections... + $admintalk = $self->{adminstore}->get_client(); + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert(not $admintalk->get_last_error()); + + $res = $admintalk->select("user.newuser.bar.sub folder"); + $self->assert(not $admintalk->get_last_error()); + + # replicate and check the renames + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication('newuser'); +} + +sub test_rename_user_sieve + :AllowMoves :Replication :SyncLog :needs_component_sieve + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "Test user rename with Sieve script"; + + my $user = 'cassandane'; + my $newuser = 'newuser'; + my $scriptname = 'test1'; + my $scriptcontent = <<'EOF'; +keep; +EOF + + # install sieve script on master + $self->{instance}->install_sieve_script($scriptcontent, name=>$scriptname); + + # verify that sieve script exists on master + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 0); + + # check if we have the new-style sieve + my $mailboxesdb = $self->{instance}->read_mailboxes_db(); + my $have_new_sieve = $mailboxesdb->{'user.cassandane.#sieve'} ? 1 : 0; + xlog "Checking for new sieve resulted in $have_new_sieve"; + + # replicate and check initial state + $self->run_replication(); + $self->check_replication($user); + + # verify that sieve script exists on both master and replica + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 1); + + if ($have_new_sieve) { + xlog "Checking that sieve mailbox is created on replica"; + my $mailboxesdb = $self->{replica}->read_mailboxes_db(); + $self->assert_not_null($mailboxesdb->{'user.cassandane.#sieve'}); + } + + # rename user + my $admintalk = $self->{adminstore}->get_client(); + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert(not $admintalk->get_last_error()); + + # verify that sieve script exists on master + $self->assert_sieve_exists($self->{instance}, $newuser, $scriptname, 1); + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 1); + + # replicate and check the renames + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication($newuser); + + # verify that sieve script exists replica + $self->assert_sieve_exists($self->{replica}, $newuser, $scriptname, 1); + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 1); + + if ($have_new_sieve) { + xlog "Checking that sieve mailboxes are renamed at both ends"; + my $mdb = $self->{instance}->read_mailboxes_db(); + my $rdb = $self->{replica}->read_mailboxes_db(); + $self->assert_null($mdb->{'user.cassandane.#sieve'}); + $self->assert_null($rdb->{'user.cassandane.#sieve'}); + $self->assert_not_null($mdb->{'user.newuser.#sieve'}); + $self->assert_not_null($rdb->{'user.newuser.#sieve'}); + } +} + +sub test_rename_paths + :MetaPartition :NoAltNameSpace +{ + my ($self) = @_; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.rename-src.sub") || die; + + $self->{store}->set_folder("INBOX.rename-src.sub"); + $self->{store}->write_begin(); + my $msg1 = $self->{gen}->generate(subject => "subject 1"); + $self->{store}->write_message($msg1, flags => ["\\Seen", "\$NotJunk"]); + $self->{store}->write_end(); + + # check source files exist + my $srcdata = $self->{instance}->run_mbpath('user.cassandane.rename-src.sub'); + -d "$srcdata->{data}" || die; + -d "$srcdata->{meta}" || die; + -f "$srcdata->{meta}/cyrus.header" || die; + -f "$srcdata->{meta}/cyrus.index" || die; + -f "$srcdata->{data}/cyrus.cache" || die; + -f "$srcdata->{data}/1." || die; + + # and target don't + my $dstdata = eval { $self->{instance}->run_mbpath('user.cassandane.rename-dst.sub') }; + $self->assert(not $dstdata or (not -d $dstdata->{data} and not -d $dstdata->{meta})); + + $imaptalk->rename("INBOX.rename-src.sub", "INBOX.rename-dst.sub"); + + # check dest files exist + $dstdata = $self->{instance}->run_mbpath('user.cassandane.rename-dst.sub'); + -d "$dstdata->{data}" || die; + -d "$dstdata->{meta}" || die; + -f "$dstdata->{meta}/cyrus.header" || die; + -f "$dstdata->{meta}/cyrus.index" || die; + -f "$dstdata->{data}/cyrus.cache" || die; + -f "$dstdata->{data}/1." || die; + + # and src don't any more (unless UUID when the paths are the same!) + $srcdata->{data} ne $dstdata->{data} && -d "$srcdata->{data}" && die; + $srcdata->{meta} ne $dstdata->{meta} && -d "$srcdata->{meta}" && die; +} + +sub test_rename_deepuser_unixhs + :AllowMoves :Replication :SyncLog :UnixHierarchySep + :needs_component_replication +{ + my ($self) = @_; + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Test user rename"; + + $admintalk->create("user/cassandane/foo") || die; + $admintalk->create("user/cassandane/bar") || die; + $admintalk->create("user/cassandane/bar/sub") || die; + + # replicate and check initial state + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication('cassandane'); + unlink($synclogfname); + + # n.b. run_replication dropped all our store connections... + $admintalk = $self->{adminstore}->get_client(); + my $res = $admintalk->rename('user/cassandane', 'user/new.user'); + $self->assert(not $admintalk->get_last_error()); + + $res = $admintalk->select("user/new.user/bar/sub"); + $self->assert(not $admintalk->get_last_error()); + + # replicate and check the renames + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication('new.user'); +} + +sub _match_intermediates +{ + my ($self, %expect) = @_; + my @lines = $self->{instance}->getsyslog(); + #'Aug 23 12:34:20 bat 0234200101/ctl_cyrusdb[14527]: mboxlist: creating intermediate with children: user.cassandane.a (ec10f137-1bee-443e-8cb2-c6c893463b0a)', + #'Aug 23 12:34:20 bat 0234200101/ctl_cyrusdb[14527]: mboxlist: deleting intermediate with no children: user.cassandane.hanging (b13ba9d4-9d40-4474-911f-77346a73d747)', + for (@lines) { + if (m/mboxlist: creating intermediate with children: (.*?)($| \()/) { + my $mbox = $1; + $self->assert(exists $expect{$mbox}, "didn't expect touch of $mbox"); + my $val = delete $expect{$mbox}; + $self->assert(!$val, "create when expected delete of $mbox"); + } + if (m/mboxlist: deleting intermediate with no children: (.*?)($| \()/) { + my $mbox = $1; + $self->assert(exists $expect{$mbox}, "didn't expect touch of $mbox"); + my $val = delete $expect{$mbox}; + $self->assert(!!$val, "delete when expected create of $mbox"); + } + } + use Data::Dumper; + $self->assert_num_equals(0, scalar keys %expect, "EXPECTED TO SEE " . Dumper(\%expect, \@lines)); +} + +sub _dbset +{ + my ($self, $key, $value) = @_; + $self->assert_str_equals('ok', $self->{instance}->run_dbcommand_cb( + sub { die "got a response!" }, + "$self->{instance}->{basedir}/conf/mailboxes.db", + 'twoskip', + defined($value) + ? ['SET', $key => $value] + : ['DELETE', $key], + )); +} + +sub test_intermediate_cleanup + :min_version_3_1 :max_version_3_4 :NoAltNameSpace :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.a.b.c.subdir") || die; + $imaptalk->create("INBOX.x.y.z.subdir") || die; + $imaptalk->create("INBOX.INBOX.subinbox") || die; + $imaptalk->create("INBOX.INBOX.a.b") || die; + + _match_intermediates($self, + 'user.cassandane.a' => undef, + 'user.cassandane.a.b' => undef, + 'user.cassandane.a.b.c' => undef, + 'user.cassandane.x' => undef, + 'user.cassandane.x.y' => undef, + 'user.cassandane.x.y.z' => undef, + 'user.cassandane.INBOX.a' => undef, + ); + + $imaptalk->create("INBOX.x.y"); + + _match_intermediates($self); + + $imaptalk->delete("INBOX.x.y.z.subdir"); + + _match_intermediates($self, + 'user.cassandane.x.y.z' => 1, + ); + + $imaptalk->delete("INBOX.x.y"); + + _match_intermediates($self, + 'user.cassandane.x' => 1, + ); + + $imaptalk->delete("INBOX.INBOX.a.b"); + + _match_intermediates($self, + 'user.cassandane.INBOX.a' => 1, + ); + + _dbset($self, 'user.cassandane.old', '%(I 66eb299a-35a8-423d-a0a6-90cbacfd153a T di C 1 F 1 M 1538674002)'); + + $imaptalk->create("INBOX.old.foo"); + + _match_intermediates($self, + 'user.cassandane.old' => undef, + ); + + $imaptalk->delete("INBOX.old.foo"); + + _match_intermediates($self, + 'user.cassandane.old' => 1, + ); + + my %set = ( + 'user.cassandane.hanging' => '%(I b13ba9d4-9d40-4474-911f-77346a73d747 T i C 1 F 1 M 1538674002)', + 'user.cassandane.a' => undef, + 'user.cassandane.a.b' => undef, + 'user.cassandane.x' => '%(I 7c89e632-04a0-4560-9a59-18b07c13ddff T i C 1 F 1 M 1538674002)', + 'user.cassandane.x.y' => '%(I 385d7a66-6173-4b5e-9340-0301ac55b373 T i C 1 F 1 M 1538674002)', + ); + + # NOTE: This is all very specific! + foreach my $key (keys %set) { + _dbset($self, $key, $set{$key}); + } + + $self->{instance}->getsyslog(); + + # perform startup magic + $self->{instance}->run_command( + { cyrus => 1 }, + 'ctl_cyrusdb', '-r', + ); + + _match_intermediates($self, %set); +} + +sub test_rename_user_sharee + :AllowMoves :NoAltNameSpace :ReverseACLs :min_version_3_6 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Test user rename with shares"; + + my %exp; + + $admintalk->create("user.foo") || die; + $admintalk->create("user.foo.shared") || die; + $admintalk->setacl("user.foo.shared", 'cassandane' => 'lrs') || die; + + $admintalk->create("user.foo.#calendars") || die; + $admintalk->create("user.foo.#calendars.events") || die; + $admintalk->setacl("user.foo.#calendars.events", 'cassandane' => 'lrs') || die; + + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create("INBOX.sub"); + $imaptalk->subscribe("INBOX.sub"); + $imaptalk->subscribe("user.foo.shared"); + $imaptalk->subscribe("user.foo.#calendars.events"); + + my $structure = { + 'INBOX' => [ '\\HasChildren' ], + 'INBOX.sub' => [ '\\Subscribed', '\\HasNoChildren' ], + 'user.foo.#calendars.events' => [ '\\Subscribed', '\\HasNoChildren' ], + 'user.foo.shared' => [ '\\Subscribed', '\\HasNoChildren' ], + }; + + + my $list = $imaptalk->list([qw( vendor.cmu-dav )], '', '*', 'return', ['subscribed']); + + $self->assert_mailbox_structure($list, '.', $structure); + + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + my $newstore = $self->{instance}->get_service('imap')->create_store( + username => "newuser"); + my $newtalk = $newstore->get_client(); + + $list = $newtalk->list([qw( vendor.cmu-dav )], '', '*', 'return', ['subscribed']); + + $self->assert_mailbox_structure($list, '.', $structure); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Replace.pm b/cassandane/Cassandane/Cyrus/Replace.pm new file mode 100644 index 0000000000..8cb0cb7f36 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Replace.pm @@ -0,0 +1,127 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2023 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Replace; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Generator; +use Cassandane::MessageStoreFactory; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({adminstore => 1}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_replace_same_mailbox + :min_version_3_9 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my %exp; + $exp{A} = $self->make_message("Message A", store => $self->{store}); + $self->check_messages(\%exp); + + $talk->select('INBOX'); + + %exp = (); + $exp{B} = $self->{gen}->generate(subject => "Message B"); + + # REPLACE + $talk->_imap_cmd('REPLACE', 0, '', "1", "INBOX", + { Literal => $exp{B}->as_string() }); + $self->check_messages(\%exp); + + %exp = (); + $exp{C} = $self->{gen}->generate(subject => "Message C"); + + # UID REPLACE + $talk->_imap_cmd('UID', 0, '', 'REPLACE', "2", "INBOX", + "(\\flagged)", " 7-Feb-1994 22:43:04 -0800", + { Literal => $exp{C}->as_string() }); + $self->check_messages(\%exp); +} + +sub test_replace_different_mailbox + :min_version_3_9 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my %exp; + $exp{A} = $self->make_message("Message A", store => $self->{store}); + $self->check_messages(\%exp); + + $talk->create("INBOX.foo"); + $talk->select('INBOX'); + + %exp = (); + $exp{B} = $self->{gen}->generate(subject => "Message B", uid => 1); + + $talk->_imap_cmd('REPLACE', 0, '', "1", "INBOX.foo", + { Literal => $exp{B}->as_string() }); + $self->check_messages({}); + + $self->{store}->set_folder("INBOX.foo"); + $self->check_messages(\%exp); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Replication.pm b/cassandane/Cassandane/Cyrus/Replication.pm new file mode 100644 index 0000000000..fde337ae0f --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Replication.pm @@ -0,0 +1,105 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Replication; +use strict; +use warnings; +use Data::Dumper; +use DateTime; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Service; +use Cassandane::Config; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ replica => 1, adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# XXX need a test for version 10 mailbox without guids in it! + +#* create mailbox on master with no messages +#* sync_client to get it copied to replica +#* create a message in the mailbox on replica (imaptalk on replica_store) +#* delete the message from the replica (with expunge_mode default or expunge_mode immediate... try both) +#* run sync_client on the master again and make sure it successfully syncs up + +sub assert_user_sub_exists +{ + my ($self, $instance, $user) = @_; + + my $subs = $instance->get_conf_user_file($user, 'sub'); + $self->assert_not_null($subs); + + xlog $self, "Looking for subscriptions file $subs"; + + $self->assert_file_test($subs, '-f'); +} + +sub assert_user_sub_not_exists +{ + my ($self, $instance, $user) = @_; + + my $subs = $instance->get_conf_user_file($user, 'sub'); + return unless $subs; # user might not exist + + xlog $self, "Looking for subscriptions file $subs"; + + $self->assert_not_file_test($subs, '-f'); +} + +use Cassandane::Tiny::Loader 'tiny-tests/Replication'; + +1; diff --git a/cassandane/Cassandane/Cyrus/Search.pm b/cassandane/Cassandane/Cyrus/Search.pm new file mode 100644 index 0000000000..dd60517f0d --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Search.pm @@ -0,0 +1,898 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Search; +use strict; +use warnings; +use Cwd qw(abs_path); +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config->default()->clone(); + $config->set(conversations => 'on'); + return $class->SUPER::new({adminstore => 1, config => $config}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub _fgrep_msgs +{ + my ($msgs, $attr, $s) = @_; + my @res; + + foreach my $msg (values %$msgs) + { + push(@res, $msg->uid()) + if (index($msg->$attr(), $s) >= 0); + } + @res = sort { $a <=> $b } @res; + return \@res; +} + +sub test_from +{ + my ($self) = @_; + + xlog $self, "test SEARCH with the FROM predicate"; + my $talk = $self->{store}->get_client(); + + xlog $self, "append some messages"; + my %exp; + my %from_domains; + my $N = 20; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + my ($dom) = ($msg->from() =~ m/(@[^>]*)>/); + $from_domains{$dom} = 1; + xlog $self, "Message uid " . $msg->uid() . " from domain " . $dom; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + my @found; + foreach my $dom (keys %from_domains) + { + xlog $self, "searching for: FROM $dom"; + my $uids = $talk->search('from', { Quote => $dom }) + or die "Cannot search: $@"; + my $expected_uids = _fgrep_msgs(\%exp, 'from', $dom); + $self->assert_deep_equals($expected_uids, $uids); + map { $found[$_] = 1 } @$uids; + } + + xlog $self, "checking all the message were found"; + for (1..$N) + { + $self->assert($found[$_], + "UID $_ was not returned from a SEARCH"); + } + + xlog $self, "Double-check the messages are still there"; + $self->check_messages(\%exp); +} + +sub test_header_multiple +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $extra_headers = [ + ['x-nice-day-for', 'start again (come on)' ], + ['x-nice-day-for', 'white wedding' ], + ['x-nice-day-for', 'start agaaain' ], + ]; + + my %exp; + $exp{1} = $self->make_message('message 1', + 'extra_headers' => $extra_headers); + $exp{2} = $self->make_message('nice day'); + $self->check_messages(\%exp); + + # make sure a search that doesn't match anything doesn't find anything! + my $uids = $talk->search('header', 'x-nice-day-for', 'cease and desist'); + $self->assert_num_equals(0, scalar @{$uids}); + + # we must be able to find a message by the first header value + $uids = $talk->search('header', 'x-nice-day-for', 'come on'); + $self->assert_num_equals(1, scalar @{$uids}); + $self->assert_deep_equals( [ 1 ], $uids); + + # we must be able to find a message by the last header value + $uids = $talk->search('header', 'x-nice-day-for', 'start agaaain'); + $self->assert_num_equals(1, scalar @{$uids}); + $self->assert_deep_equals( [ 1 ], $uids); + + # we must be able to find a message by some other header value + $uids = $talk->search('header', 'x-nice-day-for', 'white wedding'); + $self->assert_num_equals(1, scalar @{$uids}); + $self->assert_deep_equals( [ 1 ], $uids); + + # we must be able to ever find some other message! + $uids = $talk->search('header', 'subject', 'nice day'); + $self->assert_num_equals(1, scalar @{$uids}); + $self->assert_deep_equals( [ 2 ], $uids); +} + +sub test_esearch + :NoAltNameSpace :needs_search_xapian :Conversations :min_version_3_7 +{ + my ($self) = @_; + + xlog $self, "Create shared folder, writeable by cassandane user"; + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("shared"); + $admintalk->setacl("shared", "cassandane", "lrsip"); + + xlog $self, "Create some personal folders"; + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( INBOX.a INBOX.a.b.c INBOX.d INBOX.d.e INBOX.f )] ], + [ 'subscribe' => [qw( INBOX.a.b INBOX.d )] ], + [ 'subscribe' => [qw( shared )] ], + ]); + + xlog $self, "Remove 'p' right from most personal folders"; + $imaptalk->setacl("INBOX.a", "anyone", "-p"); + $imaptalk->setacl("INBOX.a.b", "anyone", "-p"); + $imaptalk->setacl("INBOX.a.b.c", "anyone", "-p"); + $imaptalk->setacl("INBOX.d", "anyone", "-p"); + $imaptalk->setacl("INBOX.d.e", "anyone", "-p"); + + my $alldata = $imaptalk->list("", "*"); + + $self->assert_mailbox_structure($alldata, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.a' => [qw( \\HasChildren )], + 'INBOX.a.b' => [qw( \\HasChildren )], + 'INBOX.a.b.c' => [qw( \\HasNoChildren )], + 'INBOX.d' => [qw( \\HasChildren )], + 'INBOX.d.e' => [qw( \\HasNoChildren )], + 'INBOX.f' => [qw( \\HasNoChildren )], + 'shared' => [qw( \\HasNoChildren )], + }); + + xlog $self, "Append some emails into the folders"; + my %raw = ( + A => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test A\r +EOF + B => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test B\r +EOF + C => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test C\r +EOF + D => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + E => <<"EOF", +From: \r +To: to\@local\r +Subject: test3\r +Message-Id: \r +In-Reply-To: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test E\r +EOF + F => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test F\r +EOF + G => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +In-Reply-To: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + ); + + $imaptalk->append('INBOX', "()", $raw{A}) || die $@; + $imaptalk->append('INBOX', "()", $raw{B}) || die $@; + $imaptalk->append('INBOX', "()", $raw{C}) || die $@; + $imaptalk->append('INBOX.a', "()", $raw{B}) || die $@; + $imaptalk->append('INBOX.a.b', "()", $raw{C}) || die $@; + $imaptalk->append('INBOX.a.b.c', "()", $raw{D}) || die $@; + $imaptalk->append('INBOX.d', "()", $raw{E}) || die $@; + $imaptalk->append('INBOX.f', "()", $raw{F}) || die $@; + $imaptalk->append('shared', "()", $raw{G}) || die $@; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @results; + my %handlers = + ( + esearch => sub + { + my (undef, $esearch) = @_; + push(@results, $esearch); + }, + ); + + xlog $self, "Search the (un)selected mailbox (should fail)"; + my $res = $imaptalk->_imap_cmd('ESEARCH', 0, 'esearch', + 'IN', '(SELECTED)', + 'subject', 'test'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + xlog $self, "Now select a mailbox"; + $res = $imaptalk->select("INBOX"); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Search the newly selected mailbox"; + @results = (); + $res = $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(SELECTED)', 'RETURN', '(MIN MAX ALL)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(1, scalar @results); + $self->assert_str_equals('INBOX', $results[0][0][3]); + $self->assert_num_equals(1, $results[0][3]); + $self->assert_num_equals(3, $results[0][5]); + $self->assert_str_equals('1:3', $results[0][7]); + + xlog $self, "Search the personal namespace, returning just counts"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(PERSONAL)', 'RETURN', '(COUNT)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(6, scalar @results); + $self->assert_str_equals('INBOX', $results[0][0][3]); + $self->assert_num_equals(3, $results[0][3]); + $self->assert_str_equals('INBOX.a', $results[1][0][3]); + $self->assert_num_equals(1, $results[1][3]); + $self->assert_str_equals('INBOX.a.b', $results[2][0][3]); + $self->assert_num_equals(1, $results[2][3]); + $self->assert_str_equals('INBOX.a.b.c', $results[3][0][3]); + $self->assert_num_equals(1, $results[3][3]); + $self->assert_str_equals('INBOX.d', $results[4][0][3]); + $self->assert_num_equals(1, $results[4][3]); + $self->assert_str_equals('INBOX.f', $results[5][0][3]); + $self->assert_num_equals(1, $results[5][3]); + + xlog $self, "Search the subscribed folders"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(SUBSCRIBED)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(4, scalar @results); + $self->assert_str_equals('INBOX', $results[0][0][3]); + $self->assert_str_equals('INBOX.a.b', $results[1][0][3]); + $self->assert_str_equals('INBOX.d', $results[2][0][3]); + $self->assert_str_equals('shared', $results[3][0][3]); + + xlog $self, "Search the Inboxes (deliverable)"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(INBOXES)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(2, scalar @results); + $self->assert_str_equals('INBOX', $results[0][0][3]); + $self->assert_str_equals('INBOX.f', $results[1][0][3]); + + xlog $self, "Search a subtree"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(SUBTREE INBOX.a)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(3, scalar @results); + $self->assert_str_equals('INBOX.a', $results[0][0][3]); + $self->assert_str_equals('INBOX.a.b', $results[1][0][3]); + $self->assert_str_equals('INBOX.a.b.c', $results[2][0][3]); + + xlog $self, "Search a limited subtree"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(SUBTREE-ONE INBOX.a)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(2, scalar @results); + $self->assert_str_equals('INBOX.a', $results[0][0][3]); + $self->assert_str_equals('INBOX.a.b', $results[1][0][3]); + + xlog $self, "Search a single folder without a match"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(MAILBOXES INBOX.e)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(0, scalar @results); + + xlog $self, "Search a single folder with a match"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(MAILBOXES INBOX.f)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(1, scalar @results); + $self->assert_str_equals('INBOX.f', $results[0][0][3]); + + xlog $self, "Search a multiple folders with only one match)"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(MAILBOXES (INBOX.e INBOX.f))', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(1, scalar @results); + $self->assert_str_equals('INBOX.f', $results[0][0][3]); + + xlog $self, "Search multiple sourcesand make sure there are no duplicates"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(SUBSCRIBED SELECTED SUBTREE-ONE INBOX.a MAILBOXES (INBOX.e shared))', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(5, scalar @results); + $self->assert_str_equals('INBOX', $results[0][0][3]); + $self->assert_str_equals('INBOX.a.b', $results[1][0][3]); + $self->assert_str_equals('INBOX.d', $results[2][0][3]); + $self->assert_str_equals('shared', $results[3][0][3]); + $self->assert_str_equals('INBOX.a', $results[4][0][3]); + + xlog $self, "Fuzzy search the personal namespace"; + @results = (); + $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(PERSONAL)', 'FUZZY', 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(3, scalar @results); + $self->assert_str_equals('INBOX', $results[0][0][3]); + $self->assert_str_equals('INBOX.a', $results[1][0][3]); + $self->assert_str_equals('INBOX.a.b', $results[2][0][3]); +} + +sub test_searchres + :NoAltNameSpace :min_version_3_7 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( INBOX.target )] ], + ]); + + xlog $self, "Append some emails into the folders"; + my %raw = ( + A => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test A\r +EOF + B => <<"EOF", +From: \r +To: to\@local\r +Subject: foo\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test B\r +EOF + C => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test C\r +EOF + D => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + E => <<"EOF", +From: \r +To: to\@local\r +Subject: test3\r +Message-Id: \r +In-Reply-To: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test E\r +EOF + F => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test F\r +EOF + G => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +In-Reply-To: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + ); + + $imaptalk->append('INBOX', "()", $raw{A}) || die $@; + $imaptalk->append('INBOX', "()", $raw{B}) || die $@; + $imaptalk->append('INBOX', "()", $raw{C}) || die $@; + $imaptalk->append('INBOX', "()", $raw{D}) || die $@; + $imaptalk->append('INBOX', "()", $raw{E}) || die $@; + $imaptalk->append('INBOX', "()", $raw{F}) || die $@; + $imaptalk->append('INBOX', "()", $raw{G}) || die $@; + + my @results; + my %handlers = + ( + esearch => sub + { + my (undef, $esearch) = @_; + push(@results, $esearch); + }, + ); + + xlog $self, "Search the (un)selected mailbox (should fail)"; + my $res = $imaptalk->_imap_cmd('SEARCH', 0, 'esearch', + 'RETURN', '(SAVE)', + 'subject', 'test'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + xlog $self, "Now select a mailbox"; + $res = $imaptalk->select("INBOX"); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Search results should be empty"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(0, scalar keys %{$res}); + + xlog $self, "Attempt to Search the newly selected mailbox and others"; + @results = (); + $res = $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'IN', '(SELECTED PERSONAL)', 'RETURN', '(SAVE)', + 'subject', 'test'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + xlog $self, "Search the selected mailbox for minimum and save"; + @results = (); + $res = $imaptalk->_imap_cmd('ESEARCH', 0, \%handlers, + 'RETURN', '(SAVE MIN)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Fetch using the search results"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(1, scalar keys %{$res}); + $self->assert_str_equals('1', $res->{'1'}->{uid}); + + xlog $self, "Search the mailbox for maximum and save"; + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(MAX SAVE)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Fetch using the search results"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(1, scalar keys %{$res}); + $self->assert_str_equals('7', $res->{'7'}->{uid}); + + xlog $self, "Search the mailbox for minimum & maximum and save"; + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(MAX SAVE MIN)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Fetch using the search results"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(2, scalar keys %{$res}); + $self->assert_str_equals('1', $res->{'1'}->{uid}); + $self->assert_str_equals('7', $res->{'7'}->{uid}); + + xlog $self, "Search the mailbox for all and save"; + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(SAVE)', + 'subject', 'test'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Fetch using the search results"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(6, scalar keys %{$res}); + $self->assert_str_equals('1', $res->{'1'}->{uid}); + $self->assert_str_equals('3', $res->{'3'}->{uid}); + $self->assert_str_equals('4', $res->{'4'}->{uid}); + $self->assert_str_equals('5', $res->{'5'}->{uid}); + $self->assert_str_equals('6', $res->{'6'}->{uid}); + $self->assert_str_equals('7', $res->{'7'}->{uid}); + + xlog $self, "Store using the search results"; + $res = $imaptalk->store('$', '+flags', '(\\Flagged)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(6, scalar keys %{$res}); + $self->assert_str_equals('1', $res->{'1'}->{uid}); + $self->assert_str_equals('3', $res->{'3'}->{uid}); + $self->assert_str_equals('4', $res->{'4'}->{uid}); + $self->assert_str_equals('5', $res->{'5'}->{uid}); + $self->assert_str_equals('6', $res->{'6'}->{uid}); + $self->assert_str_equals('7', $res->{'7'}->{uid}); + + xlog $self, "Copy using the search results"; + $res = $imaptalk->copy('$', 'INBOX.target'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $res = $imaptalk->get_response_code('copyuid'); + $self->assert_str_equals('1,3:7', $res->[1]); + $self->assert_str_equals('1:6', $res->[2]); + + xlog $self, "Expunge the first message"; + $res = $imaptalk->store('1', '+flags', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $res = $imaptalk->expunge(); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Fetch using the search results"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(5, scalar keys %{$res}); + $self->assert_str_equals('3', $res->{'2'}->{uid}); + $self->assert_str_equals('4', $res->{'3'}->{uid}); + $self->assert_str_equals('5', $res->{'4'}->{uid}); + $self->assert_str_equals('6', $res->{'5'}->{uid}); + $self->assert_str_equals('7', $res->{'6'}->{uid}); + + xlog $self, "Expunge the middle message in the search results range"; + $res = $imaptalk->store('4', '+flags', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $res = $imaptalk->expunge(); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Fetch using the search results"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(4, scalar keys %{$res}); + $self->assert_str_equals('3', $res->{'2'}->{uid}); + $self->assert_str_equals('4', $res->{'3'}->{uid}); + $self->assert_str_equals('6', $res->{'4'}->{uid}); + $self->assert_str_equals('7', $res->{'5'}->{uid}); + + xlog $self, "Expunge the 1st message in the 1st range and the last in the 2nd"; + $res = $imaptalk->store('2,5', '+flags', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $res = $imaptalk->expunge(); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Fetch using the search results"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(2, scalar keys %{$res}); + $self->assert_str_equals('4', $res->{'2'}->{uid}); + $self->assert_str_equals('6', $res->{'3'}->{uid}); + + xlog $self, "Search the mailbox for a from address in the saved results"; + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(SAVE ALL)', + 'uid', '$', 'from', 'foo'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Fetch using the search results"; + $res = $imaptalk->fetch('$', 'UID'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(1, scalar keys %{$res}); + $self->assert_str_equals('6', $res->{'3'}->{uid}); +} + +sub test_uidsearch_empty + :min_version_3_9 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + $imap->create('INBOX.test'); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @results; + my %handlers = + ( + esearch => sub + { + my (undef, $esearch) = @_; + push(@results, $esearch); + }, + ); + + $imap->select('INBOX.test'); + $imap->_imap_cmd('UID', 0, \%handlers, + 'SEARCH', 'RETURN', '(ALL SAVE COUNT) UID 1:*'); + $self->assert_num_equals(1, scalar @results); + $self->assert_str_equals('UID', $results[0][1]); + $self->assert_str_equals('COUNT', $results[0][2]); + $self->assert_str_equals('0', $results[0][3]); +} + +sub test_partial +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "append some messages"; + my %exp; + my $N = 10; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + # delete the 1st and 6th + $imaptalk->store('1,6', '+FLAGS', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + my @results; + my %handlers = + ( + esearch => sub + { + my (undef, $esearch) = @_; + push(@results, $esearch); + }, + ); + + # search and return non-existent messages + @results = (); + my $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(PARTIAL -100:-1)', '100:300'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('PARTIAL', $results[0][1]); + $self->assert_str_equals('-1:-100', $results[0][2][0]); + $self->assert_null($results[0][2][1]); + + # search and return all messages + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '()', 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('2:5,7:10', $results[0][2]); + + # attempt search with all and partial + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(ALL PARTIAL 1:2)', 'UNDELETED'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + # search and return first 2 messages + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(PARTIAL 1:2)', 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('PARTIAL', $results[0][1]); + $self->assert_str_equals('1:2', $results[0][2][0]); + $self->assert_str_equals('2:3', $results[0][2][1]); + + # search and return next 2 messages + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(PARTIAL 3:4)', 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('PARTIAL', $results[0][1]); + $self->assert_str_equals('3:4', $results[0][2][0]); + $self->assert_str_equals('4:5', $results[0][2][1]); + + # flag the last message + $imaptalk->store('10', '+FLAGS', '(\\flagged)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + # search and return next 2 messages + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(PARTIAL 5:6)', 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('PARTIAL', $results[0][1]); + $self->assert_str_equals('5:6', $results[0][2][0]); + $self->assert_str_equals('7:8', $results[0][2][1]); + + # search and return last 2 messages + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(PARTIAL -1:-2)', 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('PARTIAL', $results[0][1]); + $self->assert_str_equals('-1:-2', $results[0][2][0]); + $self->assert_str_equals('9:10', $results[0][2][1]); + + # search and return the previous 2 messages + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(PARTIAL -3:-4)', 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('PARTIAL', $results[0][1]); + $self->assert_str_equals('-3:-4', $results[0][2][0]); + $self->assert_str_equals('7:8', $results[0][2][1]); + + # search and return middle 2 messages by UID + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(PARTIAL 2:3)', + 'UID', '4:8', 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('PARTIAL', $results[0][1]); + $self->assert_str_equals('2:3', $results[0][2][0]); + $self->assert_str_equals('5,7', $results[0][2][1]); + + # search and return non-existent messages + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(PARTIAL 9:10)', 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('PARTIAL', $results[0][1]); + $self->assert_str_equals('9:10', $results[0][2][0]); + $self->assert_null($results[0][2][1]); + + # search and return count, min, max, and partial + @results = (); + $res = $imaptalk->_imap_cmd('SEARCH', 0, \%handlers, + 'RETURN', '(MIN MAX COUNT PARTIAL 3:4)', + 'UNDELETED'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('COUNT', $results[0][1]); + $self->assert_str_equals('8', $results[0][2]); + $self->assert_str_equals('MIN', $results[0][3]); + $self->assert_str_equals('2', $results[0][4]); + $self->assert_str_equals('MAX', $results[0][5]); + $self->assert_str_equals('10', $results[0][6]); + $self->assert_str_equals('PARTIAL', $results[0][7]); + $self->assert_str_equals('3:4', $results[0][8][0]); + $self->assert_str_equals('4:5', $results[0][8][1]); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/SearchFuzzy.pm b/cassandane/Cassandane/Cyrus/SearchFuzzy.pm new file mode 100644 index 0000000000..0b1a84ecd4 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/SearchFuzzy.pm @@ -0,0 +1,319 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::SearchFuzzy; +use strict; +use warnings; +use Cwd qw(abs_path); +use DateTime; +use Data::Dumper; +use File::Temp qw(tempdir); +use File::stat; +use MIME::Base64 qw(encode_base64); +use Encode qw(decode encode); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + + my ($class, @args) = @_; + my $config = Cassandane::Config->default()->clone(); + $config->set( + conversations => 'on', + httpallowcompress => 'no', + httpmodules => 'jmap', + ); + return $class->SUPER::new({ + config => $config, + jmap => 1, + services => [ 'imap', 'http' ] + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + # This will be "words" if Xapian has a CJK word-tokeniser, "ngrams" + # if it doesn't, or "none" if it cannot tokenise CJK at all. + $self->{xapian_cjk_tokens} = + $self->{instance}->{buildinfo}->get('search', 'xapian_cjk_tokens') + || "none"; + + xlog $self, "Xapian CJK tokeniser '$self->{xapian_cjk_tokens}' detected.\n"; + + use experimental 'smartmatch'; + my $skipdiacrit = $self->{instance}->{config}->get('search_skipdiacrit'); + if (not defined $skipdiacrit) { + $skipdiacrit = 1; + } + if ($skipdiacrit ~~ ['no', 'off', 'f', 'false', '0']) { + $skipdiacrit = 0; + } + $self->{skipdiacrit} = $skipdiacrit; + + my $fuzzyalways = $self->{instance}->{config}->get('search_fuzzy_always'); + if ($fuzzyalways ~~ ['yes', 'on', 't', 'true', '1']) { + $self->{fuzzyalways} = 1; + } else { + $self->{fuzzyalways} = 0 ; + } +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub create_testmessages +{ + my ($self) = @_; + + xlog $self, "Generate test messages."; + # Some subjects with the same verb word stem + $self->make_message("I am running") || die; + $self->make_message("I run") || die; + $self->make_message("He runs") || die; + + # Some bodies with the same word stems but different senders. We use + # the "connect" word stem since it it the first example on Xapian's + # Stemming documentation (https://xapian.org/docs/stemming.html). + # Mails from foo@example.com... + my %params; + %params = ( + from => Cassandane::Address->new( + localpart => "foo", + domain => "example.com" + ), + ); + $params{'body'} ="He has connections.", + $self->make_message("1", %params) || die; + $params{'body'} = "Gonna get myself connected."; + $self->make_message("2", %params) || die; + # ...as well as from bar@example.com. + %params = ( + from => Cassandane::Address->new( + localpart => "bar", + domain => "example.com" + ), + body => "Einstein's gravitational theory resulted in beautiful relations connecting gravitational phenomena with the geometry of space; this was an exciting idea." + ); + $self->make_message("3", %params) || die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); +} + +sub get_snippets +{ + # Previous versions of this test module used XSNIPPETS to + # assert snippets but this command got removed from Cyrus. + # Use JMAP instead. + + my ($self, $folder, $uids, $filter) = @_; + + my $imap = $self->{store}->get_client(); + my $jmap = $self->{jmap}; + + $self->assert_not_null($jmap); + + $imap->select($folder); + my $res = $imap->fetch($uids, ['emailid']); + my %emailIdToImapUid = map { $res->{$_}{emailid}[0] => $_ } keys %$res; + + $res = $jmap->CallMethods([ + ['SearchSnippet/get', { + filter => $filter, + emailIds => [ keys %emailIdToImapUid ], + }, 'R1'], + ]); + + my @snippets; + foreach (@{$res->[0][1]{list}}) { + if ($_->{subject}) { + push(@snippets, [ + 0, + $emailIdToImapUid{$_->{emailId}}, + 'SUBJECT', + $_->{subject}, + ]); + } + if ($_->{preview}) { + push(@snippets, [ + 0, + $emailIdToImapUid{$_->{emailId}}, + 'BODY', + $_->{preview}, + ]); + } + } + + return { + snippets => [ sort { $a->[1] <=> $b->[1] } @snippets ], + }; +} + +sub run_delve { + my ($self, $dir, @args) = @_; + my $basedir = $self->{instance}->{basedir}; + my @myargs = ('xapian-delve'); + push(@myargs, @args); + push(@myargs, $dir); + $self->{instance}->run_command({redirects => {stdout => "$basedir/delve.out"}}, @myargs); + open(FH, "<$basedir/delve.out") || die "can't find delve.out"; + my $data = ; + return $data; +} + +sub delve_docs +{ + my ($self, $dir) = @_; + my $delveout = $self->run_delve($dir, '-V0'); + $delveout =~ s/^Value 0 for each document: //; + my @docs = split ' ', $delveout; + my @parts = map { $_ =~ /^\d+:\*P\*/ ? substr($_, 5) : () } @docs; + my @gdocs = map { $_ =~ /^\d+:\*G\*/ ? substr($_, 5) : () } @docs; + return \@gdocs, \@parts; +} + +sub start_echo_extractor +{ + my ($self, %params) = @_; + my $instance = $self->{instance}; + + xlog "Start extractor server with tracedir $params{tracedir}"; + my $nrequests = 0; + my $handler = sub { + my ($conn, $req) = @_; + + $nrequests++; + + if ($params{trace_delay_seconds}) { + sleep $params{trace_delay_seconds}; + } + + if ($params{tracedir}) { + # touch trace file in tracedir + my @paths = split(q{/}, URI->new($req->uri)->path); + my $guid = pop(@paths); + my $fname = join(q{}, + $params{tracedir}, "/req", $nrequests, "_", $req->method, "_$guid"); + open(my $fh, ">", $fname) or die "Can't open > $fname: $!"; + close $fh; + } + + my $res; + + if ($req->method eq 'HEAD') { + $res = HTTP::Response->new(204); + $res->content(""); + } elsif ($req->method eq 'GET') { + $res = HTTP::Response->new(404); + $res->content("nope"); + } else { + $res = HTTP::Response->new(200); + $res->content($req->content); + } + + if ($params{response_delay_seconds}) { + my $secs = $params{response_delay_seconds}; + if (ref($secs) eq 'ARRAY') { + $secs = ($nrequests <= scalar @$secs) ? + $secs->[$nrequests-1] : 0; + } + sleep $secs; + } + + $conn->send_response($res); + }; + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + $instance->start_httpd($handler, $uri->port()); +} + +sub squatter_attachextract_cache_run +{ + my ($self, $cachedir, @squatterArgs) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + xlog "Append emails with identical attachments"; + $self->make_message("msg1", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/pdf\r\n" + ."\r\n" + ."attachterm" + ."\r\n--123456789abcdef--\r\n" + ) || die; + $self->make_message("msg2", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/pdf\r\n" + ."\r\n" + ."attachterm" + ."\r\n--123456789abcdef--\r\n" + ) || die; + + xlog "Run squatter with cachedir $cachedir"; + $self->{instance}->run_command({cyrus => 1}, + 'squatter', "--attachextract-cache-dir=$cachedir", @squatterArgs); +} + +use Cassandane::Tiny::Loader 'tiny-tests/SearchFuzzy'; + +1; diff --git a/cassandane/Cassandane/Cyrus/SearchSquat.pm b/cassandane/Cassandane/Cyrus/SearchSquat.pm new file mode 100644 index 0000000000..75e57462ea --- /dev/null +++ b/cassandane/Cassandane/Cyrus/SearchSquat.pm @@ -0,0 +1,554 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2020 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::SearchSquat; +use strict; +use warnings; +use Cwd qw(abs_path); +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; + +sub new +{ + my ($class, @args) = @_; + my $config = Cassandane::Config->default()->clone(); + $config->set(conversations => 'on'); + return $class->SUPER::new({ config => $config }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub run_squatter +{ + my ($self, @args) = @_; + + my $outfname = $self->{instance}->{basedir} . "/squatter.out"; + my $errfname = $self->{instance}->{basedir} . "/squatter.err"; + + $self->{instance}->run_command({ + cyrus => 1, + redirects => { + stdout => $outfname, + stderr => $errfname, + }, + }, + 'squatter', + @args + ); + + return (slurp_file($outfname), slurp_file($errfname)); +} + +# XXX version gated to 3.4+ for now to keep travis happy, but if we +# XXX backport the fix we should change or remove the gate... +sub test_simple + :SearchEngineSquat :min_version_3_4 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + $self->make_message("term2", body => "term1") || die; + $self->make_message("term2", body => "term1") || die; + $self->make_message("term1", body => "term2") || die; + $self->make_message("term3", body => "term4") || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @tests = ({ + search => ['body', 'term1'], + wantUids => [1,2], + }, { + search => ['text', 'term1'], + wantUids => [1,2,3], + }, { + search => ['subject', 'term2'], + wantUids => [1,2], + }, { + search => ['subject', 'term3'], + wantUids => [4], + }, { + search => ['body', 'term4'], + wantUids => [4], + }, { + search => ['fuzzy', 'body', 'term4'], + wantUids => [4], + }, { + # we don't index content-type, make sure we actually didn't + search => ['from', 'text/plain'], + wantUids => [], + }, { + # we don't index content-type, make sure we actually didn't + search => ['to', 'text/plain'], + wantUids => [], + }, { + # we don't index content-type, make sure we actually didn't + search => ['subject', 'text/plain'], + wantUids => [], + }); + + foreach (@tests) { + $self->{instance}->getsyslog(); + + my $uids = $imap->search(@{$_->{search}}) || die; + $self->assert_deep_equals($_->{wantUids}, $uids); + + $self->assert_syslog_matches($self->{instance}, qr{Squat run}); + } +} + +sub test_one_doc_per_message + :SearchEngineSquat :min_version_3_4 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + # make some messages where the only indexed field is the body + foreach my $body (qw(term1 term1 term2 term4)) { + $self->make_message(undef, + from => undef, + to => undef, + body => $body) || die; + } + + # make enough other messages such that an incremental reindex + # will need to realloc doc_ID_map + for (1..50) { + $self->make_message() || die; + } + + $self->run_squatter(); + + my @tests = ({ + search => ['body', 'term1'], + wantUids => [1,2], + }, { + search => ['body', 'term2'], + wantUids => [3], + }, { + search => ['body', 'term3'], + wantUids => [], + }, { + search => ['body', 'term4'], + wantUids => [4], + }); + + foreach (@tests) { + $self->{instance}->getsyslog(); + + my $uids = $imap->search(@{$_->{search}}) || die; + $self->assert_deep_equals($_->{wantUids}, $uids); + + $self->assert_syslog_matches($self->{instance}, qr/Squat run/); + } + + # make some more messages + foreach my $body (qw(term5 term6 term6 term8)) { + $self->make_message(undef, + from => undef, + to => undef, + body => $body) || die; + } + + # incremental reindex + my (undef, $err) = $self->run_squatter('-i', '-v'); + $self->assert_matches(qr{indexed 4 messages}, $err); + + push @tests, { + search => ['body', 'term5'], + wantUids => [55], + }, { + search => ['body', 'term6'], + wantUids => [56, 57], + }, { + search => ['body', 'term7'], + wantUids => [], + }, { + search => ['body', 'term8'], + wantUids => [58], + }; + + # better not be any off-by-one errors in search results! + foreach (@tests) { + $self->{instance}->getsyslog(); + + my $uids = $imap->search(@{$_->{search}}) || die; + $self->assert_deep_equals($_->{wantUids}, $uids); + + $self->assert_syslog_matches($self->{instance}, qr/Squat run/); + } +} + +# XXX version gated to 3.4+ for now to keep travis happy, but if we +# XXX backport the fix we should change or remove the gate... +sub test_skip_unmodified + :SearchEngineSquat :min_version_3_4 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + $self->make_message() || die; + + sleep(1); + + $self->{instance}->getsyslog(); + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + $self->assert_syslog_does_not_match($self->{instance}, + qr{Squat skipping mailbox}); + + $self->{instance}->getsyslog(); + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v', '-s', '0'); + $self->assert_syslog_matches($self->{instance}, + qr{Squat skipping mailbox}); +} + +sub test_nonincremental + :SearchEngineSquat +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + my $n = 0; + + for (1..5) { + # make a new message + $self->make_message(); + $n++; + + # do a full reindex + my (undef, $err) = $self->run_squatter('-vv'); + + # better have indexed them all, not just the new one! + $self->assert_matches(qr{indexed $n messages}, $err); + } + + # make a message with no subject, to, or from + $self->make_message(undef, to => undef, from => undef); + $n++; + + # do a full reindex + my (undef, $err) = $self->run_squatter('-vv'); + + # better have indexed them all, not just the new one! + $self->assert_matches(qr{indexed $n messages}, $err); +} + +sub test_incremental + :SearchEngineSquat +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + my $err; + + # some initial messages - enough to definitely force a doc_ID_map realloc + # when incrementally reindexing later + for (1..50) { + $self->make_message(); + } + + # make a message with no subject, to, or from + # this used to trigger an indexing bug and produce a corrupt index, + # which would lead to a crash during incremental reindex + my $weird = $self->make_message(undef, to => undef, from => undef); + xlog "weird message:\n" . $weird->as_string(); + + sleep(1); + + # initial non-incremental index + (undef, $err) = $self->run_squatter('-vv'); + $self->assert_matches(qr{indexed 51 messages}, $err); + + # incremental reindex with no changes to mailbox + (undef, $err) = $self->run_squatter('-i', '-vv'); + $self->assert_matches(qr{indexed 0 messages}, $err); + + # delete, expunge, and cyr_expire some messages + # n.b. this does not unindex the message in any way + $imap->store('5', '+flags', '(\\Deleted)'); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); + $imap->expunge(); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); + $self->{instance}->run_command({cyrus => 1}, 'cyr_expire', '-X', '0'); + + # incremental reindex after one message expunged + (undef, $err) = $self->run_squatter('-i', '-vv'); + $self->assert_matches(qr{indexed 0 messages}, $err); + + # make one new message + for (1) { + $self->make_message(); + } + sleep(1); + + # incremental reindex after one new message + (undef, $err) = $self->run_squatter('-i', '-vv'); + $self->assert_matches(qr{indexed 1 messages}, $err); + + # incremental reindex with no changes to mailbox + (undef, $err) = $self->run_squatter('-i', '-vv'); + $self->assert_matches(qr{indexed 0 messages}, $err); + + # make some new messages + for (1..10) { + $self->make_message(); + } + sleep(1); + + # incremental reindex after new messages + (undef, $err) = $self->run_squatter('-i', '-vv'); + $self->assert_matches(qr{indexed 10 messages}, $err); + + # incremental reindex with no changes to mailbox + (undef, $err) = $self->run_squatter('-i', '-vv'); + $self->assert_matches(qr{indexed 0 messages}, $err); +} + +sub test_relocate_legacy_searchdb + :DelayedDelete :min_version_3_6 :MailboxLegacyDirs + :Admin :SearchEngineSquat :NoAltNamespace :VirtDomains +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser\@example.com"; + my $subfolder = "user.magicuser.foo\@example.com"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, 'relocate_by_id', '-u' => "magicuser\@example.com" ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); +} + +sub test_relocate_legacy_nosearchdb + :DelayedDelete :min_version_3_6 :MailboxLegacyDirs + :Admin :SearchEngineSquat :NoAltNamespace :VirtDomains +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser\@example.com"; + my $subfolder = "user.magicuser.foo\@example.com"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Don't create the search database! + # A user who's never been indexed should still relocate cleanly + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, 'relocate_by_id', '-u' => "magicuser\@example.com" ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); +} + +sub test_unindexed + :SearchEngineSquat :min_version_3_4 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + $self->make_message("needle 1", body => "needle") || die; + $self->make_message("xxxxxx 2", body => "xxxxxx") || die; + + $self->run_squatter; + + my $uids = $imap->search('text', 'needle'); + $self->assert_deep_equals([1], $uids); + + $self->make_message("needle 3", body => "needle") || die; + $self->make_message("xxxxxx 4", body => "xxxxxx") || die; + + # Do not rerun squatter. Make sure search only returns + # a matching unindexed message. + + $uids = $imap->search('text', 'needle'); + $self->assert_deep_equals([1,3], $uids); +} + +sub test_unindexed_fuzzy + :SearchEngineSquat :min_version_3_4 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + $self->make_message("needle 1", body => "needle") || die; + $self->make_message("xxxxxx 2", body => "xxxxxx") || die; + + $self->run_squatter; + + my $uids = $imap->search('fuzzy', 'body', 'needle'); + $self->assert_deep_equals([1], $uids); + + $self->make_message("needle 3", body => "needle") || die; + $self->make_message("xxxxxx 4", body => "xxxxxx") || die; + + # Do not rerun squatter. Make sure search only returns + # a matching unindexed message. + + $uids = $imap->search('fuzzy', 'body', 'needle'); + $self->assert_deep_equals([1,3], $uids); +} + +sub test_unindexed_since + :SearchEngineSquat :min_version_3_4 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + my $past_dt = DateTime->last_day_of_month(year => 2023, month => 12); + + $self->make_message("needle 1", body => "needle") || die; + $self->make_message("xxxxxx 2", body => "xxxxxx") || die; + $self->make_message("old 3", date => $past_dt, body => "needle") || die; + + $self->run_squatter; + + my $uids = $imap->search('text', 'needle', 'since', '1-Feb-2024'); + $self->assert_deep_equals([1], $uids); + + $uids = $imap->search('text', 'needle', 'not', 'since', '1-Feb-2024'); + $self->assert_deep_equals([3], $uids); + + $self->make_message("needle 4", body => "needle") || die; + $self->make_message("xxxxxx 5", body => "xxxxxx") || die; + $self->make_message("old 6", date => $past_dt, body => "needle") || die; + + # Do not rerun squatter. Make sure search only returns + # a matching unindexed message. + + $uids = $imap->search('text', 'needle', 'since', '1-Feb-2024'); + $self->assert_deep_equals([1,4], $uids); + + $uids = $imap->search('text', 'needle', 'not', 'since', '1-Feb-2024'); + $self->assert_deep_equals([3, 6], $uids); +} + +sub test_since + :SearchEngineSquat :min_version_3_4 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + my $past_dt = DateTime->last_day_of_month(year => 2023, month => 12); + + $self->make_message("needle 1", body => "needle") || die; + $self->make_message("xxxxxx 2", body => "xxxxxx") || die; + $self->make_message("old 3", date => $past_dt, body => "needle") || die; + + $self->run_squatter; + + my $uids = $imap->search('since', '1-Feb-2024'); + $self->assert_deep_equals([1,2], $uids); + + $uids = $imap->search('not', 'since', '1-Feb-2024'); + $self->assert_deep_equals([3], $uids); + + $self->make_message("needle 4", body => "needle") || die; + $self->make_message("xxxxxx 5", body => "xxxxxx") || die; + $self->make_message("old 6", date => $past_dt, body => "needle") || die; + + # Do not rerun squatter. Make sure search only returns + # a matching unindexed message. + + $uids = $imap->search('since', '1-Feb-2024'); + $self->assert_deep_equals([1,2,4,5], $uids); + + $uids = $imap->search('not', 'since', '1-Feb-2024'); + $self->assert_deep_equals([3,6], $uids); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Sieve.pm b/cassandane/Cassandane/Cyrus/Sieve.pm new file mode 100644 index 0000000000..0ba95ce6cb --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Sieve.pm @@ -0,0 +1,401 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Sieve; +use Net::CalDAVTalk 0.12; +use strict; +use warnings; +use IO::File; +use version; +use utf8; +use File::Temp qw/tempfile/; +use DateTime; +use Date::Parse; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Encode qw(decode); +use MIME::Base64 qw(encode_base64); +use Data::Dumper; + +sub new +{ + my $class = shift; + my $config = Cassandane::Config->default()->clone(); + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj == 3 && $min == 0) { + # need to explicitly add 'body' to sieve_extensions for 3.0 + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags mailbox mboxmetadata servermetadata variables " . + "body"); + } + elsif ($maj < 3) { + # also for 2.5 (the earliest Cyrus that Cassandane can test) + $config->set(sieve_extensions => + "fileinto reject vacation vacation-seconds imap4flags notify " . + "envelope relational regex subaddress copy date index " . + "imap4flags body"); + } + $config->set(sievenotifier => 'mailto'); + $config->set(caldav_realm => 'Cassandane'); + $config->set(httpmodules => ['caldav', 'carddav', 'jmap']); + $config->set(calendar_user_address_set => 'example.com'); + $config->set(httpallowcompress => 'no'); + $config->set(caldav_historical_age => -1); + $config->set(icalendar_max_size => 100000); + $config->set(virtdomains => 'no'); + $config->set(jmap_nonstandard_extensions => 'yes'); + $config->set(conversations => 'yes'); + + return $class->SUPER::new({ + config => $config, + deliver => 1, + jmap => 1, + services => [ 'imap', 'sieve' ], + adminstore => 1, + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + if ($self->{jmap}) { + $self->{jmap}->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'urn:ietf:params:jmap:calendars:preferences', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + ]); + } +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub read_errors +{ + my ($filename) = @_; + + my @errors; + if ( -f $filename ) + { + open FH, '<', $filename + or die "Cannot open $filename for reading: $!"; + @errors = readline(FH); + close FH; + if (get_verbose) + { + xlog "errors: "; + map { xlog $_ } @errors; + } + # Hack to remove spurious junk generated when + # running coveraged code under ggcov-run + @errors = grep { ! m/libggcov:/ && ! m/profiling:/ } @errors; + } + return @errors; +} + +sub compile_sievec +{ + my ($self, $name, $script) = @_; + + my $basedir = $self->{instance}->{basedir}; + + xlog $self, "Checking preconditions for compiling sieve script $name"; + + $self->assert_not_file_test("$basedir/$name.script", '-f'); + $self->assert_not_file_test("$basedir/$name.bc", '-f'); + $self->assert_not_file_test("$basedir/$name.errors", '-f'); + + open(FH, '>', "$basedir/$name.script") + or die "Cannot open $basedir/$name.script for writing: $!"; + print FH $script; + close(FH); + + xlog $self, "Running sievec on script $name"; + my $result = $self->{instance}->run_command( + { + cyrus => 1, + redirects => { stderr => "$basedir/$name.errors" }, + handlers => { + exited_normally => sub { return 'success'; }, + exited_abnormally => sub { return 'failure'; }, + }, + }, + "sievec", "$basedir/$name.script", "$basedir/$name.bc"); + + # Read the errors file in @errors + my (@errors) = read_errors("$basedir/$name.errors"); + + if ($result eq 'success') + { + xlog $self, "Checking that sievec wrote the output .bc file"; + $self->assert_file_test("$basedir/$name.bc", '-f'); + xlog $self, "Checking that sievec didn't write anything to stderr"; + $self->assert_equals(0, scalar(@errors)); + } + elsif ($result eq 'failure') + { + xlog $self, "Checking that sievec didn't write the output .bc file"; + $self->assert_not_file_test("$basedir/$name.bc", '-f'); + } + + return ($result, join("\n", @errors)); +} + +sub compile_timsieved +{ + my ($self, $name, $script) = @_; + + my $basedir = $self->{instance}->{basedir}; + my $bindir = $self->{instance}->{cyrus_destdir} . + $self->{instance}->{cyrus_prefix} . '/bin'; + my $srv = $self->{instance}->get_service('sieve'); + + xlog $self, "Checking preconditions for compiling sieve script $name"; + + $self->assert_not_file_test("$basedir/$name.script", '-f'); + $self->assert_not_file_test("$basedir/$name.errors", '-f'); + + open(FH, '>', "$basedir/$name.script") + or die "Cannot open $basedir/$name.script for writing: $!"; + print FH $script; + close(FH); + + if (! -f "$basedir/sieve.passwd" ) + { + open(FH, '>', "$basedir/sieve.passwd") + or die "Cannot open $basedir/sieve.passwd for writing: $!"; + print FH "\ntestpw\n"; + close(FH); + } + + xlog $self, "Running installsieve on script $name"; + my $result = $self->{instance}->run_command({ + redirects => { + # No cyrus => 1 as installsieve is a Perl + # script which doesn't need Valgrind and + # doesn't understand the Cyrus -C option + stdin => "$basedir/sieve.passwd", + stderr => "$basedir/$name.errors" + }, + handlers => { + exited_normally => sub { return 'success'; }, + exited_abnormally => sub { return 'failure'; }, + }, + }, + "$bindir/installsieve", + "-i", "$basedir/$name.script", + "-u", "cassandane", + $srv->host() . ":" . $srv->port()); + + # Read the errors file in @errors + my (@errors) = read_errors("$basedir/$name.errors"); + + if ($result eq 'success') + { + xlog $self, "Checking that installsieve didn't write anything to stderr"; + $self->assert_equals(0, scalar(@errors)); + } + + return ($result, join("\n", @errors)); +} + +sub compile_sieve_script +{ + my ($self, $name, $script) = @_; + + my $meth = 'compile_' . $self->{compile_method}; + return $self->$meth($name, $script); +} + +sub badscript_common +{ + my ($self) = @_; + + my $res; + my $errs; + + ($res, $errs) = $self->compile_sieve_script('badrequire', + "require [\"nonesuch\"];\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/Unsupported feature.*nonesuch/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badreject1', + "reject \"foo\"\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/reject.*MUST be enabled/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badreject2', + "require [\"reject\"];\nreject\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/syntax error/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badreject3', + "require [\"reject\"];\nreject 42\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/syntax error/, $errs); + + # TODO: test UTF-8 verification of the string parameter + + ($res, $errs) = $self->compile_sieve_script('badfileinto1', + "fileinto \"foo\"\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/fileinto.*MUST be enabled/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badfileinto2', + "require [\"fileinto\"];\nfileinto\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/syntax error/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badfileinto3', + "require [\"fileinto\"];\nfileinto 42\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/syntax error/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badfileinto4', + "require [\"fileinto\"];\nfileinto :copy \"foo\"\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/copy.*MUST be enabled/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badfileinto5', + "require [\"fileinto\",\"copy\"];\nfileinto \"foo\"\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/syntax error/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badfileinto6', + "require [\"fileinto\",\"copy\"];\nfileinto :copy \"foo\"\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/syntax error/, $errs); + + ($res, $errs) = $self->compile_sieve_script('badchar1', + "require [\"fileinto\"];\n☃;\nfileinto \"foo\";\n"); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/non-ASCII/, $errs); + + ($res, $errs) = $self->compile_sieve_script('goodfileinto7', + "require [\"fileinto\",\"copy\"];\nfileinto \"foo\";\n"); + $self->assert_str_equals('success', $res); + + ($res, $errs) = $self->compile_sieve_script('goodfileinto8', + "require [\"fileinto\",\"copy\"];\nfileinto :copy \"foo\";\n"); + $self->assert_str_equals('success', $res); + + my $badregex1 = << 'EOF'; +require ["regex"]; +if header :regex "Subject" "Message (x)?(.*" { + stop; +} +EOF + ($res, $errs) = $self->compile_sieve_script('badregex1', $badregex1); + $self->assert_str_equals('failure', $res); + $self->assert_matches(qr/unbalanced/, $errs); + + # TODO: test UTF-8 verification of the string parameter +} + +# Disabled for now - addflag does not work +# on shared mailboxes in 2.5. +# https://github.com/cyrusimap/cyrus-imapd/issues/1453 +sub XXXtest_shared_delivery_addflag + :Admin + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing setting a flag on a sieve script on a"; + xlog $self, "shared folder. Bug 3617 / issue #1453"; + + my $imaptalk = $self->{store}->get_client(); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create the target folder"; + my $admintalk = $self->{adminstore}->get_client(); + my $target = "shared.departments.cis"; + $admintalk->create($target) + or die "Cannot create folder \"$target\": $@"; + $admintalk->setacl($target, admin => 'lrswipkxtecda') + or die "Cannot setacl for \"$target\": $@"; + $admintalk->setacl($target, 'cassandane' => 'lrswipkxtecd') + or die "Cannot setacl for \"$target\": $@"; + $admintalk->setacl($target, 'anyone' => 'p') + or die "Cannot setacl for \"$target\": $@"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'cosbySweater'; + $self->{instance}->install_sieve_script(< undef, + name => $scriptname); + + xlog $self, "Tell the folder to run the sieve script"; + $admintalk->setmetadata($target, "/shared/vendor/cmu/cyrus-imapd/sieve", $scriptname) + or die "Cannot set metadata: $@"; + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "quinoa"); + $self->{instance}->deliver($msg1, users => [], folder => $target); + + xlog $self, "Check that the message made it to target"; + $self->{store}->set_folder($target); + $msg1->set_attribute(flags => [ '\\Recent', '\\Flagged' ]); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} + +use Cassandane::Tiny::Loader 'tiny-tests/Sieve'; + +1; diff --git a/cassandane/Cassandane/Cyrus/Simple.pm b/cassandane/Cassandane/Cyrus/Simple.pm new file mode 100644 index 0000000000..002b3c5472 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Simple.pm @@ -0,0 +1,364 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Simple; +use strict; +use warnings; +use Data::Dumper; +use DateTime; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +$Data::Dumper::Sortkeys = 1; + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# +# Test APPEND of messages to IMAP +# +sub test_append +{ + my ($self) = @_; + + my %exp; + + xlog $self, "generating message A"; + $exp{A} = $self->make_message("Message A"); + $self->check_messages(\%exp); + + xlog $self, "generating message B"; + $exp{B} = $self->make_message("Message B"); + $self->check_messages(\%exp); + + xlog $self, "generating message C"; + $exp{C} = $self->make_message("Message C"); + $self->check_messages(\%exp); + + xlog $self, "generating message D"; + $exp{D} = $self->make_message("Message D"); + $self->check_messages(\%exp); +} + +sub test_appendlimit_default + :min_version_3_6 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $capa = $imaptalk->capability(); + my @appendlimits = grep { m/^appendlimit/ } keys %{$capa}; + + # should be only one appendlimit + $self->assert_num_equals(1, scalar @appendlimits); + + # we do not support per-mailbox limits, so it must have a value too + $self->assert_matches(qr{^appendlimit=\d+$}, $appendlimits[0]); + + # and since we haven't configured it, it ought to be the default + # value, which is BYTESIZE_UNLIMITED (2147483647). + $self->assert_str_equals("appendlimit=2147483647", $appendlimits[0]); +} + +sub test_appendlimit_configured + :min_version_3_6 :NoStartInstances +{ + my ($self) = @_; + + my $desired_limit = "52428800"; # based on known failure + + $self->{instance}->{config}->set('maxmessagesize' => $desired_limit); + $self->_start_instances(); + + my $imaptalk = $self->{store}->get_client(); + + my $capa = $imaptalk->capability(); + my @appendlimits = grep { m/^appendlimit/ } keys %{$capa}; + + # should be only one appendlimit + $self->assert_num_equals(1, scalar @appendlimits); + + # we do not support per-mailbox limits, so it must have a value too + $self->assert_matches(qr{^appendlimit=\d+$}, $appendlimits[0]); + + # and since we've configured it, it'd better be what we asked for! + $self->assert_str_equals("appendlimit=$desired_limit", $appendlimits[0]); +} + +sub test_select +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "SELECTing INBOX"; + $imaptalk->select("INBOX"); + $self->assert(!$imaptalk->get_last_error()); + + xlog $self, "SELECTing inbox"; + $imaptalk->select("inbox"); + $self->assert(!$imaptalk->get_last_error()); + + xlog $self, "CREATEing sub folders"; + $imaptalk->create("INBOX.sub"); + $self->assert(!$imaptalk->get_last_error()); + $imaptalk->create("inbox.blub"); + $self->assert(!$imaptalk->get_last_error()); + + xlog $self, "SELECTing subfolders"; + $imaptalk->select("inbox.sub"); + $self->assert(!$imaptalk->get_last_error()); + $imaptalk->select("INbOX.blub"); + $self->assert(!$imaptalk->get_last_error()); +} + +sub test_cmdtimer_sessionid + :min_version_3_5 :NoStartInstances +{ + my ($self) = @_; + + # log the timing for anything that takes longer than zero seconds + $self->{instance}->{config}->set('commandmintimer', '0'); + $self->_start_instances(); + + my $imaptalk = $self->{store}->get_client(); + + # put a bunch of messages in inbox to make sure fetch isn't instantaneous + my %msgs; + foreach my $n (1..5) { + $msgs{$n} = $self->make_message("message $n"); + } + + $imaptalk->select("INBOX"); + $self->assert_str_equals("ok", $imaptalk->get_last_completion_response()); + + # discard buffered syslog output from setup + $self->{instance}->getsyslog(); + + # fetch some things that will take a little while + $imaptalk->fetch('1:*', '(uid flags body[])'); + $self->assert_str_equals("ok", $imaptalk->get_last_completion_response()); + + # should have logged some timer output, which should include the sess id + if ($self->{instance}->{have_syslog_replacement}) { + # make sure that the connection is ended so that imapd reset happens + $imaptalk->logout(); + undef $imaptalk; + + my @lines = $self->{instance}->getsyslog(); + + my @timer_lines = grep { m/\bcmdtimer:/ } @lines; + $self->assert_num_gte(1, scalar @timer_lines); + foreach my $line (@timer_lines) { + $self->assert_matches(qr/sessionid=<[^ >]+>/, $line); + } + + my (@behavior_lines) = grep { /session ended/ } @lines; + + $self->assert_num_gte(1, scalar @behavior_lines); + } +} + +sub test_toggleable_debug_logging + :min_version_3_9 +{ + my ($self) = @_; + + my $config_debug = $self->{instance}->{config}->get_bool('debug', 'no'); + my $imaptalk = $self->{store}->get_client(); + + # can't do anything without captured syslog + if (!$self->{instance}->{have_syslog_replacement}) { + xlog $self, "can't examine syslog, test is useless"; + return; + } + + # find our imapd pid from syslog + my $loginpat = qr{ + \bimap\[(\d+)\]:\slogin: + \s\S+\s\[[\d\.]+\]\scassandane\splaintext + \sUser\slogged\sin + }x; + my @logins = $self->{instance}->getsyslog($loginpat); + $self->assert_num_equals(1, scalar @logins); + $logins[0] =~ m/$loginpat/; + my $imapd_pid = $1; + + for (1..5) { + $imaptalk->unselect(); + my $res = $imaptalk->select('INBOX'); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); + + # this is really looking at cassandane's own injected syslog + # output, so it depends on the injected syslog doing the right + # thing with masking + my $selectpat = qr/open: user cassandane opened INBOX/; + my @lines = $self->{instance}->getsyslog($selectpat); + if ($config_debug) { + $self->assert_num_equals(1, scalar @lines); + $self->assert_matches(qr/imap\[$imapd_pid\]:/, $lines[0]); + } + else { + $self->assert_num_equals(0, scalar @lines); + } + + $config_debug = !$config_debug; + + # toggle debug logging by sending SIGUSR1 + my $count = kill 'SIGUSR1', $imapd_pid; + $self->assert_num_equals(1, $count); + + # we can also look for the message logged by cyrus at the + # time it toggles the value + my $statuspat = qr/debug logging turned (on|off)/; + @lines = $self->{instance}->getsyslog($statuspat); + $self->assert_num_equals(1, scalar @lines); + $self->assert_matches(qr/imap\[$imapd_pid\]:/, $lines[0]); + $lines[0] =~ $statuspat; + my $status = $1; + if ($config_debug) { + $self->assert_str_equals('on', $status); + } + else { + $self->assert_str_equals('off', $status); + } + } +} + +sub test_append_binary +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + my $mime = <<'EOF' =~ s/\n/\r\n/gr; +To: to@local +From: from@local +Subject: test +Content-Transfer-Encoding:binary + +test +EOF + + $imap->append("INBOX", { Binary => $mime }); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); + + $imap->select('INBOX'); + my $res = $imap->fetch('1', '(BINARY[1])'); + $self->assert_str_equals("test\r\n", $res->{1}{binary}); +} + +sub test_fatals_abort_enabled + :NoStartInstances +{ + my ($self) = @_; + + $self->{instance}->{config}->set( + 'fatals_abort' => 'yes', + 'prometheus_enabled' => 'no', + ); + $self->_start_instances(); + + my $basedir = $self->{instance}->get_basedir(); + + # run `promstatsd -1` without having set up for prometheus, which should + # produce a "Prometheus metrics are not being tracked..." fatal error + eval { + $self->{instance}->run_command({ cyrus => 1 }, 'promstatsd', '-1'); + }; + my $e = $@; + $self->assert_not_null($e); + $self->assert_matches(qr{promstatsd pid \d+\) terminated by signal 6}, + $e->{'-text'}); + + my @cores = $self->{instance}->find_cores(); + if (@cores) { + # if we dumped core, there'd better only be one core file + $self->assert_num_equals(1, scalar @cores); + + # don't barf on it existing during shutdown + unlink $cores[0]; + } +} + +sub test_fatals_abort_disabled + :NoStartInstances +{ + my ($self) = @_; + + $self->{instance}->{config}->set( + 'fatals_abort' => 'no', + 'prometheus_enabled' => 'no', + ); + $self->_start_instances(); + + my $basedir = $self->{instance}->get_basedir(); + + # run `promstatsd -1` without having set up for prometheus, which should + # produce a "Prometheus metrics are not being tracked..." fatal error + eval { + $self->{instance}->run_command({ cyrus => 1 }, 'promstatsd', '-1'); + }; + my $e = $@; + $self->assert_not_null($e); + $self->assert_matches(qr{promstatsd pid \d+\) exited with code 78}, + $e->{'-text'}); + + # post-test sanity checks will complain for us if a core was left behind +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Specialuse.pm b/cassandane/Cassandane/Cyrus/Specialuse.pm new file mode 100644 index 0000000000..1e822de1df --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Specialuse.pm @@ -0,0 +1,273 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Specialuse; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# Test that you can rename a special use folder +sub test_rename_toplevel + :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Junk", "(USE (\\Junk))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->rename("INBOX.Junk", "INBOX.Other"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); +} + +sub test_rename_tosub + :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Junk", "(USE (\\Junk))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->create("INBOX.Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + # can't rename to a deep folder + $imaptalk->rename("INBOX.Junk", "INBOX.Trash.Junk"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); +} + +sub test_create_multiple + :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Rubbish", "(USE (\\Junk \\Trash \\Sent))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); +} + +sub test_create_dupe + :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Rubbish", "(USE (\\Trash))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); +} + +sub test_annot +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("INBOX.Trash", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); +} + +sub test_annot_dupe + :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Rubbish", "(USE (\\Trash))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->create("INBOX.Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("INBOX.Trash", "/private/specialuse", "\\Trash"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); +} + +sub test_delete_imm + :ImmediateDelete :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->delete("INBOX.Trash"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); +} + +sub test_delete_delay + :DelayedDelete :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->delete("INBOX.Trash"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); +} + +sub test_delete_removed_imm + :ImmediateDelete :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("INBOX.Trash", "/private/specialuse", undef); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->delete("INBOX.Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); +} + +sub test_delete_removed_delay + :DelayedDelete :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("INBOX.Trash", "/private/specialuse", undef); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->delete("INBOX.Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); +} + +sub test_important + :min_version_3_1 :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Important", "(USE (\\Important))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->delete("INBOX.Important"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); +} + +sub test_nochildren + :min_version_3_7 :NoStartInstances :NoAltNamespace +{ + my ($self) = @_; + + $self->{instance}->{config}->set('specialuse_nochildren' => '\\Trash'); + $self->_start_instances(); + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + # should not be able to create a child + $imaptalk->create("INBOX.Trash.child"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); + + # should not be able to create a grandchild either + $imaptalk->create("INBOX.Trash.child.grandchild"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); + + # better not have accidentally created anything + $imaptalk->select("INBOX.Trash.child"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); + + $imaptalk->select("INBOX.Trash.child.grandchild"); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); + + # what if we remove the annotation + $imaptalk->setmetadata("INBOX.Trash", "/private/specialuse", undef); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + # should be able to create a child + $imaptalk->create("INBOX.Trash.child"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + # should not be able to add the annotation back + $imaptalk->setmetadata("INBOX.Trash", "/private/specialuse", '\\Trash'); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); +} + +# compile +1; diff --git a/cassandane/Cassandane/Cyrus/SyncProto.pm b/cassandane/Cassandane/Cyrus/SyncProto.pm new file mode 100644 index 0000000000..90b2561d54 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/SyncProto.pm @@ -0,0 +1,104 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2022 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::SyncProto; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; +use Data::Dumper; +use Cyrus::SyncProto; +use Cyrus::AccountSync; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_syncproto_dump_restore +{ + my ($self) = @_; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create("subfolder"); + $imaptalk->subscribe("subfolder"); + $imaptalk->create("Sent"); + $imaptalk->setmetadata("Sent", "/private/specialuse", "\\Sent"); + + xlog $self, "Create messages"; + $self->make_message("Message A"); + $self->{store}->set_folder("INBOX.subfolder"); + $self->make_message("Message B"); + $self->make_message("Message C"); + + $imaptalk->select("INBOX"); + $imaptalk->store("1", "+flags", "\\Seen"); + $imaptalk->store("1", "+flags", "aflag"); + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + my $sp = Cyrus::SyncProto->new($admintalk); + my $as = Cyrus::AccountSync->new($sp); + my $data = $as->dump_user(username => 'cassandane'); + $self->assert_null($as->dump_user(username => 'newuser')); + $as->undump_user(username => 'newuser', data => $data); + my $newdata = $as->dump_user(username => 'newuser'); + $self->assert_deep_equals($data, $newdata); + $as->delete_user(username => 'newuser'); + $self->assert_null($as->dump_user(username => 'newuser')); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/T116.pm b/cassandane/Cassandane/Cyrus/T116.pm new file mode 100644 index 0000000000..298b5592f2 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/T116.pm @@ -0,0 +1,93 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::T116; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Instance; + +sub new +{ + my $class = shift; + return $class->SUPER::new({ adminstore => 1 }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +Cassandane::Cyrus::TestCase::magic(T116 => sub { + my ($testcase) = @_; + $testcase->config_set(virtdomains => 'userid'); +}); + +sub test_list_inbox + :T116 +{ + my ($self) = @_; + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + xlog $self, "Test Cyrus extension which renames a user to a different partition"; + + # create and prepare the user + $admintalk->create('user.test@inbox.com'); + $admintalk->setacl('user.test@inbox.com', 'admin', 'lrswipkxtecda'); + + $admintalk->create('user.test@inbox2.com'); + $admintalk->setacl('user.test@inbox2.com', 'admin', 'lrswipkxtecda'); + + my @list = $admintalk->list('', '*'); + my @items = sort map { $_->[2] } @list; + $self->assert_deep_equals(\@items, ['user.cassandane', 'user.test@inbox.com', 'user.test@inbox2.com']); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/TestCase.pm b/cassandane/Cassandane/Cyrus/TestCase.pm new file mode 100644 index 0000000000..520bf2b8b7 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/TestCase.pm @@ -0,0 +1,1781 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::TestCase; +use strict; +use warnings; +use attributes; +use Data::Dumper; +use Scalar::Util qw(refaddr); +use List::Util qw(uniq); +use Digest::file qw(digest_file_hex); +use File::Temp qw(tempfile); +use File::Path qw(rmtree); + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Util::Words; +use Cassandane::Generator; +use Cassandane::GenericListener; +use Cassandane::MessageStoreFactory; +use Cassandane::Instance; +use Cassandane::PortManager; +use Cyrus::CheckReplication; + +my @stores = qw(store adminstore + replica_store replica_adminstore + frontend_store frontend_adminstore + backend2_store backend2_adminstore); +my %magic_handlers; + +# This code for storing function attributes is from +# http://stackoverflow.com/questions/987059/how-do-perl-method-attributes-work + +my %attrs; # package variable to store attribute lists by coderef address + +sub MODIFY_CODE_ATTRIBUTES +{ + my ($package, $subref, @attrs) = @_; + $attrs{refaddr $subref} = \@attrs; + return; +} + +sub FETCH_CODE_ATTRIBUTES +{ + my ($package, $subref) = @_; + my $attrs = $attrs{refaddr $subref} || []; + return @$attrs; +} + +sub new +{ + my ($class, $params, @args) = @_; + + my $want = { + instance => 1, + replica => 0, + imapmurder => 0, + httpmurder => 0, + backups => 0, + start_instances => 1, + services => [ 'imap' ], + store => 1, + adminstore => 0, + gen => 1, + deliver => 0, + jmap => 0, + install_certificates => 0, + squatter => 0, + smtpdaemon => 0, + }; + map { + $want->{$_} = delete $params->{$_} + if defined $params->{$_}; + + } keys %$want; + $want->{folder} = delete $params->{folder} + if defined $params->{folder}; + + my $instance_params = {}; + foreach my $p (qw(config)) + { + $instance_params->{$p} = delete $params->{$p} + if defined $params->{$p}; + } + + # should have consumed all of the $params hash; if + # not something is awry. + my $leftovers = join(' ', keys %$params); + die "Unexpected configuration parameters: $leftovers" + if length($leftovers); + + my $self = $class->SUPER::new(@args); + $self->{_name} = $args[0] || 'unknown'; + $self->{_want} = $want; + $self->{_instance_params} = $instance_params; + + return $self; +} + +# return an id for use by xlog +sub id +{ + my ($self) = @_; + return $self->{_name}; # XXX something cleverer? +} + +sub filter +{ + my ($self) = @_; + + my $filter = $self->SUPER::filter(@_); + + $filter->{enable_wanted_properties} = sub { + return if not exists $self->{_name}; + my $sub = $self->can($self->{_name}); + return if not defined $sub; + + # n.b. cannot be used to unwant, sorry + foreach my $attr (attributes::get($sub)) { + next if $attr !~ m/^want(_service)?_(\w+)$/; + + # XXX Since this is a 'filter', it could also check + # XXX whether the required components are configured + # XXX ala :needs_foo, and skip the test if they're + # XXX missing, rather than failing to start them. + # XXX That is, :want_service_http could be taken to + # XXX imply :needs_component_httpd, and the test + # XXX skipped if it's unavailable. But note that + # XXX there isn't a clean mapping between the names! + # XXX For now, tests will need to be annotated with + # XXX both attributes in these cases. + + $self->{_current_magic} = "Test function attribute ':$attr'"; + if (defined $1 && $1 eq '_service') { + $self->want_services($2); + } + else { + $self->want($2); + } + $self->{_current_magic} = undef; + } + return; + }; + + return $filter; +} + +# will magically cause some special actions to be taken during test +# setup. This used to be a horrible hack to enable a replica instance +# if the test name contained the word "replication", but now it's more +# general. The handler function is called near the start of set_up(), +# before Cyrus instances are created, and can call want() to add to the +# set of features wanted by this test, or config_set() to set additional +# imapd.conf variables for all instances. +sub magic +{ + my ($name, $handler) = @_; + $name = lc($name); + die "Magic \"$name\" registered twice" + if defined $magic_handlers{$name}; + $magic_handlers{$name} = $handler; +} + +sub _who_wants_it +{ + my ($self) = @_; + return $self->{_current_magic} + if defined $self->{_current_magic} ; + return "Test " . $self->{_name}; +} + +sub want +{ + my ($self, $name, $value) = @_; + $value = 1 if !defined $value; + $self->{_want}->{$name} = $value; + xlog $self->_who_wants_it() . " wants $name = $value"; +} + +sub want_services +{ + my ($self, @services) = @_; + + @{$self->{_want}->{services}} = uniq(@{$self->{_want}->{services}}, + @services); + xlog $self->_who_wants_it() . " wants services " . join(', ', @services); + +} + +sub config_set +{ + my ($self, %pairs) = @_; + $self->{_config}->set(%pairs); + while (my ($n, $v) = each %pairs) + { + xlog $self->_who_wants_it() . " sets config $n = $v"; + } +} + +# XXX put these in alphabetical order someday +magic(ReverseACLs => sub { + shift->config_set(reverseacls => 1); +}); +magic(RightNow => sub { + shift->config_set(sync_rightnow_channel => '""'); +}); +magic(SyncLog => sub { + shift->config_set(sync_log => 1); +}); +magic(Replication => sub { shift->want('replica'); }); +magic(CSyncReplication => sub { + my ($self) = @_; + $self->want('csyncreplica'); + $self->config_set('sync_try_imap' => 0); +}); +magic(IMAPMurder => sub { shift->want('imapmurder'); }); +magic(HTTPMurder => sub { shift->want('httpmurder'); }); +magic(Backups => sub { shift->want('backups'); }); +magic(AnnotationAllowUndefined => sub { + shift->config_set(annotation_allow_undefined => 1); +}); +magic(AllowDeleted => sub { + shift->config_set(allowdeleted => 1); +}); +magic(ImmediateDelete => sub { + shift->config_set(delete_mode => 'immediate'); +}); +magic(DelayedDelete => sub { + shift->config_set(delete_mode => 'delayed'); +}); +magic(UnixHierarchySep => sub { + shift->config_set(unixhierarchysep => 'yes'); +}); +magic(ImmediateExpunge => sub { + shift->config_set(expunge_mode => 'immediate'); +}); +magic(SemidelayedExpunge => sub { + my $semidelayed = 'semidelayed'; + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj < 3 || ($maj == 3 && $min < 1)) { + # this value used to be called 'default' in 3.0 and earlier + $semidelayed = 'default'; + } + shift->config_set(expunge_mode => $semidelayed); +}); +magic(DelayedExpunge => sub { + shift->config_set(expunge_mode => 'delayed'); +}); +magic(VirtDomains => sub { + shift->config_set(virtdomains => 'userid'); +}); +magic(NoVirtDomains => sub { + shift->config_set(virtdomains => 'off'); +}); +magic(AltNamespace => sub { + shift->config_set(altnamespace => 'yes'); +}); +magic(NoAltNamespace => sub { + shift->config_set(altnamespace => 'no'); +}); +magic(NoMailboxLegacyDirs => sub { + shift->config_set(mailbox_legacy_dirs => 'no'); +}); +magic(MailboxLegacyDirs => sub { + shift->config_set(mailbox_legacy_dirs => 'yes'); +}); +magic(CrossDomains => sub { + shift->config_set(crossdomains => 'yes'); +}); +magic(Conversations => sub { + shift->config_set(conversations => 'yes'); +}); +magic(ConversationsQuota => sub { + # this setting is new in 3.3.0 -- any test using this magic + # also needs :min_version_3_3 + shift->config_set(quota_use_conversations => 'yes'); +}); +magic(Admin => sub { + shift->want('adminstore'); +}); +magic(AllowMoves => sub { + shift->config_set('allowusermoves' => 'yes'); +}); +magic(DisconnectOnVanished => sub { + shift->config_set('disconnect_on_vanished_mailbox' => 'yes'); +}); +magic(NoStartInstances => sub { + # If you use this magic, you must call $self->_start_instances() + # and optionally $self->_setup_http_service_objects() + # yourself from your test function once you're ready for Cyrus + # to be started! + # If any test function in your suite uses this magic, then + # your suite's set_up function cannot assume Cyrus is running! + shift->want('start_instances' => 0); +}); +magic(MagicPlus => sub { + shift->config_set('imapmagicplus' => 'yes'); +}); +magic(FastMailSharing => sub { + shift->config_set('fastmailsharing' => 'true'); +}); +magic(Partition2 => sub { + shift->config_set('partition-p2' => '@basedir@/data-p2'); +}); +magic(FastMailEvent => sub { + shift->config_set( + event_content_inclusion_mode => 'standard', + event_content_size => 1, # just the first byte + event_exclude_specialuse => '\\Junk', + event_extra_params => 'modseq vnd.fastmail.clientId service uidnext vnd.fastmail.sessionId vnd.cmu.envelope vnd.fastmail.convUnseen vnd.fastmail.convExists vnd.fastmail.cid vnd.cmu.mbtype vnd.cmu.davFilename vnd.cmu.davUid vnd.cmu.mailboxACL vnd.fastmail.counters messages vnd.cmu.unseenMessages flagNames vnd.cmu.visibleUsers', + event_groups => 'mailbox message flags calendar applepushservice', + ); +}); +magic(NoMunge8Bit => sub { + shift->config_set(munge8bit => 'no'); +}); +magic(RFC2047_UTF8 => sub { + shift->config_set(rfc2047_utf8 => 'yes'); +}); +magic(JMAPSearchDBLegacy => sub { + # XXX Needed for JMAPEmail.email_query_..._legacy (3.1-3.4). + # XXX Don't use in newer tests, and remove this someday when 3.4 is + # XXX obsolete. + shift->config_set('jmap_emailsearch_db_path' => + '@basedir@/search/jmap_emailsearch.db'); +}); +magic(JMAPQueryCacheMaxAge1s => sub { + shift->config_set('jmap_querycache_max_age' => '1s'); +}); +magic(JMAPNoHasAttachment => sub { + shift->config_set('jmap_set_has_attachment' => 'no'); +}); +magic(JMAPExtensions => sub { + shift->config_set('jmap_nonstandard_extensions' => 'yes'); +}); +magic(SearchAttachmentExtractor => sub { + my $port = Cassandane::PortManager::alloc("localhost"); + my $self = shift; + $self->config_set('search_attachment_extractor_url' => + "http://localhost:$port/extractor"); + $self->config_set('search_attachment_extractor_request_timeout' => '3s'); + $self->config_set('search_attachment_extractor_idle_timeout' => '3s'); +}); +magic(SearchLanguage => sub { + shift->config_set('search_index_language' => 'yes'); +}); +magic(SieveUTF8Fileinto => sub { + shift->config_set('sieve_utf8fileinto' => 'yes'); +}); +magic(SearchSetForceScanMode => sub { + shift->config_set(search_queryscan => '1'); +}); +magic(SearchFuzzyAlways => sub { + shift->config_set(search_fuzzy_always => '1'); +}); +magic(SearchEngineSquat => sub { + shift->config_set(search_engine => 'squat'); +}); +magic(SearchNormalizationMax20000 => sub { + shift->config_set(search_normalisation_max => 20000); +}); +magic(SearchMaxtime1Sec => sub { + shift->config_set(search_maxtime => 1); +}); +magic(SearchMaxSize4k => sub { + shift->config_set(search_maxsize => 4); +}); +magic(TLS => sub { + # XXX Here be dragons. Check existing tests that use this magic + # XXX for some of the hoops you may still need to jump through! + my $self = shift; + $self->config_set(tls_server_cert => '@basedir@/conf/certs/cert.pem'); + $self->config_set(tls_server_key => '@basedir@/conf/certs/key.pem'); + $self->want('install_certificates'); + $self->want_services('imaps'); +}); +magic(LowEmailLimits => sub { + # these settings are new in 3.3.0 -- any test using this magic + # also needs :min_version_3_3 + shift->config_set( + conversations_max_guidrecords => 10, + conversations_max_guidexists => 5, + conversations_max_guidinfolder => 2, + ); +}); +magic(HttpJWTAuthRSA => sub { + my $self = shift; + $self->config_set(http_jwt_key_dir => '@basedir@/conf/certs/http_jwt'); + $self->want('install_certificates'); +}); +magic(iCalendarMaxSize10k => sub { + shift->config_set(icalendar_max_size => 100000); +}); +magic(CalDAVNoDefaultCalendar => sub { + shift->config_set( + caldav_create_default => 'no', + ); +}); +magic(AltPTSDBPath => sub { + shift->config_set( + 'ptscache_db_path' => '@basedir@/conf/non-default-ptscache.db' + ); +}); +magic(ArchivePartition => sub { + my $conf = shift; + $conf->config_set('archivepartition-default' => '@basedir@/archive'); + $conf->config_set('archive_enabled' => 'yes'); + $conf->config_set('archive_after' => '7d'); +}); +magic(ArchiveNow => sub { + my $conf = shift; + $conf->config_set('archivepartition-default' => '@basedir@/archive'); + $conf->config_set('archive_enabled' => 'yes'); + $conf->config_set('archive_after' => '0d'); +}); +magic(AllowCalendarAdmin => sub { + my $conf = shift; + $conf->config_set('caldav_allowcalendaradmin' => 'yes'); +}); +magic(NoCheckSyslog => sub { + my $self = shift; + $self->{no_check_syslog} = 1; +}); +magic(JmapMaxCalendarEventNotifs => sub { + my $conf = shift; + # set to some small number + $conf->config_set('jmap_max_calendareventnotifs' => 10); +}); +magic(NoReplicaonly => sub { + my $self = shift; + $self->{no_replicaonly} = 1; +}); +magic(ConversationMaxThread10 => sub { + my $self = shift; + $self->config_set('conversation_max_thread' => 10); +}); +magic(SlowIO => sub { + my $self = shift; + $self->config_set('debug_slowio' => 'yes'); +}); +magic(Mboxgroups => sub { + my $self = shift; + $self->config_set('auth_mech' => 'mboxgroups'); +}); + +# Run any magic handlers indicated by the test name or attributes +sub _run_magic +{ + my ($self) = @_; + + my %seen; + + foreach my $m (split(/_/, $self->{_name})) + { + next if $seen{$m}; + next unless defined $magic_handlers{$m}; + $self->{_current_magic} = "Magic word $m in name"; + $magic_handlers{$m}->($self); + $self->{_current_magic} = undef; + $seen{$m} = 1; + } + + my $sub = $self->can($self->{_name}); + if (defined $sub) { + foreach my $a (attributes::get($sub)) + { + my $m = lc($a); + # ignore min/max version attribution here + next if $a =~ m/^(?:min|max)_version_/; + # ignore feature test attribution here + next if $a =~ m/^needs_/; + # ignore want attribution here + next if $a =~ m/^want_/; + die "Unknown attribute $a" + unless defined $magic_handlers{$m}; + next if $seen{$m}; + $self->{_current_magic} = "Magic attribute $a"; + $magic_handlers{$m}->($self); + $self->{_current_magic} = undef; + $seen{$m} = 1; + } + } +} + +sub _create_instances +{ + my ($self) = @_; + my $sync_port; + my $mupdate_port; + my $frontend_service_port; + my $backend1_service_port; + my $backend2_service_port; + my $backupd_port; + + $self->{_config} = $self->{_instance_params}->{config} || Cassandane::Config->default(); + $self->{_config} = $self->{_config}->clone(); + + $self->_run_magic(); + + my $want = $self->{_want}; + my %instance_params = %{$self->{_instance_params}}; + + my $cassini = Cassandane::Cassini->instance(); + + if ($want->{imapmurder} && $want->{httpmurder}) { + # XXX Murder is implemented assuming that everything is on standard + # XXX ports, but Cassandane needs to use high port numbers. + # XXX We fudge a workaround here by embedding the service port + # XXX number in the server name, which tricks Murder into using + # XXX that port number instead of the standard one, but that means + # XXX we can only have one proxied service per instance. + die "Cannot enable murder for both IMAP and JMAP at the same time"; + } + + if ($want->{instance}) + { + my $conf = $self->{_config}->clone(); + + if ($want->{replica} || $want->{csyncreplica}) + { + $sync_port = Cassandane::PortManager::alloc("localhost"); + $conf->set( + # sync_client will find the port in the config + sync_host => 'localhost', + sync_port => $sync_port, + # tell sync_client how to login + sync_authname => 'repluser', + sync_password => 'replpass', + ); + } + + if ($want->{imapmurder} || $want->{httpmurder}) + { + $mupdate_port = Cassandane::PortManager::alloc("localhost"); + $backend1_service_port = Cassandane::PortManager::alloc("localhost"); + + $conf->set( + servername => "localhost:$backend1_service_port", + mupdate_server => "localhost:$mupdate_port", + # XXX documentation says to use mupdate_port, but + # XXX this doesn't work -- need to embed port number in + # XXX mupdate_server setting instead. + #mupdate_port => $mupdate_port, + mupdate_username => 'mupduser', + mupdate_authname => 'mupduser', + mupdate_password => 'mupdpass', + proxyservers => 'mailproxy', + lmtp_admins => 'mailproxy', + proxy_authname => 'mailproxy', + proxy_password => 'mailproxy', + ); + } + + if ($want->{backups}) + { + $backupd_port = Cassandane::PortManager::alloc("localhost"); + $conf->set( + backup_sync_host => "localhost", + backup_sync_port => $backupd_port, + backup_sync_authname => 'repluser', + backup_sync_password => 'repluser', + backup_sync_try_imap => 'no', + xbackup_enabled => 'yes', + ); + } + + my $sub = $self->{_name}; + if ($sub =~ s/^test_/config_/ && $self->can($sub)) + { + die 'Use of config_ subs is not supported anymore'; + } + + $instance_params{config} = $conf; + $instance_params{install_certificates} = $want->{install_certificates}; + $instance_params{smtpdaemon} = $want->{smtpdaemon}; + + $instance_params{description} = "main instance for test $self->{_name}"; + $self->{instance} = Cassandane::Instance->new(%instance_params); + $self->{instance}->add_services(@{$want->{services}}); + $self->{instance}->_setup_for_deliver() + if ($want->{deliver}); + + if ($want->{squatter}) { + $self->{instance}->add_daemon( + name => 'squatter', + argv => [ + 'squatter', + '-R', + ], + wait => 'y', + ); + } + + if ($want->{replica} || $want->{csyncreplica}) + { + my %replica_params = %instance_params; + $replica_params{config} = $self->{_config}->clone(); + $replica_params{config}->set(sync_rightnow_channel => undef); + unless ($self->{no_replicaonly}) { + $replica_params{config}->set(replicaonly => 'yes'); + } + my $cyrus_replica_prefix = $cassini->val('cyrus replica', 'prefix'); + if (defined $cyrus_replica_prefix and -d $cyrus_replica_prefix) { + xlog $self, "replica instance: using [cyrus replica] configuration"; + $replica_params{installation} = 'replica'; + } + + $replica_params{description} = "replica instance for test $self->{_name}"; + $self->{replica} = Cassandane::Instance->new(%replica_params, + setup_mailbox => 0); + my ($v) = Cassandane::Instance->get_version($replica_params{installation}); + if ($v < 3 || $want->{csyncreplica}) { + $self->{replica}->add_service(name => 'sync', + port => $sync_port, + argv => ['sync_server']); + } + else { + $self->{replica}->add_service(name => 'sync', port => $sync_port); + } + $self->{replica}->add_services(@{$want->{services}}); + $self->{replica}->_setup_for_deliver() + if ($want->{deliver}); + } + + if ($want->{imapmurder} || $want->{httpmurder}) + { + $frontend_service_port = Cassandane::PortManager::alloc("localhost"); + $backend2_service_port = Cassandane::PortManager::alloc("localhost"); + + # set up a front end on which we also run the mupdate master + my $frontend_conf = $self->{_config}->clone(); + $frontend_conf->set( + servername => "localhost:$frontend_service_port", + mupdate_server => "localhost:$mupdate_port", + # XXX documentation says to use mupdate_port, but + # XXX this doesn't work -- need to embed port number in + # XXX mupdate_server setting instead. + #mupdate_port => $mupdate_port, + mupdate_username => 'mupduser', + mupdate_authname => 'mupduser', + mupdate_password => 'mupdpass', + serverlist => + "localhost:$backend1_service_port localhost:$backend2_service_port", + proxy_authname => 'mailproxy', + proxy_password => 'mailproxy', + ); + + my $cyrus_murder_prefix = $cassini->val('cyrus murder', 'prefix'); + if (defined $cyrus_murder_prefix and -d $cyrus_murder_prefix) { + xlog $self, "murder instance: using [cyrus murder] configuration"; + $instance_params{installation} = 'murder'; + } + + $instance_params{description} = "murder frontend for test $self->{_name}"; + $instance_params{config} = $frontend_conf; + $self->{frontend} = Cassandane::Instance->new(%instance_params, + setup_mailbox => 0); + $self->{frontend}->add_service(name => 'mupdate', + port => $mupdate_port, + argv => ['mupdate', '-m'], + prefork => 1); + $self->{frontend}->add_services(@{$want->{services}}); + $self->{frontend}->_setup_for_deliver() + if ($want->{deliver}); + + # arrange for frontend service to run on a known port + if ($want->{imapmurder}) { + $self->{frontend}->remove_service('imap'); + $self->{frontend}->add_service(name => 'imap', + port => $frontend_service_port); + } + elsif ($want->{httpmurder}) { + $self->{frontend}->remove_service('http'); + $self->{frontend}->add_service(name => 'http', + port => $frontend_service_port); + } + else { + die "shouldn't get here!"; + } + + # arrange for backend1 to push to mupdate on startup + $self->{instance}->add_start(name => 'mupdatepush', + argv => ['ctl_mboxlist', '-m']); + + # arrange for backend1 service to run on a known port + if ($want->{imapmurder}) { + $self->{instance}->remove_service('imap'); + $self->{instance}->add_service(name => 'imap', + port => $backend1_service_port); + } + elsif ($want->{httpmurder}) { + $self->{instance}->remove_service('http'); + $self->{instance}->add_service(name => 'http', + port => $backend1_service_port); + } + else { + die "shouldn't get here!"; + } + + # set up a second backend + my $backend2_conf = $self->{_config}->clone(); + $backend2_conf->set( + servername => "localhost:$backend2_service_port", + mupdate_server => "localhost:$mupdate_port", + # XXX documentation says to use mupdate_port, but + # XXX this doesn't work -- need to embed port number in + # XXX mupdate_server setting instead. + #mupdate_port => $mupdate_port, + mupdate_username => 'mupduser', + mupdate_authname => 'mupduser', + mupdate_password => 'mupdpass', + proxyservers => 'mailproxy', + lmtp_admins => 'mailproxy', + sasl_mech_list => 'PLAIN', + proxy_authname => 'mailproxy', + proxy_password => 'mailproxy', + ); + + $instance_params{description} = "murder backend2 for test $self->{_name}"; + $instance_params{config} = $backend2_conf; + $self->{backend2} = Cassandane::Instance->new(%instance_params, + setup_mailbox => 0); # XXX ? + $self->{backend2}->add_services(@{$want->{services}}); + + # arrange for backend2 to push to mupdate on startup + $self->{backend2}->add_start(name => 'mupdatepush', + argv => ['ctl_mboxlist', '-m']); + + # arrange for backend2 service to run on a known port + if ($want->{imapmurder}) { + $self->{backend2}->remove_service('imap'); + $self->{backend2}->add_service(name => 'imap', + port => $backend2_service_port); + } + elsif ($want->{httpmurder}) { + $self->{backend2}->remove_service('http'); + $self->{backend2}->add_service(name => 'http', + port => $backend2_service_port); + } + else { + die "shouldn't get here!"; + } + + $self->{backend2}->_setup_for_deliver() + if ($want->{deliver}); + } + + if ($want->{backups}) + { + # set up a backup server + my $backup_conf = $self->{_config}->clone(); + $backup_conf->set( + temp_path => '@basedir@/tmp', + backup_keep_previous => 'yes', + 'backuppartition-default' => '@basedir@/data/backup', + ); + + my $cyrus_backup_prefix = $cassini->val('cyrus backup', 'prefix'); + if (defined $cyrus_backup_prefix and -d $cyrus_backup_prefix) { + xlog $self, "backup instance: using [cyrus backup] configuration"; + $instance_params{installation} = 'backup'; + } + + $instance_params{description} = "backup server for test $self->{_name}"; + $instance_params{config} = $backup_conf; + + $self->{backups} = Cassandane::Instance->new(%instance_params, + setup_mailbox => 0); + $self->{backups}->add_service(name => 'backup', + port => $backupd_port, + argv => ['backupd']); + } + } + + if ($want->{gen}) + { + $self->{gen} = Cassandane::Generator->new(); + } +} + +sub _setup_http_service_objects +{ + my ($self) = @_; + + # nothing to do if no http service + require Mail::JMAPTalk; + require Net::CalDAVTalk; + require Net::CardDAVTalk; + + my $service = $self->{instance}->get_service("http"); + return if !$service; + + if ($self->{instance}->{config}->get_bit('httpmodules', 'carddav')) { + require Net::CardDAVTalk; + $self->{carddav} = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + } + if ($self->{instance}->{config}->get_bit('httpmodules', 'caldav')) { + require Net::CalDAVTalk; + $self->{caldav} = Net::CalDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + $self->{caldav}->UpdateAddressSet("Test User", + "cassandane\@example.com"); + } + if ($self->{instance}->{config}->get_bit('httpmodules', 'jmap')) { + require Mail::JMAPTalk; + $ENV{DEBUGJMAP} = 1; + $self->{jmap} = Mail::JMAPTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + } + + xlog $self, "http service objects setup complete!"; +} + +sub set_up +{ + my ($self) = @_; + + xlog "---------- BEGIN $self->{_name} ----------"; + + $self->_create_instances(); + if ($self->{_want}->{start_instances}) { + eval { + $self->_start_instances(); + $self->_setup_http_service_objects() if defined $self->{instance}; + }; + if ($@) { + my $e = $@; + $self->tear_down(); + die $e; + } + } + else { + xlog $self, "Instances not started due to :NoStartInstances magic!"; + xlog $self, "HTTP service objects not setup due to :NoStartInstances" + . " magic!"; + } + + if ($self->{no_check_syslog}) { + xlog $self, "Disabling syslog checks for test instance"; + } + + xlog $self, "Calling test function"; +} + +sub _start_instances +{ + my ($self) = @_; + + $self->{frontend}->start() + if (defined $self->{frontend}); + $self->{instance}->start() + if (defined $self->{instance}); + $self->{backend2}->start() + if (defined $self->{backend2}); + $self->{replica}->start() + if (defined $self->{replica}); + $self->{backups}->start() + if (defined $self->{backups}); + + $self->{store} = undef; + $self->{adminstore} = undef; + $self->{master_store} = undef; + $self->{master_adminstore} = undef; + $self->{replica_store} = undef; + $self->{replica_adminstore} = undef; + $self->{frontend_store} = undef; + $self->{frontend_adminstore} = undef; + $self->{backend1_store} = undef; + $self->{backend1_adminstore} = undef; + $self->{backend2_store} = undef; + $self->{backend2_adminstore} = undef; + + # Run the replication engine to create the user mailbox + # in the replica. Doing it this way avoids issues with + # mismatched mailbox uniqueids. + $self->run_replication() + if (defined $self->{replica}); + + my %store_params; + $store_params{folder} = $self->{_want}->{folder} + if defined $self->{_want}->{folder}; + + my %adminstore_params = ( %store_params, username => 'admin' ); + # The admin stores need an extra parameter to force their + # default folder because otherwise they will default to 'INBOX' + # which refers to user.admin not user.cassandane + $adminstore_params{folder} ||= 'INBOX'; + $adminstore_params{folder} = 'user.cassandane' + if ($adminstore_params{folder} =~ m/^inbox$/i); + + if (defined $self->{instance}) + { + my $svc = $self->{instance}->get_service('imap'); + if (defined $svc) + { + $self->{store} = $svc->create_store(%store_params) + if ($self->{_want}->{store}); + $self->{adminstore} = $svc->create_store(%adminstore_params) + if ($self->{_want}->{adminstore}); + } + } + if (defined $self->{replica}) + { + # aliases for the master's store(s) + $self->{master_store} = $self->{store}; + $self->{master_adminstore} = $self->{adminstore}; + + my $svc = $self->{replica}->get_service('imap'); + if (defined $svc) + { + $self->{replica_store} = $svc->create_store(%store_params) + if ($self->{_want}->{store}); + $self->{replica_adminstore} = $svc->create_store(%adminstore_params) + if ($self->{_want}->{adminstore}); + } + } + if (defined $self->{frontend}) + { + # aliases for first backend store + $self->{backend1_store} = $self->{store}; + $self->{backend1_adminstore} = $self->{adminstore}; + + my $svc = $self->{frontend}->get_service('imap'); + if (defined $svc) + { + $self->{frontend_store} = $svc->create_store(%store_params) + if ($self->{_want}->{store}); + $self->{frontend_adminstore} = $svc->create_store(%adminstore_params) + if ($self->{_want}->{adminstore}); + } + } + if (defined $self->{backend2}) + { + my $svc = $self->{backend2}->get_service('imap'); + if (defined $svc) + { + $self->{backend2_store} = $svc->create_store(%store_params) + if ($self->{_want}->{store}); + $self->{backend2_adminstore} = $svc->create_store(%adminstore_params) + if ($self->{_want}->{adminstore}); + } + } +} + +sub tear_down +{ + my ($self) = @_; + + xlog $self, "Beginning tear_down"; + + foreach my $s (@stores) + { + if (defined $self->{$s}) + { + $self->{$s}->disconnect(); + $self->{$s} = undef; + } + } + $self->{master_store} = undef; + $self->{master_adminstore} = undef; + $self->{backend1_store} = undef; + $self->{backend1_adminstore} = undef; + + my @stop_errors; + my @basedirs; + + if (defined $self->{instance}) + { + eval { + push @stop_errors, $self->{instance}->stop( + no_check_syslog => defined $self->{no_check_syslog} + ); + }; + push @basedirs, $self->{instance}->get_basedir(); + $self->{instance} = undef; + } + if (defined $self->{backups}) + { + eval { + push @stop_errors, $self->{backups}->stop( + no_check_syslog => defined $self->{no_check_syslog} + ); + }; + push @basedirs, $self->{backups}->get_basedir(); + $self->{backups} = undef; + } + if (defined $self->{backend2}) + { + eval { + push @stop_errors, $self->{backend2}->stop( + no_check_syslog => defined $self->{no_check_syslog} + ); + }; + push @basedirs, $self->{backend2}->get_basedir(); + $self->{backend2} = undef; + } + if (defined $self->{replica}) + { + eval { + push @stop_errors, $self->{replica}->stop( + no_check_syslog => defined $self->{no_check_syslog} + ); + }; + push @basedirs, $self->{replica}->get_basedir(); + $self->{replica} = undef; + } + if (defined $self->{frontend}) + { + eval { + push @stop_errors, $self->{frontend}->stop( + no_check_syslog => defined $self->{no_check_syslog} + ); + }; + push @basedirs, $self->{frontend}->get_basedir(); + $self->{frontend} = undef; + } + + $self->{cleanup_basedirs} = [@basedirs]; + + # maybe there's multiple errors, but we can only die for one of them... + die $stop_errors[0] if scalar @stop_errors; + + xlog "---------- END $self->{_name} ----------"; +} + +sub post_tear_down +{ + my ($self, $result) = @_; + + if ($result eq 'pass' + && ref $self->{cleanup_basedirs} + && Cassandane::Cassini->instance()->bool_val('cassandane', 'cleanup') + ) { + foreach my $basedir (@{$self->{cleanup_basedirs}}) { + xlog $self, "Cleaning up basedir " . $basedir; + rmtree $basedir; + } + } + + die "Found some stray processes" + if (Cassandane::GenericListener::kill_processes_on_ports( + Cassandane::PortManager::free_all())); +} + +sub _save_message +{ + my ($self, $msg, $store) = @_; + + $store ||= $self->{store}; + + $store->write_begin(); + $store->write_message($msg); + $store->write_end(); +} + +sub make_message +{ + my ($self, $subject, %attrs) = @_; + + my $store = $attrs{store}; # may be undef + delete $attrs{store}; + + my $msg = $self->{gen}->generate(subject => $subject, %attrs); + $msg->remove_headers('subject') if !defined $subject; + $msg->remove_headers('to') if exists $attrs{to} and not $attrs{to}; + $msg->remove_headers('from') if exists $attrs{from} and not $attrs{from}; + $self->_save_message($msg, $store); + + return $msg; +} + +sub make_random_data +{ + my ($self, $kb, %params) = @_; + my $data = ''; + $params{minreps} = 10 + unless defined $params{minreps}; + $params{maxreps} = 100 + unless defined $params{maxreps}; + $params{separators} = ' ' + unless defined $params{separators}; + my $sepidx = 0; + while (!defined $kb || length($data) < 1024*$kb) + { + my $word = random_word(); + my $count = $params{minreps} + + rand($params{maxreps} - $params{minreps}); + while ($count > 0) + { + my $sep = substr($params{separators}, + $sepidx % length($params{separators}), 1); + $sepidx++; + $data .= $sep . $word; + $count--; + } + last unless defined $kb; + } + return $data; +} + +sub check_messages +{ + my ($self, $expected, %params) = @_; + my $actual = $params{actual}; + my $check_guid = $params{check_guid}; + $check_guid = 1 unless defined $check_guid; + my $keyed_on = $params{keyed_on} || 'subject'; + + xlog $self, "check_messages: " . join(' ', %params); + + if (!defined $actual) + { + my $store = $params{store} || $self->{store}; + $actual = {}; + $store->read_begin(); + while (my $msg = $store->read_message()) + { + my $key = $msg->$keyed_on(); + $self->assert(!defined $actual->{$key}); + $actual->{$key} = $msg; + } + $store->read_end(); + } + + $self->assert_num_equals(scalar keys %$expected, scalar keys %$actual); + + foreach my $expmsg (values %$expected) + { + my $key = $expmsg->$keyed_on(); + xlog $self, "message \"$key\""; + my $actmsg = $actual->{$key}; + + $self->assert_not_null($actmsg); + + if ($check_guid) + { + xlog $self, "checking guid"; + $self->assert_str_equals($expmsg->get_guid(), + $actmsg->get_guid()); + } + + # Check required headers + foreach my $h (qw(x-cassandane-unique)) + { + xlog $self, "checking header $h"; + $self->assert_not_null($actmsg->get_header($h)); + $self->assert_str_equals($expmsg->get_header($h), + $actmsg->get_header($h)); + } + + # if there were optional headers we wished to check, do it here + + # check optional string attributes + foreach my $a (qw(id uid cid)) + { + next unless defined $expmsg->get_attribute($a); + xlog $self, "checking attribute $a"; + $self->assert_str_equals($expmsg->get_attribute($a), + $actmsg->get_attribute($a)); + } + + # check optional structured attributes + foreach my $a (qw(modseq)) + { + next unless defined $expmsg->get_attribute($a); + xlog $self, "checking attribute $a"; + $self->assert_deep_equals($expmsg->get_attribute($a), + $actmsg->get_attribute($a)); + } + + # check optional order-agnostic attributes + foreach my $a (qw(flags)) + { + next unless defined $expmsg->get_attribute($a); + xlog $self, "checking attribute $a"; + + my $exp = $expmsg->get_attribute($a); + my $act = $actmsg->get_attribute($a); + + if (ref $exp eq 'ARRAY') { + $exp = [ sort @{$exp} ]; + } + if (ref $act eq 'ARRAY') { + $act = [ sort @{$act} ]; + } + + $self->assert_deep_equals($exp, $act); + } + + # check annotations + foreach my $ea ($expmsg->list_annotations()) + { + xlog $self, "checking annotation ($ea->{entry} $ea->{attrib})"; + $self->assert($actmsg->has_annotation($ea)); + my $expval = $expmsg->get_annotation($ea); + my $actval = $actmsg->get_annotation($ea); + if (defined $expval) + { + $self->assert_not_null($actval); + $self->assert_str_equals($expval, $actval); + } + else + { + $self->assert_null($actval); + } + } + } + + return $actual; +} + +sub _disconnect_all +{ + my ($self) = @_; + + foreach my $s (@stores) + { + $self->{$s}->disconnect() + if defined $self->{$s}; + } +} + +sub _reconnect_all +{ + my ($self) = @_; + + foreach my $s (@stores) + { + if (defined $self->{$s}) + { + $self->{$s}->connect(); + $self->{$s}->_select(); + } + } +} + +sub run_replication +{ + my ($self, %opts) = @_; + + # Parse options from caller + my $server = $self->{replica}->get_service('sync')->store_params()->{host}; + $server = delete $opts{server} if exists $opts{server}; + # $server might be undef at this point + my $channel = delete $opts{channel}; + my $inputfile = delete $opts{inputfile}; + + # mode options + my $nmodes = 0; + my $user = delete $opts{user}; + my $rolling = delete $opts{rolling}; + my $mailbox = delete $opts{mailbox}; + my $meta = delete $opts{meta}; + my $nosyncback = delete $opts{nosyncback}; + my $allusers = delete $opts{allusers}; + my $stagetoarchive = delete $opts{stagetoarchive}; + $nmodes++ if $user; + $nmodes++ if $rolling; + $nmodes++ if $mailbox; + $nmodes++ if $meta; + $nmodes++ if $allusers; + + # pass through run_command options + my $handlers = delete $opts{handlers}; + my $redirects = delete $opts{redirects}; + + # historical default for Cassandane tests is user mode + $user = 'cassandane' if ($nmodes == 0); + die "Too many mode options" if ($nmodes > 1); + + die "Unrecognised options: " . join(' ', keys %opts) if (scalar %opts); + + xlog $self, "running replication"; + + # Disconnect during replication to ensure no imapd + # is locking the mailbox, which gives us a spurious + # error which is ignored in real world scenarios. + $self->_disconnect_all(); + + # build sync_client command line + my @cmd = ('sync_client', '-v', '-v', '-o'); + push(@cmd, '-S', $server) if defined $server; + push(@cmd, '-n', $channel) if defined $channel; + push(@cmd, '-f', $inputfile) if defined $inputfile; + push(@cmd, '-R') if defined $rolling; + push(@cmd, '-s') if defined $meta; + push(@cmd, '-O') if defined $nosyncback; + push(@cmd, '-u', $user) if defined $user; + push(@cmd, '-m', $mailbox) if defined $mailbox; + push(@cmd, '-A') if $allusers; # n.b. boolean + push(@cmd, '-a') if $stagetoarchive; # n.b. boolean + + my %run_options; + $run_options{cyrus} = 1; + $run_options{handlers} = $handlers if defined $handlers; + $run_options{redirects} = $redirects if defined $redirects; + $self->{instance}->run_command(\%run_options, @cmd); + + $self->_reconnect_all(); +} + +sub check_replication { + my ($self, $user) = @_; + + # get store connections as the user + + my $mastersvc = $self->{instance}->get_service('imap'); + my $masterstore = $mastersvc->create_store(username => $user); + + my $replicasvc = $self->{replica}->get_service('imap'); + my $replicastore = $replicasvc->create_store(username => $user); + + my $CR = Cyrus::CheckReplication->new( + IMAPs1 => $masterstore->get_client(), + IMAPs2 => $replicastore->get_client(), + CyrusName => $user, + SleepTime => 0, + Repeats => 1, + CheckConversations => 1, + CheckAnnotations => 1, + CheckMetadata => 1, + ); + $CR->CheckUserReplication(2); + if ($CR->HasError()) { + my @Messages = $CR->GetMessages(); + $self->assert(0, "GOT ERRORS " . join(', ', @Messages)); + } +} + +sub run_delayed_expunge +{ + my ($self) = @_; + + xlog $self, "Performing delayed expunge"; + + $self->_disconnect_all(); + + my @cmd = ( 'cyr_expire', '-E', '1', '-X', '0', '-D', '0' ); + push(@cmd, '-v') + if get_verbose; + $self->{instance}->run_command({ cyrus => 1 }, @cmd); + + $self->_reconnect_all(); +} + +sub check_conversations +{ + my ($self) = @_; + my $filename = $self->{instance}{basedir} . "/ctl_conversationsdb.out"; + $self->{instance}->run_command({ + cyrus => 1, + redirects => {stdout => $filename}, + }, 'ctl_conversationsdb', '-A', '-r', '-v'); + + my $str = slurp_file($filename); + + xlog $self, "RESULT: $str"; + $self->assert_matches(qr/is OK/, $str); + $self->assert_does_not_match(qr/is BROKEN/, $str); +} + +# Set up a mailbox structure from data +# See List.pm tests for examples of how to drive this +sub setup_mailbox_structure +{ + my ($self, $client, $test_data) = @_; + + foreach my $row (@{$test_data}) { + my ($cmd, $arg) = @{$row}; + if (ref $arg) { + foreach (@{$arg}) { + $client->$cmd($_) || die "$cmd '$_': $@"; + } + } + else { + $client->$cmd($arg) || die "$cmd '$_': $@"; + } + } +} + +# Assert that the provided LIST response match the expected data +# See List.pm tests for examples of how to drive this +sub assert_mailbox_structure +{ + my ($self, $actual, $expected_hiersep, $expected_mailbox_flags, + $strict, $msg) = @_; + + # rearrange list output into order-agnostic format + my %actual_hash; + foreach my $row (@{$actual}) { + my ($flags, $hiersep, $mailbox) = @{$row}; + + $actual_hash{$mailbox} = { + flags => { map { (lc($_) => 1) } @{$flags} }, + hiersep => $hiersep, + mailbox => $mailbox, + } + } + + # check that expected data exists + foreach my $mailbox (sort keys %{$expected_mailbox_flags}) { + xlog $self, "expect mailbox: $mailbox"; + $self->assert( + exists $actual_hash{$mailbox}, + "'$mailbox': mailbox not found" + ); + + $self->assert_str_equals( + $actual_hash{$mailbox}->{hiersep}, + $expected_hiersep, + "'$mailbox': got hierarchy separator '" + . $actual_hash{$mailbox}->{hiersep} + . "', expected '$expected_hiersep'" + ); + + my %expected_flags; + if (ref $expected_mailbox_flags->{$mailbox}) { + %expected_flags = map { (lc($_) => 1) } + @{$expected_mailbox_flags->{$mailbox}}; + } + else { + %expected_flags = map { (lc($_) => 1) } + split / /, $expected_mailbox_flags->{$mailbox}; + } + + # look for expected flags + foreach my $flag (sort keys %expected_flags) { + # https://tools.ietf.org/html/rfc5258#section-3.4: + # \NoInferiors implies \HasNoChildren + # \NonExistent implies \NoSelect + if ($flag eq "\\hasnochildren") { + $self->assert( + (exists $actual_hash{$mailbox}->{flags}->{$flag} + || exists $actual_hash{$mailbox}->{flags}->{"\\noinferiors"}), + "'$mailbox': missing flag '$flag'" + ); + } + elsif ($flag eq "\\noselect") { + $self->assert( + (exists $actual_hash{$mailbox}->{flags}->{$flag} + || exists $actual_hash{$mailbox}->{flags}->{"\\nonexistent"}), + "'$mailbox': missing flag '$flag'" + ); + } + else { + $self->assert( + exists $actual_hash{$mailbox}->{flags}->{$flag}, + "'$mailbox': missing flag '$flag'" + ); + } + } + + next if not $strict; + + # look for unexpected flags + foreach my $flag (sort keys %{$actual_hash{$mailbox}->{flags}}) { + if ($flag eq "\\noinferiors") { + $self->assert( + (exists $actual_hash{$mailbox}->{flags}->{$flag} + || exists $actual_hash{$mailbox}->{flags}->{"\\hasnochildren"}), + "'$mailbox': found unexpected flag '$flag'" + ); + } + elsif ($flag eq "\\nonexistent") { + $self->assert( + (exists $actual_hash{$mailbox}->{flags}->{$flag} + || exists $actual_hash{$mailbox}->{flags}->{"\\noselect"}), + "'$mailbox': found unexpected flag '$flag'" + ); + } + else { + $self->assert( + exists $expected_flags{$flag}, + "'$mailbox': found unexected flag '$flag'" + ); + } + } + } + + # check that unexpected data does not exist + foreach my $mailbox (sort keys %actual_hash) { + $self->assert( + exists $expected_mailbox_flags->{$mailbox}, + "'$mailbox': found unexpected extra mailbox" + ); + } +} + +sub assert_sieve_exists +{ + my ($self, $instance, $user, $scriptname, $bc_only) = @_; + + my $sieve_dir = $instance->get_sieve_script_dir($user); + + $self->assert(( -f "$sieve_dir/$scriptname.bc" ), + "$sieve_dir/$scriptname.bc: file not found"); + + if ($bc_only == 0) { + $self->assert(( -f "$sieve_dir/$scriptname.script" ), + "$sieve_dir/$scriptname.script: file not found"); + } +} + +sub assert_sieve_not_exists +{ + my ($self, $instance, $user, $scriptname, $bc_only) = @_; + + my $sieve_dir = $instance->get_sieve_script_dir($user); + + $self->assert(( ! -f "$sieve_dir/$scriptname.bc" ), + "$sieve_dir/$scriptname.bc: file exists"); + + if ($bc_only == 0) { + $self->assert(( ! -f "$sieve_dir/$scriptname.script" ), + "$sieve_dir/$scriptname.script: file exists"); + } +} + +sub assert_sieve_active +{ + my ($self, $instance, $user, $scriptname) = @_; + + my $sieve_dir = $instance->get_sieve_script_dir($user); + + $self->assert(( -l "$sieve_dir/defaultbc" ), + "$sieve_dir/defaultbc: missing or not a symlink"); + $self->assert_str_equals("$scriptname.bc", readlink "$sieve_dir/defaultbc"); +} + +sub assert_sieve_noactive +{ + my ($self, $instance, $user) = @_; + + my $sieve_dir = $instance->get_sieve_script_dir($user); + + $self->assert(( ! -e "$sieve_dir/defaultbc" ), + "$sieve_dir/defaultbc exists"); + $self->assert(( ! -l "$sieve_dir/defaultbc" ), + "dangling $sieve_dir/defaultbc symlink exists"); +} + +sub assert_sieve_matches +{ + my ($self, $instance, $user, $scriptname, $scriptcontent) = @_; + + my $sieve_dir = $instance->get_sieve_script_dir($user); + + my $bcname = "$sieve_dir/$scriptname.bc"; + + $self->assert(( -f $bcname ), + "$sieve_dir/$scriptname.bc: file not found"); + + # compile $scriptcontent and compare digests of bytecode + my (undef, $tmp) = tempfile('scriptXXXXX', OPEN => 0, + DIR => $instance->{basedir} . "/tmp"); + open my $f, '>', $tmp or die "open: $!"; + print $f $scriptcontent; + close $f; + + my (undef, $filename) = tempfile('tmpXXXXXX', OPEN => 0, + DIR => $instance->{basedir} . "/tmp"); + + $instance->run_command({ redirects => {stdin => \$scriptcontent}, + cyrus => 1, + }, + 'sievec', $tmp, "$filename"); + $self->assert_str_equals(digest_file_hex($bcname, "MD5"), + digest_file_hex($filename, "MD5")); +} + +sub assert_syslog_matches +{ + my ($self, $instance, $pattern) = @_; + + if ($instance->{have_syslog_replacement}) { + $self->assert((scalar $instance->getsyslog($pattern) >= 1), + "syslog does not match pattern $pattern"); + } +} + +sub assert_syslog_does_not_match +{ + my ($self, $instance, $pattern) = @_; + + if ($instance->{have_syslog_replacement}) { + $self->assert((scalar $instance->getsyslog($pattern) == 0), + "syslog matches pattern $pattern"); + } +} + +# create a bunch of mailboxes and messages with various flags and annots, +# returning a hash of what to expect to find there later +sub populate_user +{ + my ($self, $instance, $store, $folders) = @_; + + my $created = {}; + + my @specialuse = qw(Drafts Junk Sent Trash); + + foreach my $folder (@{$folders}) { + $store->set_folder($folder); + + # create some messages + foreach my $n (1 .. 20) { + my $msg = $self->make_message("Message $n", store => $store); + $created->{mailboxes}->{$folder}->{messages}->{$msg->uid()} = $msg; + } + + # fizzbuzz some flags + my $talk = $store->get_client(); + $talk->select($folder); + my $n = 1; + while (my ($uid, $msg) + = each %{$created->{mailboxes}->{$folder}->{messages}}) + { + my @flags; + + if ($n % 3 == 0) { + # fizz + $talk->store("$uid", '+flags', '(\\Flagged)'); + $self->assert_str_equals( + 'ok', $talk->get_last_completion_response() + ); + push @flags, '\\Flagged'; + } + if ($n % 5 == 0) { + # buzz + $talk->store("$uid", '+flags', '(\\Deleted)'); + $self->assert_str_equals( + 'ok', $talk->get_last_completion_response() + ); + push @flags, '\\Deleted'; + } + + $msg->set_attribute('flags', \@flags) if scalar @flags; + $n++; + } + + # make sure the messages are as expected + $store->set_fetch_attributes('uid', 'flags'); + $self->check_messages($created->{mailboxes}->{$folder}->{messages}, + store => $store, + check_guid => 0, + keyed_on => 'uid'); + + # maybe set a special use annotation if the folder name is such + my ($suflag, @extra) = grep { + lc $folder =~ m{^(?:INBOX[./])?$_$} + } @specialuse; + if ($suflag and not scalar @extra) { + $talk->setmetadata($folder, '/private/specialuse', "\\$suflag"); + $self->assert_str_equals('ok', + $talk->get_last_completion_response()); + $created->{mailboxes}->{$folder}->{specialuse} = "\\$suflag"; + } + } + + # XXX ought to be conditional on whether $instance was built with sieve + # XXX support, but Cassandane::BuildInfo doesn't currently support + # XXX choosing an instance to ask about... + my $scriptname = random_word(); + my $scriptcontent = 'keep;'; + + $instance->install_sieve_script($scriptcontent, + name => $scriptname, + username => $store->{username}); + $self->assert_sieve_exists($instance, $store->{username}, $scriptname); + $self->assert_sieve_active($instance, $store->{username}, $scriptname); + + $created->{sieve}->{scripts}->{$scriptname} = $scriptcontent; + $created->{sieve}->{active} = $scriptname; + + return $created; +} + +# check that the contents of the store match the data returned by +# populate_user() +sub check_user +{ + my ($self, $instance, $store, $expected) = @_; + + die "bad expected hash" if ref $expected ne 'HASH'; + + foreach my $folder (keys %{$expected->{mailboxes}}) { + $store->set_folder($folder); + $store->set_fetch_attributes('uid', 'flags'); + $self->check_messages($expected->{mailboxes}->{$folder}->{messages}, + store => $store, + check_guid => 0, + keyed_on => 'uid'); + + my $specialuse = $expected->{mailboxes}->{$folder}->{specialuse}; + if ($specialuse) { + my $talk = $store->get_client(); + my $res = $talk->getmetadata($folder, '/private/specialuse'); + $self->assert_str_equals('ok', + $talk->get_last_completion_response()); + $self->assert_not_null($res); + + $self->assert_str_equals($specialuse, + $res->{$folder}->{'/private/specialuse'}); + } + } + + if (exists $expected->{sieve}) { + while (my ($scriptname, $scriptcontent) + = each %{$expected->{sieve}->{scripts}}) + { + $self->assert_sieve_exists($instance, $store->{username}, + $scriptname, 1); + $self->assert_sieve_matches($instance, $store->{username}, + $scriptname, $scriptcontent); + } + if ($expected->{sieve}->{active}) { + $self->assert_sieve_active($instance, $store->{username}, + $expected->{sieve}->{active}); + } + } +} + +1; diff --git a/cassandane/Cassandane/Cyrus/TesterCalDAV.pm b/cassandane/Cassandane/Cyrus/TesterCalDAV.pm new file mode 100644 index 0000000000..98e748162a --- /dev/null +++ b/cassandane/Cassandane/Cyrus/TesterCalDAV.pm @@ -0,0 +1,1693 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::TesterCalDAV; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use JSON::XS; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Cassini; +use Net::CalDAVTalk; + +my $basedir; +my $binary; +my $testdir; +my %suppressed; +my %expected; + +my $KNOWN_ERRORS = <SERVER/10 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER/-1 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER/11 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER/5 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER/6 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER/7 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER/9 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/10 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/-1 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/11 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/12 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/13 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/14 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/2 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/3 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/4 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/6 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start)/9 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/10 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/-1 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/11 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/12 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/13 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/2 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/3 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/4 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/5 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/6 | 1 +implicitscheduleagent/Organizer CLIENT->SERVER (no ATTENDEE at start recurring)/9 | 1 +implicitscheduleagent/Organizer SERVER->NONE, no matching ATTENDEE/1 | 1 +implicitscheduleagent/Organizer SERVER->NONE, no matching ATTENDEE/2 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/-1 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/11 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/12 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/13 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/15 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/16 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/2 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/21 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/22 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/23 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/25 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/26 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/3 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/31 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/33 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/34 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/35 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/4 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/6 | 1 +implicitscheduleagent/Simple invite of three attendees - two initially not auto-scheduled/7 | 1 +implicitscheduletag/Update to resource with schedule-tag behavior/3a | 1 +implicitscheduletag/Update to resource with schedule-tag behavior/3b | 1 +implicitsecurity/Prevent ATTENDEE party crash/2 | 1 +implicitsecurity/Prevent ATTENDEE party crash/3 | 1 +implicitsecurity/Prevent ATTENDEE party crash/4 | 1 +implicitsecurity/Prevent ATTENDEE party crash/7 | 1 +implicitsecurity/Prevent ATTENDEE party crash/8 | 1 +implicitsecurity/Prevent ATTENDEE party crash/9 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/10 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/11 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/12 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/13 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/2 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/3 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/4 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/6 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - an attendee) via overwrite/8 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/10 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/11 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/12 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/13 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/2 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/3 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/4 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/6 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER (someone else - not an attendee) via overwrite/8 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/10 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/11 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/12 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/13 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/2 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/3 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/4 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/6 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/7 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via new event/9 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/10 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/11 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/12 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/13 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/2 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/3 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/4 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/6 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/8 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER via overwrite/9 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER without them as ATTENDEE via overwrite/10 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER without them as ATTENDEE via overwrite/2 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER without them as ATTENDEE via overwrite/3 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER without them as ATTENDEE via overwrite/5 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER without them as ATTENDEE via overwrite/6 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER without them as ATTENDEE via overwrite/7 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER without them as ATTENDEE via overwrite/8 | 1 +implicitsecurity/Prevent ATTENDEE switching ORGANIZER without them as ATTENDEE via overwrite/9 | 1 +implicitsecurity/Prevent ORGANIZER forgeries/10 | 1 +implicitsecurity/Prevent ORGANIZER forgeries/2 | 1 +implicitsecurity/Prevent ORGANIZER forgeries/3 | 1 +implicitsecurity/Prevent ORGANIZER forgeries/4 | 1 +implicitsecurity/Prevent ORGANIZER forgeries/7 | 1 +implicitsecurity/Prevent ORGANIZER forgeries/8 | 1 +implicitsecurity/Prevent ORGANIZER forgeries/9 | 1 +implicitsecurity/Prevent ORGANIZER switching ORGANIZER without them as ATTENDEE via overwrite/10 | 1 +implicitsecurity/Prevent ORGANIZER switching ORGANIZER without them as ATTENDEE via overwrite/2 | 1 +implicitsecurity/Prevent ORGANIZER switching ORGANIZER without them as ATTENDEE via overwrite/3 | 1 +implicitsecurity/Prevent ORGANIZER switching ORGANIZER without them as ATTENDEE via overwrite/5 | 1 +implicitsecurity/Prevent ORGANIZER switching ORGANIZER without them as ATTENDEE via overwrite/7 | 1 +implicitsecurity/Prevent ORGANIZER switching ORGANIZER without them as ATTENDEE via overwrite/8 | 1 +implicitsecurity/Prevent ORGANIZER switching ORGANIZER without them as ATTENDEE via overwrite/9 | 1 +implicitsequence/Lower Sequence/12 | 1 +implicitsequence/Lower Sequence/13 | 1 +implicitsequence/Lower Sequence/2 | 1 +implicitsequence/Lower Sequence/3 | 1 +implicitsequence/Lower Sequence/4 | 1 +implicitsequence/Lower Sequence/7 | 1 +implicitsequence/Lower Sequence/8 | 1 +implicitsequence/Lower Sequence/9 | 1 +implicitsequence/Recreate with Lower Sequence/-1 | 1 +implicitsequence/Recreate with Lower Sequence/11 | 1 +implicitsequence/Recreate with Lower Sequence/12 | 1 +implicitsequence/Recreate with Lower Sequence/13 | 1 +implicitsequence/Recreate with Lower Sequence/2 | 1 +implicitsequence/Recreate with Lower Sequence/3 | 1 +implicitsequence/Recreate with Lower Sequence/4 | 1 +implicitsequence/Recreate with Lower Sequence/8 | 1 +implicitsequence/Recreate with Lower Sequence/9 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/11 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/12 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/13 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/15 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/2 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/3 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/4 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/6 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/7 | 1 +implicittimezones/Change event spanning a DST transition with R-IDs on either side/8 | 1 +implicittodo/Attendee Delete/10 | 1 +implicittodo/Attendee Delete/11 | 1 +implicittodo/Attendee Delete/12 | 1 +implicittodo/Attendee Delete/2 | 1 +implicittodo/Attendee Delete/3 | 1 +implicittodo/Attendee Delete/4 | 1 +implicittodo/Attendee Delete/6 | 1 +implicittodo/Attendee Delete/7 | 1 +implicittodo/Attendee Delete/8 | 1 +implicittodo/Client removing properties fix/10 | 1 +implicittodo/Client removing properties fix/-1 | 1 +implicittodo/Client removing properties fix/2 | 1 +implicittodo/Client removing properties fix/3 | 1 +implicittodo/Client removing properties fix/4 | 1 +implicittodo/Client removing properties fix/6 | 1 +implicittodo/Client removing properties fix/7 | 1 +implicittodo/Client removing properties fix/8 | 1 +implicittodo/Client removing properties fix/9 | 1 +implicittodo/Organizer Delete/11 | 1 +implicittodo/Organizer Delete/12 | 1 +implicittodo/Organizer Delete/13 | 1 +implicittodo/Organizer Delete/2 | 1 +implicittodo/Organizer Delete/3 | 1 +implicittodo/Organizer Delete/4 | 1 +implicittodo/Organizer Delete/6 | 1 +implicittodo/Organizer Delete/7 | 1 +implicittodo/Organizer Delete/8 | 1 +implicittodo/Per-attendee completed/10 | 1 +implicittodo/Per-attendee completed/-1 | 1 +implicittodo/Per-attendee completed/11 | 1 +implicittodo/Per-attendee completed/13 | 1 +implicittodo/Per-attendee completed/14 | 1 +implicittodo/Per-attendee completed/15 | 1 +implicittodo/Per-attendee completed/16 | 1 +implicittodo/Per-attendee completed/18 | 1 +implicittodo/Per-attendee completed/2 | 1 +implicittodo/Per-attendee completed/3 | 1 +implicittodo/Per-attendee completed/4 | 1 +implicittodo/Per-attendee completed/6 | 1 +implicittodo/Per-attendee completed/7 | 1 +implicittodo/Per-attendee completed/9 | 1 +implicittodo/Simple Changes/11 | 1 +implicittodo/Simple Changes/12 | 1 +implicittodo/Simple Changes/13 | 1 +implicittodo/Simple Changes/15 | 1 +implicittodo/Simple Changes/16 | 1 +implicittodo/Simple Changes/17 | 1 +implicittodo/Simple Changes/19 | 1 +implicittodo/Simple Changes/2 | 1 +implicittodo/Simple Changes/3 | 1 +implicittodo/Simple Changes/4 | 1 +implicittodo/Simple Changes/6 | 1 +implicittodo/Simple Changes/7 | 1 +implicittodo/Simple Changes/8 | 1 +implicitxdash/Attendee to Organizer/10 | 1 +implicitxdash/Attendee to Organizer/-1 | 1 +implicitxdash/Attendee to Organizer/11 | 1 +implicitxdash/Attendee to Organizer/13 | 1 +implicitxdash/Attendee to Organizer/14 | 1 +implicitxdash/Attendee to Organizer/15 | 1 +implicitxdash/Attendee to Organizer/16 | 1 +implicitxdash/Attendee to Organizer/17 | 1 +implicitxdash/Attendee to Organizer/18 | 1 +implicitxdash/Attendee to Organizer/19 | 1 +implicitxdash/Attendee to Organizer/20 | 1 +implicitxdash/Attendee to Organizer/2 | 1 +implicitxdash/Attendee to Organizer/21 | 1 +implicitxdash/Attendee to Organizer/22 | 1 +implicitxdash/Attendee to Organizer/23 | 1 +implicitxdash/Attendee to Organizer/24 | 1 +implicitxdash/Attendee to Organizer/25 | 1 +implicitxdash/Attendee to Organizer/26 | 1 +implicitxdash/Attendee to Organizer/27 | 1 +implicitxdash/Attendee to Organizer/28 | 1 +implicitxdash/Attendee to Organizer/29 | 1 +implicitxdash/Attendee to Organizer/3 | 1 +implicitxdash/Attendee to Organizer/4 | 1 +implicitxdash/Attendee to Organizer/5 | 1 +implicitxdash/Attendee to Organizer/6 | 1 +implicitxdash/Attendee to Organizer/7 | 1 +implicitxdash/Attendee to Organizer/8 | 1 +implicitxdash/Attendee to Organizer/9 | 1 +implicitxdash/Organizer to Attendee/10 | 1 +implicitxdash/Organizer to Attendee/-1 | 1 +implicitxdash/Organizer to Attendee/11 | 1 +implicitxdash/Organizer to Attendee/12 | 1 +implicitxdash/Organizer to Attendee/2 | 1 +implicitxdash/Organizer to Attendee/3 | 1 +implicitxdash/Organizer to Attendee/4 | 1 +implicitxdash/Organizer to Attendee/5 | 1 +implicitxdash/Organizer to Attendee/6 | 1 +implicitxdash/Organizer to Attendee/7 | 1 +implicitxdash/Organizer to Attendee/8 | 1 +json/Freebusy json/2 | 1 +mkcalendar/MKCALENDAR with body/3 | 1 +nonascii/Non-ascii calendar data/1 | 1 +nonascii/Non-ascii calendar data/2 | 1 +nonascii/Non-ascii calendar data/3 | 1 +nonascii/Non-ascii calendar data/4 | 1 +nonascii/Non-ascii calendar data/5 | 1 +nonascii/Non-utf-8 calendar data/1 | 1 +nonascii/Non-utf-8 calendar data/2 | 1 +nonascii/Non-utf-8 calendar data/3 | 1 +nonascii/POSTs/1 | 1 +nonascii/POSTs/2 | 1 +nonascii/PUT with CN re-write/1 | 1 +nonascii/PUT with CN re-write/2 | 1 +options/OPTIONS+DAV/1 | 1 +options/OPTIONS+DAV/2 | 1 +polls/PUT VPOLL - no scheduling/2 | 1 +polls/PUT VPOLL - simple scheduling/-1 | 1 +polls/PUT VPOLL - simple scheduling/2 | 1 +polls/PUT VPOLL - simple scheduling/3 | 1 +polls/PUT VPOLL - simple scheduling/4 | 1 +polls/PUT VPOLL - simple scheduling/5 | 1 +polls/PUT VPOLL - simple scheduling/6 | 1 +polls/PUT VPOLL - simple scheduling/7 | 1 +polls/PUT VPOLL - simple scheduling/8 | 1 +polls/PUT VPOLL - Two voter scheduling/-1 | 1 +polls/PUT VPOLL - Two voter scheduling/2 | 1 +polls/PUT VPOLL - Two voter scheduling/3 | 1 +polls/PUT VPOLL - Two voter scheduling/4 | 1 +polls/PUT VPOLL - Two voter scheduling/5 | 1 +polls/PUT VPOLL - Two voter scheduling/6 | 1 +polls/PUT VPOLL - Two voter scheduling/7 | 1 +polls/PUT VPOLL - Two voter scheduling/8 | 1 +polls/PUT VPOLL - Two voter scheduling/9 | 1 +prefer/representation schedule PUT/1 | 1 +prefer/representation schedule PUT/2 | 1 +prefer/representation schedule PUT/3 | 1 +prefer/representation schedule PUT/4 | 1 +prefer/representation schedule PUT/5 | 1 +propfind/Depth:infinity disabled/2 | 1 +propfind/Depth:infinity disabled/3 | 1 +propfind/Depth:infinity disabled/4 | 1 +propfind/Depth:infinity disabled/5 | 1 +propfind/Depth:infinity disabled/6 | 1 +propfind/Depth:infinity disabled/7 | 1 +propfind/prop all/3 | 1 +propfind/prop names/3 | 1 +propfind/regular calendar prop finds/3 | 1 +propfind/regular home prop finds/3 | 1 +proppatch/prop patches/4 | 1 +proppatch/prop patch property attributes/1 | 1 +proppatch/prop patch property attributes/2 | 1 +put/PUTs with ^ parameter encoding/1 | 1 +put/PUT VEVENT/1 | 1 +put/PUT with Content-Type parameters/3 | 1 +put/PUT with relaxed parsing/1 | 1 +put/PUT with relaxed parsing/2 | 1 +put/PUT with X- using VALUE != TEXT/1 | 1 +quota/Quota after collection create/2 | 1 +quota/Quota after collection create, and PUT/3 | 1 +quota/Quota after empty collection delete/2 | 1 +quota/Quota after non-empty collection delete/1 | 1 +quota/Quota after non-empty collection delete/2 | 1 +quota/Quota enabled by default on calendar home and below only/3 | 1 +quota/Quota enabled by default on calendar home and below only/4 | 1 +reports/alarm time-range query reports/1 | 1 +reports/alarm time-range query reports/3 | 1 +reports/alarm time-range query reports/4 | 1 +reports/alarm time-range query reports/5 | 1 +reports/basic query reports/11 | 1 +reports/basic query reports/13 | 1 +reports/basic query reports/15 | 1 +reports/basic query reports/16 | 1 +reports/basic query reports/17a | 1 +reports/basic query reports/19a | 1 +reports/basic query reports/21 | 1 +reports/basic query reports/23 | 1 +reports/basic query reports/24 | 1 +reports/basic query reports/25 | 1 +reports/basic query reports/2a | 1 +reports/basic query reports/3 | 1 +reports/basic query reports/4 | 1 +reports/basic query reports/5 | 1 +reports/basic query reports/6 | 1 +reports/basic query reports/7 | 1 +reports/basic query reports/8 | 1 +reports/basic query reports/9 | 1 +reports/free-busy reports/1 | 1 +reports/free-busy reports/2 | 1 +reports/limit/expand recurrence in reports/10 | 1 +reports/limit/expand recurrence in reports/11 | 1 +reports/limit/expand recurrence in reports/12 | 1 +reports/limit/expand recurrence in reports/9a | 1 +reports/query reports with filtered data/1 | 1 +reports/query reports with filtered data/2 | 1 +reports/time-range query reports/10 | 1 +reports/time-range query reports/11 | 1 +reports/time-range query reports/12a | 1 +reports/time-range query reports/16 | 1 +reports/time-range query reports/19a | 1 +reports/time-range query reports/2 | 1 +reports/time-range query reports/21a | 1 +reports/time-range query reports/23a | 1 +reports/time-range query reports/4 | 1 +reports/time-range query reports/6 | 1 +reports/time-range query reports/7 | 1 +reports/time-range query reports/8 | 1 +rscale/Bad data/2 | 1 +rscale/Chinese MonthDay Skip/1 | 1 +rscale/Chinese MonthDay Skip/2 | 1 +rscale/Chinese MonthDay Skip/3 | 1 +rscale/Chinese MonthDay Skip/4 | 1 +rscale/Chinese Monthly Skip/1 | 1 +rscale/Chinese Monthly Skip/2 | 1 +rscale/Chinese Monthly Skip/3 | 1 +rscale/Chinese Monthly Skip/4 | 1 +rscale/Ethiopic, Last Day Of Year/1 | 1 +rscale/Gregorian Monthly Skip/1 | 1 +rscale/Gregorian Monthly Skip/2 | 1 +rscale/Gregorian Monthly Skip/3 | 1 +rscale/Gregorian Monthly Skip/4 | 1 +rscale/Gregorian Yearly Skip/1 | 1 +rscale/Gregorian Yearly Skip/2 | 1 +rscale/Gregorian Yearly Skip/3 | 1 +rscale/Gregorian Yearly Skip/4 | 1 +scheduleimplicit-compatability/OPTIONS header/1 | 1 +scheduleimplicit-compatability/OPTIONS header/2 | 1 +scheduleimplicit-compatability/POSTs ignored/1 | 1 +schedulenomore/SCHEDULE Fails/1 | 1 +schedulepost/POST Errors/10 | 1 +schedulepost/POST Errors/1 | 1 +schedulepost/POST Errors/11 | 1 +schedulepost/POST Errors/2 | 1 +schedulepost/POST Errors/3 | 1 +schedulepost/POST Errors/4 | 1 +schedulepost/POST Errors/5 | 1 +schedulepost/POST Errors/6 | 1 +schedulepost/POST Errors/7 | 1 +schedulepost/POST Errors/8 | 1 +schedulepost/POST Errors/9 | 1 +schedulepost/POSTs/1 | 1 +schedulepost/POSTs/5 | 1 +schedulepost/POSTs/6 | 1 +schedulepost/POSTs free busy/1 | 1 +schedulepost/POSTs free busy/2 | 1 +schedulepost/POSTs free busy/3 | 1 +schedulepost/POSTs free busy/4 | 1 +schedulepost/Reports on Inbox/Outbox/3 | 1 +schedulepost/Reports on Inbox/Outbox/4 | 1 +schedulepost/Reports on Inbox/Outbox/5 | 1 +schedulepost/Reports on Inbox/Outbox/6 | 1 +scheduleprops/free-busy-set/10 | 1 +scheduleprops/free-busy-set/1 | 1 +scheduleprops/free-busy-set/3 | 1 +scheduleprops/free-busy-set/4 | 1 +scheduleprops/free-busy-set/9 | 1 +servertoserverincoming/POST Errors/1 | 1 +servertoserverincoming/POST Errors/2 | 1 +servertoserverincoming/POST Errors/3 | 1 +servertoserverincoming/POST Errors/4 | 1 +servertoserverincoming/POST Errors/5 | 1 +servertoserverincoming/POST Errors/6 | 1 +servertoserverincoming/POST free-busy/1 | 1 +servertoserverincoming/POST free-busy/2 | 1 +servertoserverincoming/POST invite one user/1 | 1 +servertoserverincoming/POST invite one user/2 | 1 +servertoserverincoming/POST invite two users/1 | 1 +servertoserverincoming/POST invite two users/2 | 1 +servertoserverincoming/POST invite two users/3 | 1 +servertoserveroutgoing/POST free-busy/1 | 1 +servertoserveroutgoing/POST invite/1 | 1 +sync-report/simple reports - diff token - no props - calendar depth | 1 +sync-report/simple reports - diff token - no props - calendar depth:1/1 | 1 +sync-report/simple reports - diff token - props/1 | 1 +sync-report/simple reports - empty inbox/1 | 1 +sync-report/simple reports - empty token - no props/1 | 1 +sync-report/simple reports - empty token - no props/13 | 1 +sync-report/simple reports - empty token - no props/5 | 1 +sync-report/simple reports - empty token - no props/9 | 1 +sync-report/simple reports - empty token - props/1 | 1 +sync-report/simple reports - empty token - props/2 | 1 +sync-report/simple reports - empty token - props/3 | 1 +sync-report/simple reports - empty token - props/4 | 1 +sync-report/simple reports - sync-level/7 | 1 +sync-report/simple reports - valid token/1 | 1 +sync-report/support-report-set/sync-token property/1 | 1 +sync-report/support-report-set/sync-token property/2 | 1 +timezonestdservice/Expand/1 | 1 +timezonestdservice/Expand/2 | 1 +timezonestdservice/Expand/3 | 1 +timezonestdservice/Expand/4 | 1 +timezonestdservice/GET well-known/1 | 1 +timezonestdservice/Invalid query action=expand/10 | 1 +timezonestdservice/Invalid query action=expand/1 | 1 +timezonestdservice/Invalid query action=expand/2 | 1 +timezonestdservice/Invalid query action=expand/3 | 1 +timezonestdservice/Invalid query action=expand/4 | 1 +timezonestdservice/Invalid query action=expand/5 | 1 +timezonestdservice/Invalid query action=expand/6 | 1 +timezonestdservice/Invalid query action=expand/7 | 1 +timezonestdservice/Invalid query action=expand/8 | 1 +timezonestdservice/Invalid query action=expand/9 | 1 +timezonestdservice/Invalid query action=find/1 | 1 +timezonestdservice/Invalid query action=find/2 | 1 +timezonestdservice/Invalid query action=find/3 | 1 +timezonestdservice/Invalid query action=get/1 | 1 +timezonestdservice/Invalid query action=get/2 | 1 +timezonestdservice/Non-query GET/1 | 1 +timezonestdservice/PROPFIND timezone-service-set/1 | 1 +timezonestdservice/Query action=get/1 | 1 +timezonestdservice/Query action=get/2 | 1 +timezonestdservice/Query action=get/3 | 1 +timezonestdservice/Query bogus parameters/1 | 1 +timezonestdservice/Query bogus parameters/2 | 1 +timezonestdservice/Query method=capabilities/1 | 1 +timezonestdservice/Query method=find/1 | 1 +timezonestdservice/Query method=find/2 | 1 +timezonestdservice/Query method=find/3 | 1 +timezonestdservice/Query method=find/4 | 1 +timezonestdservice/Query method=find/5 | 1 +timezonestdservice/Query method=find/6 | 1 +timezonestdservice/Query method=find/7 | 1 +timezonestdservice/Query method=find/8 | 1 +timezonestdservice/Query method=find/9 | 1 +timezonestdservice/Query method=list/1 | 1 +timezones/Timezone cache/2 | 1 +timezones/Timezone cache/4 | 1 +timezones/Timezone cache - aliases/2 | 1 +timezones/Timezone cache - aliases/4 | 1 +timezones/Timezone properties/6 | 1 +webcal/GET on calendar collection after DELETE/1 | 1 +webcal/GET on calendar collection after DELETE/2 | 1 +webcal/GET on calendar collection after initial PUT/1 | 1 +webcal/GET on calendar collection after initial PUT/2 | 1 +webcal/GET on calendar collection after PUT/1 | 1 +webcal/GET on calendar collection after PUT/2 | 1 +webcal/GET on empty calendar collection/1 | 1 +webcal/GET on empty calendar collection/2 | 1 +well-known/Simple GET tests/3 | 1 +well-known/Simple GET tests/4 | 1 +well-known/Simple PROPFIND tests/1 | 1 +well-known/Simple PROPFIND tests/2 | 1 +well-known/Simple PROPFIND tests/3 | 1 +well-known/Simple PROPFIND tests/4 | 1 +well-known/Simple PROPFIND tests/5 | 1 +well-known/Simple PROPFIND tests/6 | 1 +EOF + +sub init +{ + my $cassini = Cassandane::Cassini->instance(); + $basedir = $cassini->val('caldavtester', 'basedir'); + return unless defined $basedir; + $basedir = abs_path($basedir); + + my $supp = $cassini->val('caldavtester', 'suppress-caldav', + ''); + map { $suppressed{$_} = 1; } split(/\s+/, $supp); + + foreach my $row (split /\n/, $KNOWN_ERRORS) { + next if $row =~ m/^\s*\#/; + next unless $row =~ m/\S/; + my ($key, @items) = split /\s*\|\s*/, $row; + $expected{$key} = \@items; + } + + $binary = "$basedir/testcaldav.py"; + $testdir = "$basedir/scripts/tests/CalDAV"; +} +init; + +sub new +{ + my $class = shift; + + my $buildinfo = Cassandane::BuildInfo->new(); + + if (not defined $basedir or not $buildinfo->get('component', 'httpd')) { + # don't bother setting up, we're not running tests anyway + return $class->SUPER::new({}, @_); + } + + my $config = Cassandane::Config->default()->clone(); + $config->set(servername => "127.0.0.1"); # urlauth needs matching servername + $config->set(caldav_realm => 'Cassandane'); + $config->set(httpmodules => 'caldav'); + $config->set(httpallowcompress => 'no'); + + return $class->SUPER::new({ + config => $config, + adminstore => 1, + services => ['imap', 'http'], + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + if (not defined $basedir + or not $self->{instance}->{buildinfo}->get('component', 'httpd')) + { + # don't bother setting up further, we're not running tests anyway + return; + } + + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + $ENV{JMAP_ALWAYS_FULL} = 1; + + for (1..40) { + my $name = sprintf("user%02d", $_); + my $displayname = sprintf("User %02d", $_); + $admintalk->create("user.$name"); + $admintalk->setacl("user.$name", admin => 'lrswipkxtecda'); + $admintalk->setacl("user.$name", $name => 'lrswipkxtecd'); + + my $CalDAV = Net::CalDAVTalk->new( + user => $name, + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + eval { + # this fails on older Cyruses -- but don't crash during set_up! + $CalDAV->UpdateAddressSet($displayname, "$name\@example.com"); + }; + } +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub list_tests +{ + my @tests; + + if (!defined $basedir) + { + return ( 'test_warning_caldavtester_is_not_installed' ); + } + + open(FH, "-|", 'find', $testdir, '-name' => '*.xml'); + while () + { + chomp; + next unless s{^$testdir/}{}; + next unless s{\.xml$}{}; + next if $suppressed{$_}; + push(@tests, "test_$_"); + } + close(FH); + + return @tests; +} + +sub run_test +{ + my ($self) = @_; + + if (!defined $basedir) + { + xlog "CalDAVTester tests are not enabled. To enabled them, please"; + xlog "install CalDAVTester from http://calendarserver.org/wiki/CalDAVTester"; + xlog "and edit [caldavtester]basedir in cassandane.ini"; + xlog "This is not a failure"; + return; + } + + my $name = $self->name(); + $name =~ s/^test_//; + my $testname = $name; + $testname .= ".xml"; + + my $logdir = "$self->{instance}->{basedir}/rawlog/"; + mkdir($logdir); + + my $svc = $self->{instance}->get_service('http'); + my $params = $svc->store_params(); + + my $rundir = "$self->{instance}->{basedir}/run"; + mkdir($rundir); + + system('ln', '-s', "$testdir", "$rundir/tests"); + system('ln', '-s', "$basedir", "$rundir/data"); + + # XXX - make the config file! + my $configfile = "$rundir/serverinfo.xml"; + { + my $config = slurp_file(abs_path("data/caldavtester-serverinfo-template.xml")); + $config =~ s/SERVICE_HOST/$params->{host}/g; + $config =~ s/SERVICE_PORT/$params->{port}/g; + + open(FH, ">", $configfile); + print FH $config; + close(FH); + } + + my $errfile = $self->{instance}->{basedir} . "/$name.errors"; + my $outfile = $self->{instance}->{basedir} . "/$name.stdout"; + my $status; + my @verbose; + if (get_verbose) { + push @verbose, "--always-print-request", "--always-print-response"; + } + $self->{instance}->run_command({ + redirects => { stderr => $errfile, stdout => $outfile }, + workingdir => $logdir, + handlers => { + exited_normally => sub { $status = 1; }, + exited_abnormally => sub { $status = 0; }, + }, + }, + $binary, + "--basedir" => $rundir, + "--observer=jsondump", + @verbose, + $testname); + + my $json; + { + my $output = slurp_file($outfile); + $output =~ s/^.*?\[/[/s; + $json = decode_json($output); + } + + if (0 && (!$status || get_verbose)) { + foreach my $file ($errfile) { + next unless -f $file; + xlog $self, slurp_file($file); + } + } + + $json->[0]{name} = $name; # short name at top level + $self->assert(_check_result($name, $json->[0])); +} + +sub _check_result { + my $name = shift; + my $json = shift; + my $res = 1; + + if (defined $json->{result}) { + if ($json->{result} == 0) { + xlog "$name [OK]"; + } + elsif ($json->{result} == 1) { + xlog "$name [FAILED]"; + $res = 0; + } + elsif ($json->{result} == 3) { + xlog "$name [SKIPPED]"; + } + if (exists $expected{$name}) { + if ($json->{result} == $expected{$name}[0]) { + xlog "EXPECTED RESULT FOR $name"; + $res = 1; + } + else { + xlog "UNEXPECTED RESULT FOR $name: " . $expected{$name}[1] if $expected{$name}[1]; + $res = 0; # yep, even if we succeeded + } + } + xlog $json->{details} if $json->{result}; + } + + xlog "FAILED WHEN NOT EXPECTED $name" unless $res; + + if ($json->{tests}) { + foreach my $test (@{$json->{tests}}) { + $res = 0 unless _check_result("$name/$test->{name}", $test); + } + } + + return $res; +} + +1; diff --git a/cassandane/Cassandane/Cyrus/TesterCardDAV.pm b/cassandane/Cassandane/Cyrus/TesterCardDAV.pm new file mode 100644 index 0000000000..af85999660 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/TesterCardDAV.pm @@ -0,0 +1,360 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::TesterCardDAV; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use JSON::XS; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Cassini; + +my $basedir; +my $binary; +my $testdir; +my %suppressed; +my %expected; + +my $KNOWN_ERRORS = <instance(); + $basedir = $cassini->val('caldavtester', 'basedir'); + return unless defined $basedir; + $basedir = abs_path($basedir); + + my $supp = $cassini->val('caldavtester', 'suppress-carddav', + ''); + map { $suppressed{$_} = 1; } split(/\s+/, $supp); + + foreach my $row (split /\n/, $KNOWN_ERRORS) { + next if $row =~ m/\s*\#/; + next unless $row =~ m/\S/; + my ($key, @items) = split /\s*\|\s*/, $row; + $expected{$key} = \@items; + } + + $binary = "$basedir/testcaldav.py"; + $testdir = "$basedir/scripts/tests/CardDAV"; +} +init; + +sub new +{ + my $class = shift; + + my $buildinfo = Cassandane::BuildInfo->new(); + + if (not defined $basedir or not $buildinfo->get('component', 'httpd')) { + # don't bother setting up, we're not running tests anyway + return $class->SUPER::new({}, @_); + } + + my $config = Cassandane::Config->default()->clone(); + $config->set(servername => "127.0.0.1"); # urlauth needs matching servername + $config->set(caldav_realm => 'Cassandane'); + $config->set(httpmodules => 'carddav'); + $config->set(httpallowcompress => 'no'); + + return $class->SUPER::new({ + config => $config, + adminstore => 1, + services => ['imap', 'http'], + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); + + if (not defined $basedir + or not $self->{instance}->{buildinfo}->get('component', 'httpd')) + { + # don't bother setting up further, we're not running tests anyway + return; + } + + my $admintalk = $self->{adminstore}->get_client(); + + for (1..40) { + my $name = sprintf("user%02d", $_); + $admintalk->create("user.$name"); + $admintalk->setacl("user.$name", admin => 'lrswipkxtecda'); + $admintalk->setacl("user.$name", $name => 'lrswipkxtecd'); + } +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub list_tests +{ + my @tests; + + if (!defined $basedir) + { + return ( 'test_warning_caldavtester_is_not_installed' ); + } + + open(FH, "-|", 'find', $testdir, '-name' => '*.xml'); + while () + { + chomp; + next unless s{^$testdir/}{}; + next unless s{\.xml$}{}; + next if $suppressed{$_}; + push(@tests, "test_$_"); + } + close(FH); + + return @tests; +} + +sub run_test +{ + my ($self) = @_; + + if (!defined $basedir) + { + xlog "CalDAVTester tests are not enabled. To enabled them, please"; + xlog "install CalDAVTester from http://calendarserver.org/wiki/CalDAVTester"; + xlog "and edit [caldavtester]basedir in cassandane.ini"; + xlog "This is not a failure"; + return; + } + + my $name = $self->name(); + $name =~ s/^test_//; + my $testname = $name; + $testname .= ".xml"; + + my $logdir = "$self->{instance}->{basedir}/rawlog/"; + mkdir($logdir); + + my $svc = $self->{instance}->get_service('http'); + my $params = $svc->store_params(); + + my $rundir = "$self->{instance}->{basedir}/run"; + mkdir($rundir); + + system('ln', '-s', "$testdir", "$rundir/tests"); + system('ln', '-s', "$basedir", "$rundir/data"); + + # XXX - make the config file! + my $configfile = "$rundir/serverinfo.xml"; + { + my $config = slurp_file(abs_path("data/caldavtester-serverinfo-template.xml")); + $config =~ s/SERVICE_HOST/$params->{host}/g; + $config =~ s/SERVICE_PORT/$params->{port}/g; + + open(FH, ">", $configfile); + print FH $config; + close(FH); + } + + my $errfile = $self->{instance}->{basedir} . "/$name.errors"; + my $outfile = $self->{instance}->{basedir} . "/$name.stdout"; + my $status; + my @verbose; + if (get_verbose) { + push @verbose, "--always-print-request", "--always-print-response"; + } + $self->{instance}->run_command({ + redirects => { stderr => $errfile, stdout => $outfile }, + workingdir => $logdir, + handlers => { + exited_normally => sub { $status = 1; }, + exited_abnormally => sub { $status = 0; }, + }, + }, + $binary, + "--basedir" => $rundir, + "--observer=jsondump", + @verbose, + $testname); + + my $json = decode_json(slurp_file($outfile)); + + if (0 && (!$status || get_verbose)) { + foreach my $file ($errfile) { + next unless -f $file; + xlog $self, slurp_file($file); + } + } + + $json->[0]{name} = $name; # short name at top level + $self->assert(_check_result($name, $json->[0])); +} + +sub _check_result { + my $name = shift; + my $json = shift; + my $res = 1; + + if (defined $json->{result}) { + if ($json->{result} == 0) { + xlog "$name [OK]"; + } + elsif ($json->{result} == 1) { + xlog "$name [FAILED]"; + $res = 0; + } + elsif ($json->{result} == 3) { + xlog "$name [SKIPPED]"; + } + if (exists $expected{$name}) { + if ($json->{result} == $expected{$name}[0]) { + xlog "EXPECTED RESULT FOR $name"; + $res = 1; + } + else { + xlog "UNEXPECTED RESULT FOR $name: " . $expected{$name}[1] if $expected{$name}[1]; + $res = 0; # yep, even if we succeeded + } + } + xlog $json->{details} if $json->{result}; + } + + xlog "FAILED WHEN NOT EXPECTED $name" unless $res; + + if ($json->{tests}) { + foreach my $test (@{$json->{tests}}) { + $res = 0 unless _check_result("$name/$test->{name}", $test); + } + } + + return $res; +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Thread.pm b/cassandane/Cassandane/Cyrus/Thread.pm new file mode 100644 index 0000000000..392cf969ec --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Thread.pm @@ -0,0 +1,352 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Thread; +use strict; +use warnings; +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub test_unrelated +{ + my ($self) = @_; + + xlog $self, "test THREAD with no inter-message references"; + xlog $self, "and all different subjects"; + my $talk = $self->{store}->get_client(); + my $res; + + xlog $self, "append some messages"; + my %exp; + my $N = 20; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + xlog $self, "The REFERENCES algorithm gives each message in a singleton thread"; + $res = $talk->thread('REFERENCES', 'US-ASCII', 'ALL'); + $self->assert_deep_equals([ map { [ $_ ] } (1..$N) ], $res); + + xlog $self, "The ORDEREDSUBJECT algorithm gives each message in a singleton thread"; + $res = $talk->thread('ORDEREDSUBJECT', 'US-ASCII', 'ALL'); + $self->assert_deep_equals([ map { [ $_ ] } (1..$N) ], $res); + + xlog $self, "Double-check the messages are still there"; + $self->check_messages(\%exp); +} + +sub test_subjects +{ + my ($self) = @_; + + xlog $self, "test THREAD with no inter-message references"; + xlog $self, "but apparently similar subjects"; + my $talk = $self->{store}->get_client(); + my $res; + + xlog $self, "append some messages"; + my %exp; + my %exp_by_sub; + my $N = 20; + my @subjects = ( 'quinoa', 'selvedge', 'messenger bag' ); + for (1..$N) + { + my $sub = $subjects[($_ - 1) % scalar(@subjects)]; + $exp_by_sub{$sub} ||= []; + my $msg = $self->make_message(("Re: " x scalar(@{$exp_by_sub{$sub}})) . $sub); + push(@{$exp_by_sub{$sub}}, $msg); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + my @expthreads; + foreach my $sub (@subjects) + { + my @thread = ( map { $_->uid } @{$exp_by_sub{$sub}} ); + my $parent = shift(@thread); + push(@expthreads, [ $parent, map { [ $_ ] } @thread ] ); + } + + xlog $self, "The REFERENCES algorithm gives one thread per subject, even"; + xlog $self, "though the References headers are completely missing"; + $res = $talk->thread('REFERENCES', 'US-ASCII', 'ALL'); + $self->assert_deep_equals(\@expthreads, $res); + + xlog $self, "The ORDEREDSUBJECT algorithm gives one thread per subject"; + $res = $talk->thread('ORDEREDSUBJECT', 'US-ASCII', 'ALL'); + $self->assert_deep_equals(\@expthreads, $res); + + xlog $self, "Double-check the messages are still there"; + $self->check_messages(\%exp); +} + +sub test_references_chain +{ + my ($self) = @_; + + xlog $self, "test THREAD with a linear chain of inter-message references"; + xlog $self, "and apparently similar subjects"; + my $talk = $self->{store}->get_client(); + my $res; + + xlog $self, "append some messages"; + my %exp; + my %exp_by_sub; + my $N = 20; + my @subjects = ( 'cosby sweater', 'brooklyn', 'portland' ); + for (1..$N) + { + my $sub = $subjects[($_ - 1) % scalar(@subjects)]; + $exp_by_sub{$sub} ||= []; + my $msg; + if (scalar @{$exp_by_sub{$sub}}) + { + my $parent = $exp_by_sub{$sub}->[-1]; + $msg = $self->make_message("Re: " . $parent->subject, + references => [ $parent ]); + } + else + { + $msg = $self->make_message($sub); + } + push(@{$exp_by_sub{$sub}}, $msg); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + my @expthreads; + + xlog $self, "The REFERENCES algorithm gives the true thread structure which is deep"; + foreach my $sub (@subjects) + { + push(@expthreads, [ map { $_->uid } @{$exp_by_sub{$sub}} ]); + } + $res = $talk->thread('REFERENCES', 'US-ASCII', 'ALL'); + $self->assert_deep_equals(\@expthreads, $res); + +# From RFC5256 +# The top level or "root" in ORDEREDSUBJECT threading contains +# the first message of every thread. All messages in the root +# are siblings of each other. The second message of a thread is +# the child of the first message, and subsequent messages of the +# thread are siblings of the second message and hence children of +# the message at the root. Hence, there are no grandchildren in +# ORDEREDSUBJECT threading. + xlog $self, "The ORDEREDSUBJECT algorithm gives a false more flat view of the structure"; + @expthreads = (); + foreach my $sub (@subjects) + { + my @thread = ( map { $_->uid } @{$exp_by_sub{$sub}} ); + my $parent = shift(@thread); + push(@expthreads, [ $parent, map { [ $_ ] } @thread ] ); + } + $res = $talk->thread('ORDEREDSUBJECT', 'US-ASCII', 'ALL'); + $self->assert_deep_equals(\@expthreads, $res); + + xlog $self, "Double-check the messages are still there"; + $self->check_messages(\%exp); +} + +sub test_references_star +{ + my ($self) = @_; + + xlog $self, "test THREAD with a star configuration of inter-message references"; + xlog $self, "and apparently similar subjects"; + my $talk = $self->{store}->get_client(); + my $res; + + xlog $self, "append some messages"; + my %exp; + my %exp_by_sub; + my $N = 20; + my @subjects = ( 'cosby sweater', 'brooklyn', 'portland' ); + foreach my $uid (1..$N) + { + my $sub = $subjects[($uid - 1) % scalar(@subjects)]; + $exp_by_sub{$sub} ||= []; + my $msg; + if (scalar @{$exp_by_sub{$sub}}) + { + my $parent = $exp_by_sub{$sub}->[0]; + $msg = $self->make_message("Re: " . $parent->subject, + references => [ $parent ]); + } + else + { + $msg = $self->make_message($sub); + } + push(@{$exp_by_sub{$sub}}, $msg); + $exp{$uid} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp, keyed_on => 'uid'); + + my @expthreads; + foreach my $sub (@subjects) + { + my @thread = ( map { $_->uid } @{$exp_by_sub{$sub}} ); + my $parent = shift(@thread); + push(@expthreads, [ $parent, map { [ $_ ] } @thread ] ); + } + + xlog $self, "The REFERENCES algorithm gives the true thread structure which is flat"; + $res = $talk->thread('REFERENCES', 'US-ASCII', 'ALL'); + $self->assert_deep_equals(\@expthreads, $res); + + xlog $self, "The ORDEREDSUBJECT algorithm gives the same flat view"; + $res = $talk->thread('ORDEREDSUBJECT', 'US-ASCII', 'ALL'); + $self->assert_deep_equals(\@expthreads, $res); + + xlog $self, "Double-check the messages are still there"; + $self->check_messages(\%exp, keyed_on => 'uid'); +} + +sub test_references_missing_parent +{ + my ($self) = @_; + + xlog $self, "test THREAD with two messages which share a common parent"; + xlog $self, "which is not seen on the server"; + my $talk = $self->{store}->get_client(); + my $res; + my %exp; + + xlog $self, "Message A is never seen by the server"; + my $msgA = $self->{gen}->generate(subject => "put a bird on it"); + + xlog $self, "Generate message B, which References message A"; + my $msgB = $self->make_message("Re: " . $msgA->subject, + uid => 1, + references => [ $msgA ]); + $exp{1} = $msgB; + + xlog $self, "Generate message C, which References message A"; + my $msgC = $self->make_message("Re: " . $msgA->subject, + uid => 2, + references => [ $msgA ]); + $exp{2} = $msgC; + + xlog $self, "check the messages got there"; + $self->check_messages(\%exp, keyed_on => 'uid'); + + xlog $self, "The REFERENCES algorithm gives the true thread"; + xlog $self, "structure which is flat with a missing common parent"; + $res = $talk->thread('REFERENCES', 'US-ASCII', 'ALL'); + $self->assert_deep_equals([[[1],[2]]], $res); + + xlog $self, "The ORDEREDSUBJECT algorithm gives a false more flat view of the structure"; + $res = $talk->thread('ORDEREDSUBJECT', 'US-ASCII', 'ALL'); + $self->assert_deep_equals([[1, 2]], $res); + + xlog $self, "Double-check the messages are still there"; + $self->check_messages(\%exp, keyed_on => 'uid'); +} + + +sub test_references_loop +{ + my ($self) = @_; + + xlog $self, "test THREAD with a loop configuration of inter-message references"; + xlog $self, "and a missing common parent (Bug 3784)"; + my $talk = $self->{store}->get_client(); + my $res; + my %exp; + + xlog $self, "Generate message B, which References itself and some other messages"; + my $msgB = $self->{gen}->generate(subject => "Re: put a bird on it", uid => 1); + $msgB->set_headers('Message-Id', '<477CBE0D020000330001972A@gwia1.boku.ac.at>'); + $msgB->set_headers('References', + '<477CB3AF0200001E00003B58@gwia1.boku.ac.at>' . "\n" . + '<477CBA030200003300019722@gwia1.boku.ac.at>' . "\n" . + '<477CBD530200003300019726@gwia1.boku.ac.at>' . "\n" . + '<477CBE0D020000330001972A@gwia1.boku.ac.at>'); + $msgB->set_headers('In-Reply-To', '<477CBE0D020000330001972A@gwia1.boku.ac.at>'); + $self->_save_message($msgB); + $exp{1} = $msgB; + + xlog $self, "Generate message C, which References itself and some other messages"; + my $msgC = $self->{gen}->generate(subject => "Re: put a bird on it", uid => 2); + $msgC->set_headers('Message-Id', '<478B52E10200003300019E06@gwia1.boku.ac.at>'); + $msgC->set_headers('References', + '<477CB3AF0200001E00003B58@gwia1.boku.ac.at>' . "\n" . + '<478B2D7F0200003300019DA2@gwia1.boku.ac.at>' . "\n" . + '<478B2E9F0200003300019DA5@gwia1.boku.ac.at>' . "\n" . + '<478B2F0E0200003300019DA8@gwia1.boku.ac.at>' . "\n" . + '<478B32C40200003300019DB1@gwia1.boku.ac.at>' . "\n" . + '<478B38C40200003300019DBD@gwia1.boku.ac.at>' . "\n" . + '<478B52E10200003300019E06@gwia1.boku.ac.at>'); + $msgC->set_headers('In-Reply-To', '<478B52E10200003300019E06@gwia1.boku.ac.at>'); + $self->_save_message($msgC); + $exp{2} = $msgC; + + xlog $self, "check the messages got there"; + $self->check_messages(\%exp, keyed_on => 'uid'); + + xlog $self, "The REFERENCES algorithm gives the true thread"; + xlog $self, "structure which is flat"; + $res = $talk->thread('REFERENCES', 'US-ASCII', 'ALL'); + $self->assert_deep_equals([[[1], [2]]], $res); + + xlog $self, "The ORDEREDSUBJECT algorithm gives a false more flat view of the structure"; + $res = $talk->thread('ORDEREDSUBJECT', 'US-ASCII', 'ALL'); + $self->assert_deep_equals([[1, 2]], $res); + + xlog $self, "Double-check the messages are still there"; + $self->check_messages(\%exp, keyed_on => 'uid'); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/UIDonly.pm b/cassandane/Cassandane/Cyrus/UIDonly.pm new file mode 100644 index 0000000000..f505731c55 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/UIDonly.pm @@ -0,0 +1,122 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2023 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::UIDonly; +use strict; +use warnings; +use DateTime; +use JSON; +use JSON::XS; +use Mail::JMAPTalk 0.13; +use Data::Dumper; +use Storable 'dclone'; +use File::Basename; +use IO::File; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +use charnames ':full'; + +sub new +{ + my ($class, @args) = @_; + + my $config = Cassandane::Config->default()->clone(); + + $config->set(conversations => 'yes'); + + return $class->SUPER::new({ + config => $config, + deliver => 1, + adminstore => 1, + services => [ 'imap', 'sieve' ] + }, @args); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub uidonly_cmd +{ + my $self = shift; + my $imaptalk = shift; + my $cmd = shift; + + my %fetched; + my %handlers = + ( + uidfetch => sub + { + my (undef, $items, $uid) = @_; + + if (ref($items) ne 'HASH') { + # IMAPTalk < 4.06. Convert the key/value list into a hash + my %hash; + my $kvlist = $imaptalk->_next_atom(); + while (@$kvlist) { + my ($key, $val) = (shift @$kvlist, shift @$kvlist); + $hash{lc($key)} = $val; + } + $items = \%hash; + } + + $fetched{$uid} = $items; + }, + ); + + $imaptalk->_imap_cmd($cmd, 0, \%handlers, @_); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + return %fetched; +} + +use Cassandane::Tiny::Loader 'tiny-tests/UIDonly'; + +1; diff --git a/cassandane/Cassandane/Cyrus/URLAuth.pm b/cassandane/Cassandane/Cyrus/URLAuth.pm new file mode 100644 index 0000000000..3078811fed --- /dev/null +++ b/cassandane/Cassandane/Cyrus/URLAuth.pm @@ -0,0 +1,164 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2020 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::URLAuth; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::NetString; + + +sub new +{ + my $class = shift; + + my $config = Cassandane::Config->default()->clone(); + $config->set(servername => "127.0.0.1"); # urlauth needs matching servername + + return $class->SUPER::new({ + config => $config, + adminstore => 1, + services => ['imap'] + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_urlfetch +{ + my ($self) = @_; + + my %exp_sub; + my $store = $self->{store}; + my $talk = $store->get_client(); + + $store->set_folder("INBOX"); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body; + + # Subpart 1 + $body = "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "\r\n" + . "body1" + . "\r\n"; + + # Subpart 2 + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: multipart/mixed;boundary=frontier\r\n" + . "\r\n"; + + # Subpart 2.1 + $body .= "--frontier\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body21" + . "\r\n"; + + # End subpart 2 + $body .= "--frontier--\r\n"; + + # Subpart 3 + my $msg3 = "" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "body3"; + + $body .= "--047d7b33dd729737fe04d3bde348\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . $msg3 + . "\r\n"; + + # End body + $body .= "--047d7b33dd729737fe04d3bde348--"; + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body + ); + + my $data; + my %handlers = + ( + urlfetch => sub + { + my ($cmd, $params) = @_; + $data = ${$params}[1]; + }, + ); + + my $url = $talk->_imap_cmd('genurlauth', 0, "genurlauth", + "imap://cassandane\@127.0.0.1/INBOX/;uid=1/;section=3.TEXT;partial=1.3;urlauth=user+cassandane", + 'INTERNAL'); + + my $res = $talk->_imap_cmd('urlfetch', 0, \%handlers, substr($url, 1, -1)); + + $self->assert_str_equals($data, "ody"); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/UTF8Accept.pm b/cassandane/Cassandane/Cyrus/UTF8Accept.pm new file mode 100644 index 0000000000..7ee62dea47 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/UTF8Accept.pm @@ -0,0 +1,226 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2024 Fastmail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::UTF8Accept; +use strict; +use warnings; +use Cwd qw(abs_path); +use File::Path qw(mkpath); +use DateTime; +use Data::Dumper; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; +use Cassandane::Util::NetString; + + +sub new +{ + my $class = shift; + my $config = Cassandane::Config->default()->clone(); + + # Make sure the server will advertise support for UTF8=ACCEPT + $config->set(reject8bit => 'off'); + $config->set(munge8bit => 'off'); + + return $class->SUPER::new({ services => ['imap'], config => $config }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_mboxname + :NoAltNameSpace :min_version_3_9 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + xlog $self, "Create mailbox with mUTF7 encoded name"; + my $res = $talk->_imap_cmd('CREATE', 0, "", "INBOX.&JgA-"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "ENABLE UTF8=ACCEPT"; + $res = $talk->_imap_cmd('ENABLE', 0, "enabled", "UTF8=ACCEPT"); + $self->assert_num_equals(1, $res->{'utf8=accept'}); + + xlog $self, "Create a mailbox with denormalized mailbox name"; + $res = $talk->_imap_cmd('CREATE', 0, "", "INBOX.Å"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Create a child mailbox with normalized mailbox name"; + $res = $talk->_imap_cmd('CREATE', 0, "", "INBOX.Å.B"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that LIST responses use UTF8 mailbox names"; + $res = $talk->list("", "*"); + $self->assert_mailbox_structure($res, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.☀' => [qw( \\HasNoChildren )], + 'INBOX.Å' => [qw( \\HasChildren )], + "INBOX.Å.B" => [qw( \\HasNoChildren )], + }); + + xlog $self, "EXAMINE mailbox with UTF8 mailbox name"; + $res = $talk->_imap_cmd('EXAMINE', 0, "", "INBOX.☀"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $talk->unselect(); + + xlog $self, "RENAME mailbox with denormalized mailbox names"; + $res = $talk->_imap_cmd('RENAME', 0, "", "INBOX.Å", "INBOX.Ω"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "DELETE a child mailbox with normalized mailbox name"; + $res = $talk->_imap_cmd('DELETE', 0, "", "INBOX.Ω.B"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Verify that LIST responses use UTF8 mailbox names"; + $res = $talk->list("", "*"); + $self->assert_mailbox_structure($res, '.', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX.☀' => [qw( \\HasNoChildren )], + "INBOX.Ω" => [qw( \\HasNoChildren )], + }); +} + +sub test_append + :NoAltNameSpace :min_version_3_9 +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $MsgTxt = <_imap_cmd('CREATE', 0, "", "INBOX.&JgA-"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + # Using UTF8 before UTF8=ACCEPT should fail + xlog $self, "Attempt to append message with UTF-8 header to mailbox"; + $res = $talk->_imap_cmd('APPEND', 0, "", "INBOX.&JgA-", + 'UTF8', [ { Literal => $MsgTxt } ]); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + xlog $self, "ENABLE UTF8=ACCEPT"; + $res = $talk->_imap_cmd('ENABLE', 0, "enabled", "UTF8=ACCEPT"); + $self->assert_num_equals(1, $res->{'utf8=accept'}); + + xlog $self, "Append message with UTF-8 header to mailbox"; + $res = $talk->_imap_cmd('APPEND', 0, "", "INBOX.☀", + 'UTF8', [ { Literal => $MsgTxt } ]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Catenate message with UTF-8 header to mailbox"; + $res = $talk->_imap_cmd('APPEND', 0, "", "INBOX.☀", + 'CATENATE', [ 'UTF8', [ { Literal => $MsgTxt } ] ]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); +} + +sub test_search_sort_thread + :NoAltNameSpace :min_version_3_9 +{ + my ($self) = @_; + + xlog $self, "Make some messages"; + my $uid = 1; + my %msgs; + for (1..10) + { + $msgs{$uid} = $self->make_message("Message $uid"); + $msgs{$uid}->set_attribute('uid', $uid); + $uid++; + } + + my $talk = $self->{store}->get_client(); + + # Verify that pre-ENABLE search/sort/thread work as expected + my $uids = $talk->search('charset', 'us-ascii', 'all'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $uids = $talk->sort('(size)', 'us-ascii', 'all'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + $uids = $talk->thread('orderedsubject', 'us-ascii', 'all'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "ENABLE UTF8=ACCEPT"; + my $res = $talk->_imap_cmd('ENABLE', 0, "enabled", "UTF8=ACCEPT"); + $self->assert_num_equals(1, $res->{'utf8=accept'}); + + # Using CHARSET after UTF8=ACCEPT should fail + $uids = $talk->search('charset', 'us-ascii', 'all'); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + $uids = $talk->search('all'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + # Using CHARSET other than UTF-8 after UTF8=ACCEPT should fail + $uids = $talk->sort('(size)', 'us-ascii', 'all'); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + $uids = $talk->sort('(size)', 'utf8', 'all'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + # Using CHARSET other than UTF-8 after UTF8=ACCEPT should fail + $uids = $talk->thread('orderedsubject', 'us-ascii', 'all'); + $self->assert_str_equals('bad', $talk->get_last_completion_response()); + + $uids = $talk->thread('orderedsubject', 'utf8', 'all'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); +} + +1; diff --git a/cassandane/Cassandane/Cyrus/Userid.pm b/cassandane/Cassandane/Cyrus/Userid.pm new file mode 100644 index 0000000000..3a7f4f6899 --- /dev/null +++ b/cassandane/Cassandane/Cyrus/Userid.pm @@ -0,0 +1,160 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::Cyrus::Userid; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +Cassandane::Cyrus::TestCase::magic(NoAutocreate => sub { + shift->config_set('autocreate_users' => 'nobody'); +}); +Cassandane::Cyrus::TestCase::magic(PopUseACL => sub { + shift->config_set('popuseacl' => 'yes'); +}); + + +sub new +{ + my $class = shift; + return $class->SUPER::new({ + adminstore => 1, + services => ['imap', 'pop3'] + }, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +# Tests userid with dots and unix hierarchy separator +sub test_dots_unix + :UnixHierarchySep NoAutocreate PopUseACL +{ + my ($self) = @_; + + my $user = 'userid.with.dots'; + # We will also play a bit with the internal form of this userid + (my $user_internal = $user) =~ s/\./^/g; + + # Create user per instance->create_user() - see builds 1170-1176 + $self->{instance}->create_user($user); + + # There should only be ACLs for (external) userid + my $adminclient = $self->{adminstore}->get_client(); + my $mb = Cassandane::Mboxname->new( + config => $self->{instance}->{config}, + userid => $user + )->to_external(); + + my %acls = $adminclient->getacl($mb); + $self->assert(defined($acls{$user})); + $self->assert(!defined($acls{$user_internal})); + + + # User should be able to login and enter its INBOX + my $store = $self->{instance}->get_service('imap')->create_store(username => $user); + my $client = $store->get_client(); + $client->select('INBOX'); + $self->assert_str_equals('ok', $client->get_last_completion_response()); + $store->disconnect(); + + # Same thing in POP + $store = $self->{instance}->get_service('pop3')->create_store(username => $user); + $client = $store->get_client(); + $store->disconnect(); + + + # Internal userid shall not be able to enter its INBOX + $store = $self->{instance}->get_service('imap')->create_store(username => $user_internal); + $client = $store->get_client(); + $client->select('INBOX'); + $self->assert_str_equals('no', $client->get_last_completion_response()); + $store->disconnect(); + + # Same thing in POP + $store = $self->{instance}->get_service('pop3')->create_store(username => $user_internal); + { + # shut up + local $SIG{__DIE__}; + local $SIG{__WARN__} = sub { 1 }; + + eval { $client = $store->get_client(); }; + my $Err = $@; + $store->disconnect(); + $self->assert_matches(qr/Cannot login via POP3/, $Err); + }; + + + # We should be able to set ACLs for internal userid + $adminclient->setacl($mb, $user_internal => 'lrswipkxtecd') + or die "Cannot setacl for $mb: $@"; + %acls = $adminclient->getacl($mb); + $self->assert(defined($acls{$user_internal})); + + # XXX - In an ideal world, internal userid should still not be able to enter + # its INBOX. However, since we set its rights, it will be able to access the + # external userid mailbox: the '^' character is not forbidden, and since it + # is converted to itself in internal form, the code is such that both + # external and internal userid have the same mailbox name. + + + # We should be able to delete ACLs for external/internal userid + # But external one, as mailbox owner, should still keep some + $adminclient->deleteacl($mb, $user) + or die "Cannot deleteacl for $mb: $@"; + $adminclient->deleteacl($mb, $user_internal) + or die "Cannot deleteacl for $mb: $@"; + %acls = $adminclient->getacl($mb); + $self->assert(defined($acls{$user})); + $self->assert(!defined($acls{$user_internal})); +} + +1; diff --git a/cassandane/Cassandane/Generator.pm b/cassandane/Cassandane/Generator.pm new file mode 100644 index 0000000000..3b141fd552 --- /dev/null +++ b/cassandane/Cassandane/Generator.pm @@ -0,0 +1,363 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Generator; +use strict; +use warnings; +use feature qw(state); +use Digest::MD5 qw(md5_hex); + +use lib '.'; +use Cassandane::Util::DateTime qw(to_rfc822 from_iso8601); +use Cassandane::Address; +use Cassandane::Message; +use Cassandane::Util::SHA; + +our $admin = 'qa@cyrus.works'; + +our @girls_forenames = ( + # Top 10 girl baby names in 2006 according to + # http://www.babyhold.com/babynames/Popular/Popular_girl_names_in_the_US_for_2006/ + 'Emily', + 'Emma', + 'Madison', + 'Abigail', + 'Olivia', + 'Isabella', + 'Hannah', + 'Samantha', + 'Ava', + 'Ashley' +); +our @surnames = ( + # Top 10 common surnames in Australia according to + # http://genealogy.about.com/od/australia/tp/common_surnames.htm + 'Smith', + 'Jones', + 'Williams', + 'Brown', + 'Wilson', + 'Taylor', + 'Nguyen', + 'Johnson', + 'Martin', + 'White' +); +our @domains = ( + # Pulled out of my hat. + 'fastmail.fm', + 'gmail.com', + 'hotmail.com', + 'yahoo.com' +); +our @localpart_styles = ( + sub($$$) + { + my ($forename, $initial, $surname) = @_; + return "$forename.$surname"; + }, + sub($$$) + { + my ($forename, $initial, $surname) = @_; + return lc(substr($forename,0,1) . $initial . $surname); + }, + sub($$$) + { + my ($forename, $initial, $surname) = @_; + return lc(substr($forename,0,1) . $initial . substr($surname,0,1)); + } +); + +sub new +{ + my ($class, %params) = @_; + + my $self = { + next_uid => 1, + min_extra_lines => $params{min_extra_lines} || 0, + max_extra_lines => $params{max_extra_lines} || 0, + }; + + bless $self, $class; + return $self; +} + +sub _generate_uid +{ + my ($self) = @_; + my $uid = $self->{next_uid}++; + return $uid; +} + +sub set_next_uid +{ + my ($self, $uid) = @_; + $self->{next_uid} = 0+$uid; +} + +sub make_random_address +{ + my (%params) = @_; + + my $i = int(rand(scalar(@girls_forenames))); + my $forename = delete $params{forename}; + $forename = $girls_forenames[$i] if !defined $forename; + + $i = int(rand(scalar(@surnames))); + my $surname = delete $params{surname}; + $surname = $surnames[$i] if !defined $surname; + + my $digest = md5_hex("$forename $surname"); + + $i = oct("0x" . substr($digest,0,4)) % scalar(@domains); + my $domain = delete $params{domain}; + $domain = $domains[$i] if !defined $domain; + + $i = oct("0x" . substr($digest,4,4)) % 26; + my $initial = delete $params{initial}; + $initial = substr("ABCDEFGHIJKLMNOPQRSTUVWXYZ", $i, 1) + if !defined $initial; + + $i = oct("0x" . substr($digest,8,4)) % scalar(@localpart_styles); + my $localpart = delete $params{localpart}; + $localpart = $localpart_styles[$i]->($forename, $initial, $surname) + if !defined $localpart; + + my $extra = delete $params{extra}; + $extra = '' if !defined $extra; + + return Cassandane::Address->new( + name => "$forename $initial. $surname$extra", + localpart => $localpart, + domain => $domain + ); +} + +sub _generate_from +{ + my ($self, $params) = @_; + return make_random_address(); +} + +sub _generate_to +{ + my ($self, $params) = @_; + return Cassandane::Address->new( + name => "Test User", + localpart => 'test', + domain => 'vmtom.com' + ); +} + +sub _generate_messageid +{ + my ($self, $params) = @_; + state $counter = 0; + my $idsalt = int(rand(65536)); + + return sprintf 'fake.%d.%d.%d@%s', + $params->{date}->epoch(), + ++ $counter, + $idsalt, + $params->{from}->domain(); +} + +sub _params_defaults +{ + my $self = shift; + my $params = { @_ }; + + # Note: no error checking, e.g. for unknown parameters. Sorry. + # + $params->{date} = DateTime->now() + unless defined $params->{date}; + $params->{date} = from_iso8601($params->{date}) + if ref $params->{date} eq ''; + die "Bad date: " . ref $params->{date} + unless ref $params->{date} eq 'DateTime'; + + $params->{from} = $self->_generate_from($params) + unless defined $params->{from}; + die "Bad from: " . ref $params->{from} + unless ref $params->{from} eq 'Cassandane::Address'; + + $params->{subject} = "Generated test email" + unless defined $params->{subject}; + + $params->{to} = $self->_generate_to($params) + unless defined $params->{to}; + die "Bad to: " . ref $params->{to} + unless ref $params->{to} eq 'Cassandane::Address'; + + $params->{messageid} = $self->_generate_messageid($params) + unless defined $params->{messageid}; + + # Allow 'references' to be an array of Message objects + # which is really handy for generating conversation data + if (defined $params->{references} && + ref $params->{references} eq 'ARRAY') + { + my @refs; + map { + if (ref($_) eq 'Cassandane::Message') + { + push(@refs, $_->messageid()) + } + else + { + push(@refs, "" . $_); + } + } @{$params->{references}}; + $params->{references} = join(', ', @refs); + } + + $params->{uid} = $self->_generate_uid() + unless defined $params->{uid}; + + $params->{body} = "This is a generated test email. " . + "If received, please notify $admin\r\n" + unless defined $params->{body}; + + $params->{extra_lines} = int($self->{min_extra_lines} + + rand($self->{max_extra_lines} - + $self->{min_extra_lines})) + unless defined $params->{extra_lines}; + + $params->{mime_encoding} = '7bit' + unless defined $params->{mime_encoding}; + $params->{mime_type} = 'text/plain' + unless defined $params->{mime_type}; + $params->{mime_charset} = 'us-ascii' + unless defined $params->{mime_charset}; + $params->{mime_boundary} = 'Apple-Mail-1-798269008' + unless defined $params->{mime_boundary}; + + return $params; +} + +sub _generate_unique +{ + return sha1_hex("" . int(rand(65536))); +} + +# +# Generate a single email. +# Args: Generator, (param-key => param-value ... ) +# Returns: Message ref +# +sub generate +{ + my ($self, @aparams) = @_; + my $params = $self->_params_defaults(@aparams); + my $datestr = to_rfc822($params->{date}); + my $from = $params->{from}; + my $to = $params->{to}; + my $extra_lines = $params->{extra_lines}; + my $extra = ''; + if ($extra_lines) { + $extra .= "This is an extra line\r\n" x $extra_lines; + } + my $size = $params->{size}; + my $msg = Cassandane::Message->new(); + + $msg->add_header("Return-Path", "<" . $from->address() . ">"); + # TODO: two minutes ago + $msg->add_header("Received", + "from gateway (gateway." . $to->domain() . " [10.0.0.1])\r\n" . + "\tby ahost (ahost." . $to->domain() . "[10.0.0.2]); $datestr"); + $msg->add_header("Received", + "from mail." . $from->domain() . " (mail." . $from->domain() . " [192.168.0.1])\r\n" . + "\tby gateway." . $to->domain() . " (gateway." . $to->domain() . " [10.0.0.1]); $datestr"); + $msg->add_header("MIME-Version", "1.0"); + my $mimetype = $params->{mime_type}; + if ($mimetype =~ m/multipart\//i) + { + $mimetype .= "; boundary=\"$params->{mime_boundary}\"" + } + else + { + $mimetype .= "; charset=\"$params->{mime_charset}\"" + if $params->{mime_charset} ne ''; + } + $msg->add_header("Content-Type", $mimetype); + $msg->add_header("Content-Transfer-Encoding", $params->{mime_encoding}); + $msg->add_header("Subject", $params->{subject}); + $msg->add_header("From", $from); + $msg->add_header("Message-ID", "<" . $params->{messageid} . ">"); + $msg->add_header("References", $params->{references}) + if defined $params->{references}; + $msg->add_header("Date", $datestr); + $msg->add_header("To", $to); + $msg->add_header("Cc", $params->{cc}) if defined $params->{cc}; + $msg->add_header("Bcc", $params->{bcc}) if defined $params->{bcc}; + if (defined($params->{extra_headers})) { + foreach my $extra_header (@{$params->{extra_headers}}) { + $msg->add_header(@{$extra_header}); + } + } + $msg->add_header('X-Cassandane-Unique', _generate_unique()); + if (defined $size) + { + my $padding = "ton bear\r\n"; + my $msg_size = $msg->size() + length($params->{body}) + length($extra); + my $needs = $size - $msg_size; + die "size $size cannot be achieved, message is already $msg_size bytes long" + if $needs < 0; + my $npad = int($needs / length($padding)) - 1; + if ($npad > 0) + { + $extra .= $padding x $npad; + $needs -= length($padding) * $npad; + } + $extra .= 'X' x ($needs - 2) if ($needs >= 2); + $extra .= "\r\n"; + } + $msg->set_body($params->{body} . $extra); + $msg->set_attributes(uid => $params->{uid}); + $msg->set_internaldate($params->{date}); + + if ($params->{flags}) { + $msg->set_attribute(flags => $params->{flags}); + } + + return $msg; +} + + +1; diff --git a/cassandane/Cassandane/GenericListener.pm b/cassandane/Cassandane/GenericListener.pm new file mode 100644 index 0000000000..1b59f7aa1e --- /dev/null +++ b/cassandane/Cassandane/GenericListener.pm @@ -0,0 +1,468 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::GenericListener; +use strict; +use warnings; + +use lib '.'; +use Cassandane::Util::Log; +use Cassandane::PortManager; + +sub new +{ + my ($class, %params) = @_; + + my $host = '127.0.0.1'; + $host = delete $params{host} + if (exists $params{host}); + my $port = delete $params{port}; + my $config = delete $params{config}; + my $argv = delete $params{argv}; + my $name = delete $params{name}; + + die "Unexpected parameters: " . join(" ", keys %params) + if scalar %params; + + return bless + { + name => $name, + host => $host, + port => $port, + config => $config, + argv => $argv, + }, $class; +} + +sub set_config +{ + my ($self, $config) = @_; + $self->{config} = $config; +} + +# Return the host +sub host +{ + my ($self) = @_; + return $self->{host}; +} + +# Return the port +sub port +{ + my ($self) = @_; + + $self->set_port($self->{port}); + + return $self->{port}; +} + +sub set_port +{ + my ($self, $port) = @_; + + if (defined $port && + defined $self->{config}) + { + # expand @basedir@ et al + $port = $self->{config}->substitute($port); + } + + $port ||= Cassandane::PortManager::alloc($self->host); + $self->{port} = $port; +} + +# Return a hash of parameters for connecting to the listener. +# These will ultimately go through to MessageStoreFactory::create. +sub connection_params +{ + my ($self, %params) = @_; + + $params{address_family} = $self->{address_family}; + $params{host} = $self->host(); + $params{port} = $self->port(); + $params{verbose} ||= get_verbose(); + return \%params; +} + +sub address +{ + my ($self) = @_; + my @parts; + + my $port = $self->port(); + if (defined $self->{host} && !($port =~ m/^\//)) + { + # Cyrus uses the syntax '[ipv6address]:port' to specify + # an IPv6 address (which will contain the : character) + # as the host part. + push(@parts, '[') if ($self->{host} =~ m/:/); + push(@parts, $self->{host}); + push(@parts, ']') if ($self->{host} =~ m/:/); + push(@parts, ':'); + } + push(@parts, $port); + return join('', @parts); +} + +sub parse_address +{ + my ($s) = @_; + my $host; + my $port; + + if ($s =~ m/^\//) + { + # UNIX domain socket + $port = $s; + } + if (!defined $port) + { + # syntax '[ipv6address]:port' + ($host, $port) = ($s =~ m/^\[([^]]+)\]:([^:]+)$/); + } + if (!defined $port) + { + # syntax 'host:port' + ($host, $port) = ($s =~ m/^([^:]+):([^:]+)$/); + } + if (!defined $port) + { + # syntax 'port' + ($port) = ($s =~ m/^([^:]+)$/); + } + if (!defined $port) + { + die "Cannot parse \"$s\" as socket address" + } + + return { host => $host, port => $port }; +} + +my %netstat_parse = ( + # # netstat -ln -Ainet + # Active Internet connections (only servers) + # Proto Recv-Q Send-Q Local Address Foreign Address State + # tcp 0 0 0.0.0.0:56686 0.0.0.0:* LISTEN + # + # # netstat -lnp -Ainet + # Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name + # tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1058/sshd + inet => sub + { + my ($line, $wantpid) = @_; + + my @a = split(/\s+/, $line); + return unless scalar(@a) == 6 + ($wantpid ? 1 : 0); + + my ($addr, $port) = ($a[3] =~ m/^(.*):([0-9]+)$/); + return unless defined $port; + $addr = 'any' if ($addr eq '0.0.0.0'); + $addr = 'localhost' if ($addr eq '127.0.0.1'); + + my $pid; + my $cmd; + ($pid, $cmd) = ($a[6] =~ m/^([0-9]+)\/(.*)$/) if ($wantpid); + + return { + address_family => 'inet', + protocol => $a[0], # 'tcp' + state => $a[5], # 'LISTEN' + local_addr => $addr, # numeric + local_port => $port, # numeric + pid => $pid, # numeric or undef + cmd => $cmd, # string or undef + }; + }, + + # # netstat -ln -Ainet6 + # Active Internet connections (only servers) + # Proto Recv-Q Send-Q Local Address Foreign Address State + # tcp6 0 0 :::22 :::* LISTEN + # + # # netstat -lnp -Ainet6 + # Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name + # tcp6 0 0 :::22 :::* LISTEN 1058/sshd + inet6 => sub + { + my ($line, $wantpid) = @_; + + my @a = split(/\s+/, $line); + return unless scalar(@a) == 6 + ($wantpid ? 1 : 0); + + my $prot = $a[0]; # tcp or tcp6 + $prot =~ s/6$//; + + my ($addr, $port) = ($a[3] =~ m/^(.*):([0-9]+)$/); + return unless defined $port; + $addr = 'any' if ($addr eq '::'); + $addr = 'localhost' if ($addr eq '::1'); + + my $pid; + my $cmd; + ($pid, $cmd) = ($a[6] =~ m/^([0-9]+)\/(.*)$/) if ($wantpid); + + return { + address_family => 'inet6', + protocol => $prot, # 'tcp' + state => $a[5], # 'LISTEN' + local_addr => $addr, # numeric + local_port => $port, # numeric + pid => $pid, # numeric or undef + cmd => $cmd, # string or undef + }; + }, + + # # netstat -ln -Aunix + # Active UNIX domain sockets (only servers) + # Proto RefCnt Flags Type State I-Node Path + # unix 2 [ ACC ] STREAM LISTENING 7941 /var/run/dbus/system_bus_socket + # + # # netstat -lnp -Aunix + # Active UNIX domain sockets (only servers) + # Proto RefCnt Flags Type State I-Node PID/Program name Path + # unix 2 [ ACC ] STREAM LISTENING 13317 2016/gconf-helper /tmp/orbit-gnb/linc-7e0-0-6044c14eae22e + unix => sub + { + my ($line, $wantpid) = @_; + + # Compress the Flags field to eliminate spaces and make split() + # return a predictable number of fields. + $line =~ s/\[[^]]*\]/[]/; + + my @a = split(/\s+/, $line); + return unless scalar(@a) == 7 + ($wantpid ? 1 : 0); + + my $state = $a[4]; + $state =~ s/^LISTENING$/LISTEN/; + + return if $a[0] ne 'unix'; + my $prot; + $prot = 'tcp' if ($a[3] eq 'STREAM'); + $prot = 'udp' if ($a[3] eq 'DGRAM'); + return if !defined $prot; + + my $pid; + my $cmd; + ($pid, $cmd) = ($a[6] =~ m/^([0-9]+)\/(.*)$/) if ($wantpid); + + return { + address_family => 'unix', + protocol => $prot, # 'tcp' + state => $state, # 'LISTEN' + local_addr => 'any', + local_port => $a[-1], # bound socket path + pid => $pid, # numeric or undef + cmd => $cmd, # string or undef + }; + }, +); + +sub address_family +{ + my ($self) = @_; + my $h = $self->host(); + my $p = $self->port(); + + # port being a UNIX domain socket is ok + return 'unix' if ($p =~ m/^\//); + + # otherwise, the port has to be numeric + die "Sorry, the port \"$p\" must be a numeric TCP port or unix path" + unless ($p =~ m/^\d+$/); + + # undefined host is ok = inet, IPADDR_ANY + return 'inet' if !defined $h; + # IPv4 address is ok + return 'inet' if ($h =~ m/^\d+\.\d+\.\d+\.\d+$/); + # full IPv6 address is ok + return 'inet6' if ($h =~ m/^([[:xdigit:]]{1,4}::?)+[[:xdigit:]]{1,4}$/); + # IPv6 forms xxxx::x and ::x are ok + return 'inet6' if ($h =~ m/^[[:xdigit:]]{4}::[[:xdigit:]]{1,4}$/); + return 'inet6' if ($h =~ m/^::[[:xdigit:]]{1,4}$/); + # others, not so much + die "Sorry, the host argument \"$h\" must be a numeric IPv4 or IPv6 address"; +} + +sub _is_listening_af +{ + my ($self, $af) = @_; + + my @cmd = ( + 'netstat', + '-l', # listening ports only + '-n', # numeric output + ); + my $parser = $netstat_parse{$af}; + my $found = 0; + open NETSTAT,'-|',@cmd + or die "Cannot run netstat to check for service: $!"; + + my $host = $self->{host}; + $host = 'any' if !defined $host; + $host = 'localhost' if $host eq '127.0.0.1'; + $host = 'localhost' if $host eq '::1'; + + while () + { + chomp; + my $ii = $parser->($_, 0); + next unless $ii; + next if ($ii->{protocol} ne 'tcp'); + next if ($ii->{state} ne 'LISTEN'); + next if ($ii->{local_port} ne "$self->{port}"); + next if ($ii->{local_addr} ne $host && $ii->{local_addr} ne 'any'); + $found = 1; + last; + } + close NETSTAT; + + xlog "is_listening: service $self->{name} is " . + "listening on " . $self->address() + if ($found); + + return $found; +} + +sub is_listening +{ + my ($self) = @_; + + my @afs; + my $af = $self->address_family(); + push(@afs, $af); + push(@afs, 'inet6') + if ($af eq 'inet' && !defined $self->host()); + + foreach my $af (@afs) + { + return 0 if (!$self->_is_listening_af($af)); + } + return 1; +} + +sub kill_processes_on_ports +{ + my (@ports) = @_; + + return if !scalar(@ports); + xlog "checking for stray processes on ports: " . join(' ', @ports); + + my %portshash; + map { $portshash{$_} = 1; } @ports; + + # We don't care about UNIX sockets here + # although we probably should + my @found; + foreach my $af ('inet', 'inet6') + { + # Silly netstat -p on Linux prints a warning to stderr + # -n numeric output + # -p show pid & program + my $cmd = "netstat -np 2>/dev/null"; + + my $parser = $netstat_parse{$af}; + open NETSTAT,'-|',$cmd + or die "Cannot run netstat to check for stray processes: $!"; + + while () + { + chomp; + my $ii = $parser->($_, 1); + next unless $ii; + next unless $portshash{$ii->{local_port}}; +# xlog "XXX stray socket: " . Data::Dumper::Dumper($ii); + next if !defined $ii->{pid}; # we don't have permission, + # or there is no process, + # e.g. in TIME_WAIT state + push(@found, $ii); + } + close NETSTAT; + } + + foreach my $ii (@found) + { + xlog "ERROR!! killing stray process $ii->{cmd} on port $ii->{local_port}"; + Cassandane::Instance::_stop_pid($ii->{pid}); + } + return scalar(@found); +} + +sub describe +{ + my ($self) = @_; + + printf "%s listening on %s\n", + $self->{name}, + $self->address(); +} + +sub set_argv +{ + my ($self, @args) = @_; + $self->{argv} = [ @args ]; +} + +sub get_argv +{ + my ($self) = @_; + + my $aa = $self->{argv}; + die "No command" if (!defined $aa); + my @argv; + + if (ref $aa eq 'CODE') + { + @argv = $aa->($self); + } + elsif (ref $aa eq 'ARRAY') + { + @argv = @$aa; + } + else + { + die "Unexpected command type"; + } + + map { $_ = $self->{config}->substitute($_); } @argv; + + return @argv; +} + +1; diff --git a/cassandane/Cassandane/IMAPMessageStore.pm b/cassandane/Cassandane/IMAPMessageStore.pm new file mode 100644 index 0000000000..4fd8311a9e --- /dev/null +++ b/cassandane/Cassandane/IMAPMessageStore.pm @@ -0,0 +1,581 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::IMAPMessageStore; +use strict; +use warnings; +use Mail::IMAPTalk; +use Cwd qw(abs_path); + +# runtime dependency of Mail::IMAPTalk. make sure we have it! +require IO::Socket::SSL; + +use lib '.'; +use base qw(Cassandane::MessageStore); +use Cassandane::Util::Log; +use Cassandane::Util::DateTime qw(to_rfc822); +use Cassandane::Util::Socket; + +our $BATCHSIZE = 10; + +sub new +{ + my ($class, %params) = @_; + my %bits = ( + address_family => delete $params{address_family} || 'inet', + host => delete $params{host} || 'localhost', + port => 0 + (delete $params{port} || 143), + folder => delete $params{folder} || 'INBOX', + username => delete $params{username}, + password => delete $params{password}, + client => undef, + banner => undef, + # state for streaming read + next_uid => undef, + last_uid => undef, + last_batch_uid => undef, + batch => undef, + fetch_attrs => { uid => 1, 'body.peek[]' => 1 }, + # state for XCONVFETCH + fetched => undef, + ssl => delete $params{ssl} || 0, + ); + my $self = $class->SUPER::new(%params); + map { $self->{$_} = $bits{$_}; } keys %bits; + return $self; +} + +sub connect +{ + my ($self, %params) = @_; + + # if already successfully connected, do nothing + return + if (defined $self->{client} && + ($self->{client}->state() == Mail::IMAPTalk::Authenticated || + $self->{client}->state() == Mail::IMAPTalk::Selected)); + + $self->disconnect(); + + my $client; + + if ($self->{ssl}) { + my $ca_file = abs_path("data/certs/cacert.pem"); + # XXX https://github.com/noxxi/p5-io-socket-ssl/issues/121 + # XXX With newer IO::Socket::SSL, hostname verification fails + # XXX because our hostname is an IP address and the certificate + # XXX CN does not contain the IP address. + # XXX Turning hostname verification back off again for now with + # XXX `SSL_verifycn_scheme => 'none'`. The better fix would be + # XXX to generate new certificates for 127.0.0.1 and ::1, but + # XXX I don't remember how... + $client = Mail::IMAPTalk->new( + Server => $self->{host}, + Port => $self->{port}, + UseSSL => $self->{ssl}, + SSL_ca_file => $ca_file, + SSL_verifycn_scheme => 'none', + UseBlocking => 1, # must be blocking for SSL + Pedantic => 1, + PreserveINBOX => 1, + Uid => 0, + NoLiteralPlus => delete $params{NoLiteralPlus} || 0, + ) + or die "Cannot connect to '$self->{host}:$self->{port}': $@"; + } + else { + my $sock = create_client_socket( + $self->{address_family}, + $self->{host}, $self->{port}) + or die "Cannot create client socket: $@"; + + $client = Mail::IMAPTalk->new( + Socket => $sock, + Pedantic => 1, + PreserveINBOX => 1, + Uid => 0, + NoLiteralPlus => delete $params{NoLiteralPlus} || 0, + ) + or die "Cannot connect to server: $@"; + } + + $client->set_tracing(1) + if $self->{verbose}; + + my $banner = $client->get_response_code('remainder'); + $client->login($self->{username}, $self->{password}) + or die "Cannot login to server \"$self->{host}:$self->{port}\": $@"; + + # Make Mail::IMAPTalk just stfu + $client->set_unicode_folders(1); + + $client->parse_mode(Envelope => 1); + + $self->{client} = $client; + $self->{banner} = $banner; +} + +sub disconnect +{ + my ($self) = @_; + + # We don't care if the LOGOUT fails. Really. + eval + { + local $SIG{__DIE__}; + $self->{client}->logout() + if defined $self->{client}; + }; + $self->{client} = undef; +} + +sub _select +{ + my ($self) = @_; + + if ($self->{client}->state() == Mail::IMAPTalk::Selected) + { + $self->{client}->unselect() + or die "Cannot unselect: $@"; + } + return $self->{client}->select($self->{folder}); +} + +sub write_begin +{ + my ($self) = @_; + my $r; + + $self->connect(); + + $r = $self->_select(); + if (!defined $r) + { + die "Cannot select folder \"$self->{folder}\": $@" + unless $self->{client}->get_last_error() =~ m/does not exist/; + $self->{client}->create($self->{folder}) + or die "Cannot create folder \"$self->{folder}\": $@" + } +} + +sub write_message +{ + my ($self, $msg, %opts) = @_; + + my @extra; + my @flags; + + if ($opts{flags}) { + push @flags, @{$opts{flags}}; + } + + if ($msg->has_attribute('flags')) { + push @flags, @{$msg->get_attribute('flags')}; + } + + if (@flags) { + push @extra, '(' . join(' ', @flags) . ')'; + } + + if ($msg->has_attribute('internaldate')) { + push @extra, $msg->get_attribute('internaldate'); + } + + $self->{client}->append($self->{folder}, @extra, + { Literal => $msg->as_string() } ) + || die "$@"; + + # if we know the uid and uidvalidity, update the msg object + my $appenduid = $self->{client}->get_response_code('appenduid'); + if (defined $appenduid and ref $appenduid eq 'ARRAY') { + $msg->set_attribute(uidvalidity => $appenduid->[0]); + $msg->set_attribute(uid => $appenduid->[1]); + } +} + +sub write_end +{ + my ($self) = @_; +} + +sub set_fetch_attributes +{ + my ($self, @attrs) = @_; + + $self->{fetch_attrs} = { uid => 1, 'body.peek[]' => 1 }; + foreach my $attr (@attrs) + { + $attr = lc($attr); + die "Bad fetch attribute \"$attr\"" + unless ($attr =~ m/^annotation\s+\(\S+\s+value\.(shared|priv)\)$/i || + $attr =~ m/^[a-z0-9.\[\]<>]+$/); + next + if ($attr =~ m/^body/); + $self->{fetch_attrs}->{$attr} = 1; + } +} + +sub read_begin +{ + my ($self) = @_; + + $self->connect(); + + $self->_select() + or die "Cannot select folder \"$self->{folder}\": $@"; + + $self->{next_uid} = 1; + $self->{last_uid} = -1 + $self->{client}->get_response_code('uidnext'); + $self->{last_batch_uid} = undef; + $self->{batch} = undef; +} + +sub read_message +{ + my ($self, $msg) = @_; + + for (;;) + { + while (defined $self->{batch}) + { + my $uid = $self->{next_uid}; + last if $uid > $self->{last_batch_uid}; + $self->{next_uid}++; + my $rr = $self->{batch}->{$uid}; + next unless defined $rr; + delete $self->{batch}->{$uid}; + + # xlog "found uid=$uid in batch"; + # xlog "rr=" . Dumper($rr); + my $raw = $rr->{'body'}; + delete $rr->{'body'}; + return Cassandane::Message->new(raw => $raw, + attrs => { id => $uid, %$rr }); + } + $self->{batch} = undef; + + # xlog "batch empty or no batch available"; + + for (;;) + { + my $first_uid = $self->{next_uid}; + return undef + if $first_uid > $self->{last_uid}; # EOF + my $last_uid = $first_uid + $BATCHSIZE - 1; + $last_uid = $self->{last_uid} + if $last_uid > $self->{last_uid}; + # xlog "fetching batch range $first_uid:$last_uid"; + my $attrs = join(' ', keys %{$self->{fetch_attrs}}); + $self->{batch} = $self->{client}->fetch("$first_uid:$last_uid", + "($attrs)"); + $self->{last_batch_uid} = $last_uid; + last if (defined $self->{batch} && scalar $self->{batch} > 0); + $self->{next_uid} = $last_uid + 1; + } + # xlog "have a batch, next_uid=$self->{next_uid}"; + } + + return undef; +} + +sub read_end +{ + my ($self) = @_; + + $self->{next_uid} = undef; + $self->{last_uid} = undef; + $self->{last_batch_uid} = undef; + $self->{batch} = undef; +} + +sub remove +{ + my ($self) = @_; + + $self->connect(); + my $r = $self->{client}->delete($self->{folder}); + die "IMAP DELETE failed: $@" + if (!defined $r && !($self->{client}->get_last_error() =~ m/does not exist/)); +} + +sub get_client +{ + my ($self, %params) = @_; + + $self->connect(%params); + return $self->{client}; +} + +sub get_server_name +{ + my ($self) = @_; + + $self->connect(); + + # Cyrus returns the servername config variable in the first + # word of the untagged OK reponse sent on connection. We + # Capture the non-response code part of that in {banner}. + # which looks like + # slott02 Cyrus IMAP git2.5.0+0-git-work-6640 server ready + my ($servername) = ($self->{banner} =~ m/^(\S+)\s+Cyrus\s+IMAP\s+/); + return $servername; +} + +sub as_string +{ + my ($self) = @_; + + return 'imap://' . $self->{host} . ':' . $self->{port} . '/' . $self->{folder}; +} + +sub set_folder +{ + my ($self, $folder) = @_; + + if ($self->{folder} ne $folder) + { + $self->{folder} = $folder; + } +} + +sub _kvlist_to_hash +{ + my (@kvlist) = @_; + my $h = {}; + while (my $k = shift @kvlist) + { + my $v = shift @kvlist; + $h->{lc($k)} = $v; + } + return $h; +} + +sub xconvfetch_begin +{ + my ($self, $cid, $changedsince) = @_; + my @args = ( $cid, $changedsince || 0, [ keys %{$self->{fetch_attrs}} ] ); + + my $results = + { + xconvmeta => {}, + }; + $self->{fetched} = undef; + my %handlers = + ( + xconvmeta => sub + { + # expecting: * XCONVMETA d55a42549e674b82 (MODSEQ 29) + my ($response, $rr) = @_; +# xlog "XCONVMETA rr=" . Dumper($rr); + $results->{xconvmeta}->{$rr->[0]} = _kvlist_to_hash(@{$rr->[1]}); + }, + fetch => sub + { + my ($response, $rr) = @_; +# xlog "FETCH rr=" . Dumper($rr); + push(@{$self->{fetched}}, $rr); + } + ); + + $self->connect(); + + $self->{client}->_imap_cmd("xconvfetch", 0, \%handlers, @args) + or return undef; + + return $results; +} + +sub xconvfetch_message +{ + my ($self) = @_; + + my $rr = shift @{$self->{fetched}}; + return undef + if !defined $rr; + + my $raw = $rr->{'body'}; + delete $rr->{'body'}; + return Cassandane::Message->new(raw => $raw, attrs => $rr); +} + +sub xconvfetch_end +{ + my ($self) = @_; + $self->{fetched} = undef; +} + +# +# Begin idling. Sends the IDLE command and waits for the server to +# respond with the "+ idling" response. Returns undef and sets $@ +# on error, or dies on timeout. +# +sub idle_begin +{ + my ($self) = @_; + + my $talk = $self->get_client(); + + $talk->_send_cmd('idle'); + + my $got_idling = 0; + my $handlers = { idling => sub { $got_idling = 1; } }; + + # Await the "+ idling" response + + # temporarily set Timeout + my $old_tout = $talk->{Timeout}; + $talk->{Timeout} = 5; + + # hack to force a line read + $talk->{ReadLine} = undef; + + # temporarily replace CmdId with '+' so that + # _parse_response() will think it's a tag. + my $cmd_id = $talk->{CmdId}; + $talk->{CmdId} = '+'; + + # Will die if timedout - failing the test + $talk->_parse_response($handlers); + + # replace CmdId, Timeout + $talk->{CmdId} = $cmd_id; + $talk->{Timeout} = $old_tout; + + if (!$got_idling) + { + $@ = "Did not receive expected \"idling\" response"; + return undef; + } + + return 1; +} + +# +# Read any unsolicited responses from the server. The $handlers is +# argument is like the one passed to Mail::IMAPTalk->_imap_cmd(). The +# $tout argument is a timeout in seconds indicating how long to wait if +# no responses have yet been received, with 0 specifically meaning "just +# poll, do not block". Returns true if at a response was read. +# +sub idle_response +{ + my ($self, $handlers, $tout) = @_; + my $talk = $self->get_client(); + + # Temporarily set the Timeout for _parse_response + my $old_tout = $talk->{Timeout}; + $talk->{Timeout} = $tout; + + # Temporarily set CmdId to fool _parse_response into returning as + # soon as it sees the first unsolicited response instead of waiting + # for the actual tagged response, which might be a very long time + # coming. + my $cmd_id = $talk->{CmdId}; + $talk->{CmdId} = '*'; + + my $got = 0; + eval + { + $talk->_parse_response($handlers); + $got = 1; + }; + + # Restore old values of CmdId and Timeout + $talk->{CmdId} = $cmd_id; + $talk->{Timeout} = $old_tout; + + return $got; +} + +sub idle_end +{ + my ($self, $handlers) = @_; + + my $talk = $self->get_client(); + + # Send the "DONE" continuation which cancels the IDLE command + $talk->_imap_socket_out("DONE\n"); + + # Get the final tagged response including any unsolicited responses not yet seen + $talk->_parse_response($handlers); + + # Prepare for the next command + $talk->{CmdId}++; +} + +sub get_counters +{ + my ($self) = @_; + my $KEY = "/private/vendor/cmu/cyrus-imapd/usercounters"; + my ($maj, $min) = Cassandane::Instance->get_version(); + + my $talk = $self->get_client(); + + my $counters1 = $talk->getmetadata("", $KEY); + my $counters = $counters1->{''}{$KEY}; + #"3 22 20 16 22 0 20 14 22 0 1571356860") + + + my ($v1, $all1, $mail1, $cal1, $card1, $notes1, $mailfolders1, $calfolders1, $cardfolders1, $notesfolders1, $quota1, $racl1, $valid1, $nothing1) = split / /, $counters; + + if ($maj < 3 || $maj == 3 && $min == 0) { + # 3.0 and earlier did not have quotamodseq or raclmodseq, but + # uidvalidity was still the last field + $valid1 = $quota1; + $quota1 = undef; + } + + return { + version => $v1, + highestmodseq => $all1, + mailmodseq => $mail1, + calendarmodseq => $cal1, + contactsmodseq => $card1, + notesmodseq => $notes1, + mailfoldersmodseq => $mailfolders1, + calendarfoldersmodseq => $calfolders1, + contactsfoldersmodseq => $cardfolders1, + notesfoldersmodseq => $notesfolders1, + quotamodseq => $quota1, + raclmodseq => $racl1, + uidvalidity => $valid1, + }; +} + +1; diff --git a/cassandane/Cassandane/IMAPService.pm b/cassandane/Cassandane/IMAPService.pm new file mode 100644 index 0000000000..ff5145d8f8 --- /dev/null +++ b/cassandane/Cassandane/IMAPService.pm @@ -0,0 +1,68 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::IMAPService; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Service); +use Cassandane::Util::Log; + +sub new +{ + my ($class, %params) = @_; + my $ssl = scalar grep { $_ eq '-s' } @{$params{argv}}; + my $type = $ssl ? 'imaps' : 'imap'; + my $self = $class->SUPER::new(type => $type, %params); + return $self; +} + +# Return a hash of parameters suitable for passing +# to MessageStoreFactory::create. +sub store_params +{ + my ($self, %inparams) = @_; + + my $outparams = $self->SUPER::store_params(%inparams); + $outparams->{folder} ||= 'inbox'; + return $outparams; +} + +1; diff --git a/cassandane/Cassandane/Instance.pm b/cassandane/Cassandane/Instance.pm new file mode 100644 index 0000000000..fb1fda570a --- /dev/null +++ b/cassandane/Cassandane/Instance.pm @@ -0,0 +1,2837 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Instance; +use strict; +use warnings; +use Config; +use Data::Dumper; +use Errno qw(ENOENT); +use File::Copy; +use File::Path qw(mkpath rmtree); +use File::Find qw(find); +use File::Basename; +use File::stat; +use JSON; +use POSIX qw(geteuid :signal_h :sys_wait_h :errno_h); +use DateTime; +use BSD::Resource; +use Cwd qw(abs_path getcwd); +use AnyEvent; +use AnyEvent::Handle; +use AnyEvent::Socket; +use AnyEvent::Util; +use JSON; +use HTTP::Daemon; +use DBI; +use Time::HiRes qw(usleep); +use List::Util qw(uniqstr); + +use lib '.'; +use Cassandane::Util::DateTime qw(to_iso8601); +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use Cassandane::Util::Wait; +use Cassandane::Mboxname; +use Cassandane::Config; +use Cassandane::Service; +use Cassandane::ServiceFactory; +use Cassandane::GenericListener; +use Cassandane::MasterStart; +use Cassandane::MasterEvent; +use Cassandane::MasterDaemon; +use Cassandane::Cassini; +use Cassandane::PortManager; +use Cassandane::BuildInfo; + +use lib '../perl/imap'; +require Cyrus::DList; + +my $__cached_rootdir; +my $stamp; +my $next_unique = 1; + +sub new +{ + my $class = shift; + my %params = @_; + + my $cassini = Cassandane::Cassini->instance(); + + my $self = { + name => undef, + buildinfo => undef, + basedir => undef, + installation => 'default', + cyrus_prefix => undef, + cyrus_destdir => undef, + config => Cassandane::Config->default()->clone(), + starts => [], + services => {}, + events => [], + daemons => {}, + generic_listeners => {}, + re_use_dir => 0, + setup_mailbox => 1, + persistent => 0, + authdaemon => 1, + _children => {}, + _stopped => 0, + description => 'unknown', + _shutdowncallbacks => [], + _started => 0, + _pwcheck => $cassini->val('cassandane', 'pwcheck', 'alwaystrue'), + install_certificates => 0, + _pid => $$, + smtpdaemon => 0, + }; + + $self->{name} = $params{name} + if defined $params{name}; + $self->{basedir} = $params{basedir} + if defined $params{basedir}; + $self->{installation} = $params{installation} + if defined $params{installation}; + $self->{cyrus_prefix} = $cassini->val("cyrus $self->{installation}", + 'prefix', '/usr/cyrus'); + $self->{cyrus_prefix} = $params{cyrus_prefix} + if defined $params{cyrus_prefix}; + $self->{cyrus_destdir} = $cassini->val("cyrus $self->{installation}", + 'destdir', ''); + $self->{cyrus_destdir} = $params{cyrus_destdir} + if defined $params{cyrus_destdir}; + $self->{config} = $params{config}->clone() + if defined $params{config}; + $self->{re_use_dir} = $params{re_use_dir} + if defined $params{re_use_dir}; + $self->{setup_mailbox} = $params{setup_mailbox} + if defined $params{setup_mailbox}; + $self->{persistent} = $params{persistent} + if defined $params{persistent}; + $self->{authdaemon} = $params{authdaemon} + if defined $params{authdaemon}; + $self->{description} = $params{description} + if defined $params{description}; + $self->{pwcheck} = $params{pwcheck} + if defined $params{pwcheck}; + $self->{install_certificates} = $params{install_certificates} + if defined $params{install_certificates}; + $self->{smtpdaemon} = $params{smtpdaemon} + if defined $params{smtpdaemon}; + + # XXX - get testcase name from caller, to apply even finer + # configuration from cassini ? + return bless $self, $class; +} + +# Class method! Need to be able to interrogate the Cyrus version +# being tested without actually instantiating a Cassandane::Instance. +# This also means we have to do a few things here the direct way, +# rather than using helper methods... +my %cached_version = (); +my %cached_sversion = (); +sub get_version +{ + my ($class, $installation) = @_; + $installation = 'default' if not defined $installation; + + if (exists $cached_version{$installation}) { + return @{$cached_version{$installation}} if wantarray; + return $cached_sversion{$installation}; + } + + my $cassini = Cassandane::Cassini->instance(); + + # Need to check the named-installation directory AND the + # default installation directory, before falling back to the + # default-default + # Usually Cassandane::Cyrus::TestCase only initialises an Instance + # object with a non-default installation if that installation actually + # exists, but this is a class method, not an object method, so we + # don't have that protection and have to DIY. + my ($cyrus_prefix, $cyrus_destdir, $cyrus_master); + + INSTALLATION: foreach my $i (uniqstr($installation, 'default')) { + $cyrus_prefix = $cassini->val("cyrus $i", 'prefix', + $i eq 'default' ? '/usr/cyrus' : undef); + + # no prefix? non-default installation isn't configured, skip it + next INSTALLATION if not defined $cyrus_prefix; + + $cyrus_destdir = $cassini->val("cyrus $i", 'destdir', q{}); + + foreach my $d (qw( bin sbin libexec libexec/cyrus-imapd lib cyrus/bin )) + { + my $try = "$cyrus_destdir$cyrus_prefix/$d/master"; + if (-x $try) { + $cyrus_master = $try; + last INSTALLATION; + } + } + } + + die "unable to locate master binary" if not defined $cyrus_master; + + my $version; + { + open my $fh, '-|', "$cyrus_master -V" + or die "unable to execute '$cyrus_master -V': $!"; + local $/; + $version = <$fh>; + close $fh; + } + + if (not $version) { + # Cyrus version might be too old for 'master -V' + # Try to squirrel a version out of libcyrus pkgconfig file + open my $fh, '<', "$cyrus_destdir$cyrus_prefix/lib/pkgconfig/libcyrus.pc"; + while (<$fh>) { + $version = $_ if m/^Version:/; + } + close $fh; + } + + #cyrus-imapd 3.0.0-beta3-114-g5fa1dbc-dirty + if ($version =~ m/^cyrus-imapd (\d+)\.(\d+).(\d+)(?:-(.*))?$/) { + my ($maj, $min, $rev, $extra) = ($1, $2, $3, $4); + my $pluscommits = 0; + if (defined $extra && $extra =~ m/(\d+)-g[a-fA-F0-9]+(?:-dirty)?$/) { + $pluscommits = $1; + } + $cached_version{$installation} = [ 0 + $maj, + 0 + $min, + 0 + $rev, + 0 + $pluscommits, + $extra ]; + } + elsif ($version =~ m/^Version: (\d+)\.(\d+).(\d+)(?:-(.*))?$/) { + my ($maj, $min, $rev, $extra) = ($1, $2, $3, $4); + my $pluscommits; + if ($extra =~ m/(\d+)-g[a-fA-F0-9]+(?:-dirty)?$/) { + $pluscommits = $1; + } + $cached_version{$installation} = [ 0 + $maj, + 0 + $min, + 0 + $rev, + 0 + $pluscommits, + $extra ]; + } + else { + $cached_version{$installation} = [0, 0, 0, 0, q{}]; + } + + $cached_sversion{$installation} = join q{.}, + @{$cached_version{$installation}}[0..2]; + $cached_sversion{$installation} .= "-$cached_version{$installation}->[4]" + if $cached_version{$installation}->[4]; + + return @{$cached_version{$installation}} if wantarray; + return $cached_sversion{$installation}; +} + +sub _rootdir +{ + if (!defined $__cached_rootdir) + { + my $cassini = Cassandane::Cassini->instance(); + $__cached_rootdir = + $cassini->val('cassandane', 'rootdir', '/var/tmp/cass'); + } + return $__cached_rootdir; +} + +sub _make_instance_info +{ + my ($name, $basedir) = @_; + + die "Need either a name or a basename" + if !defined $name && !defined $basedir; + $name ||= basename($basedir); + $basedir ||= _rootdir() . '/' . $name; + + my $sb = stat($basedir); + die "Cannot stat $basedir: $!" if !defined $sb && $! != ENOENT; + + return { + name => $name, + basedir => $basedir, + ctime => ($sb ? $sb->ctime : undef), + }; +} + +sub _make_unique_instance_info +{ + # This must be kept in sync with cleanup_leftovers, which expects + # to be able to recognise instance directories by name for cleanup. + if (!defined $stamp) + { + $stamp = to_iso8601(DateTime->now); + $stamp =~ s/.*T(\d+)Z/$1/; + + my $workerid = $ENV{TEST_UNIT_WORKER_ID}; + die "Invalid TEST_UNIT_WORKER_ID - code not run in Worker context" + if (defined($workerid) && $workerid eq 'invalid'); + $stamp .= sprintf("%02X", $workerid) if defined $workerid; + } + + my $rootdir = _rootdir(); + + my $name; + my $basedir; + for (;;) + { + $name = sprintf("%s%02X", $stamp, $next_unique); + $next_unique++; + $basedir = "$rootdir/$name"; + last if mkdir($basedir); + die "Cannot create $basedir: $!" if ($! != EEXIST); + } + return _make_instance_info($name, $basedir); +} + +sub list +{ + my $rootdir = _rootdir(); + opendir ROOT, $rootdir + or die "Cannot open $rootdir for reading: $!"; + my @instances; + while ($_ = readdir(ROOT)) + { + next unless m/^[0-9]+[A-Z]?$/; + push(@instances, _make_instance_info($_)); + } + closedir ROOT; + return @instances; +} + +sub exists +{ + my ($name) = @_; + return if ( ! -d _rootdir() . '/' . $name ); + return _make_instance_info($name); +} + +sub _init_basedir_and_name +{ + my ($self) = @_; + + my $info; + my $which = (defined $self->{name} ? 1 : 0) | + (defined $self->{basedir} ? 2 : 0); + if ($which == 0) + { + # have neither name nor basedir + # usual first time case for test instances + $info = _make_unique_instance_info(); + } + else + { + # have name but not basedir + # usual first time case for start-instance.pl + # or basedir but not name, which doesn't happen + $info = _make_instance_info($self->{name}, $self->{basedir}); + } + $self->{name} = $info->{name}; + $self->{basedir} = $info->{basedir}; +} + +sub get_basedir +{ + my ($self) = @_; + + return $self->{basedir} if $self->{basedir}; + + $self->_init_basedir_and_name(); + + return $self->{basedir}; +} + +# Remove on-disk traces of any previous instances +sub cleanup_leftovers +{ + my $rootdir = _rootdir(); + + return if (!-d $rootdir); + opendir ROOT, $rootdir + or die "Cannot open directory $rootdir for reading: $!"; + my @dirs; + while (my $e = readdir(ROOT)) + { + # This must be kept in sync with _make_unique_instance_info, + # which is what names and creates these directories. + my $basedirpat = qr{ + \d{6} # UTC timestamp as HHMMSS + (?:[0-9A-F]{2,})? # optional worker ID as 2+ hex digits + [0-9A-F]{2,} # unique number as 2+ hex digits + }ax; + + push(@dirs, $e) if $e =~ m/$basedirpat/; + } + closedir ROOT; + + map + { + if (get_verbose) { + xlog "Cleaning up old basedir $rootdir/$_"; + } + rmtree "$rootdir/$_"; + } @dirs; +} + +sub add_service +{ + my ($self, %params) = @_; + + my $name = $params{name}; + die "Missing parameter 'name'" + unless defined $name; + die "Already have a service named \"$name\"" + if defined $self->{services}->{$name}; + + # Add a hardcoded recover START if we're doing an actual IMAP test. + if ($name =~ m/imap/) + { + $self->add_recover(); + } + + my $srv = Cassandane::ServiceFactory->create(instance => $self, %params); + $self->{services}->{$name} = $srv; + return $srv; +} + +sub add_services +{ + my ($self, @names) = @_; + map { $self->add_service(name => $_); } @names; +} + +sub get_service +{ + my ($self, $name) = @_; + return $self->{services}->{$name}; +} + +sub remove_service +{ + my ($self, $name) = @_; + delete $self->{services}->{$name}; +} + +sub add_start +{ + my ($self, %params) = @_; + push(@{$self->{starts}}, Cassandane::MasterStart->new(%params)); +} + +sub remove_start +{ + my ($self, $name) = @_; + $self->{starts} = [ grep { $_->{name} ne $name } @{$self->{starts}} ]; +} + +sub add_recover +{ + my ($self) = @_; + + if (!grep { $_->{name} eq 'recover'; } @{$self->{starts}}) + { + $self->add_start(name => 'recover', + argv => [ qw(ctl_cyrusdb -r) ]); + } +} + +sub add_event +{ + my ($self, %params) = @_; + push(@{$self->{events}}, Cassandane::MasterEvent->new(%params)); +} + +sub add_daemon +{ + my ($self, %params) = @_; + + my $name = $params{name}; + die "Missing parameter 'name'" + unless defined $name; + die "Already have a daemon named \"$name\"" + if defined $self->{daemons}->{$name}; + + $self->{daemons}->{$name} = Cassandane::MasterDaemon->new(%params); +} + +sub add_generic_listener +{ + my ($self, %params) = @_; + + my $name = delete $params{name}; + die "Missing parameter 'name'" + unless defined $name; + die "Already have a generic listener named \"$name\"" + if defined $self->{generic_listeners}->{$name}; + + $params{config} //= $self->{config}; + + my $listener = Cassandane::GenericListener->new( + name => $name, + %params + ); + + $self->{generic_listeners}->{$name} = $listener; + return $listener; +} + +sub set_config +{ + my ($self, $conf) = @_; + + $self->{config} = $conf; +} + +sub _find_binary +{ + my ($self, $name) = @_; + + my $cassini = Cassandane::Cassini->instance(); + my $name_override = $cassini->val("cyrus $self->{installation}", $name); + $name = $name_override if defined $name_override; + + return $name if $name =~ m/^\//; + + my $base = $self->{cyrus_destdir} . $self->{cyrus_prefix}; + + if ($name =~ m/xapian-.*$/) { + my $lib = `ldd $base/libexec/imapd` || die "can't ldd imapd"; + $lib =~ m{(/\S+)/lib/libxapian-([0-9.]+)\.so}; + return "$1/bin/$name-$2"; + } + + foreach (qw( bin sbin libexec libexec/cyrus-imapd lib cyrus/bin )) + { + my $dir = "$base/$_"; + if (opendir my $dh, $dir) + { + if (grep { $_ eq $name } readdir $dh) { + xlog "Found binary $name in $dir"; + closedir $dh; + return "$dir/$name"; + } + closedir $dh; + } + else + { + xlog "Couldn't opendir $dir: $!" if $! != ENOENT; + next; + } + } + + die "Couldn't locate $name under $base"; +} + +sub _binary +{ + my ($self, $name) = @_; + + my @cmd; + my $valground = 0; + + my $cassini = Cassandane::Cassini->instance(); + + if ($cassini->bool_val('valgrind', 'enabled') && + !($name =~ m/xapian.*$/) && + !($name =~ m/\.pl$/) && + !($name =~ m/^\//)) + { + my $arguments = '-q --tool=memcheck --leak-check=full --run-libc-freeres=no'; + my $valgrind_logdir = $self->{basedir} . '/vglogs'; + my $valgrind_suppressions = + abs_path($cassini->val('valgrind', 'suppression', 'vg.supp')); + mkpath $valgrind_logdir + unless ( -d $valgrind_logdir ); + push(@cmd, + $cassini->val('valgrind', 'binary', '/usr/bin/valgrind'), + "--log-file=$valgrind_logdir/$name.%p", + "--suppressions=$valgrind_suppressions", + "--gen-suppressions=all", + split(/\s+/, $cassini->val('valgrind', 'arguments', $arguments)) + ); + $valground = 1; + } + + my $bin = $self->_find_binary($name); + push(@cmd, $bin); + + if (!$valground && $cassini->bool_val('gdb', $name)) + { + xlog "Will run binary $name under gdb due to cassandane.ini"; + xlog "Look in syslog for helpful instructions from gdbtramp"; + push(@cmd, '-D'); + } + + return @cmd; +} + +sub _imapd_conf +{ + my ($self, $prefix) = @_; + + my $fname = $prefix ? "$prefix-imapd.conf" : 'imapd.conf'; + + return $self->{basedir} . "/conf/$fname"; +} + +sub _master_conf +{ + my ($self) = @_; + + return $self->{basedir} . '/conf/cyrus.conf'; +} + +sub _pid_file +{ + my ($self, $name) = @_; + + $name ||= 'master'; + + return $self->{basedir} . "/run/$name.pid"; +} + +sub _list_pid_files +{ + my ($self) = @_; + + my $rundir = $self->{basedir} . "/run"; + if (!opendir(RUNDIR, $rundir)) { + return if $!{ENOENT}; # no run dir? never started + die "Cannot open run directory $rundir: $!"; + } + + my @pidfiles; + while ($_ = readdir(RUNDIR)) + { + my ($name) = m/^([^.].*)\.pid$/; + push(@pidfiles, $name) if defined $name; + } + + closedir(RUNDIR); + + @pidfiles = sort { $a cmp $b } @pidfiles; + @pidfiles = ( 'master', grep { $_ ne 'master' } @pidfiles ); + + return @pidfiles; +} + +sub _build_skeleton +{ + my ($self) = @_; + + my @subdirs = + ( + 'conf', + 'conf/certs', + 'conf/cores', + 'conf/sieve', + 'conf/socket', + 'conf/proc', + 'conf/log', + 'conf/log/admin', + 'conf/log/cassandane', + 'conf/log/user2', + 'conf/log/foo', + 'conf/log/mailproxy', + 'conf/log/mupduser', + 'conf/log/postman', + 'conf/log/repluser', + 'conf/log/smtpclient.sendmail', + 'conf/log/smtpclient.host', + 'lock', + 'data', + 'meta', + 'run', + 'smtpd', + 'tmp', + ); + foreach my $sd (@subdirs) + { + my $d = $self->{basedir} . '/' . $sd; + mkpath $d + or die "Cannot make path $d: $!"; + } +} + +sub _generate_imapd_conf +{ + my ($self, $config, $prefix) = @_; + + # Be very careful about setting $config options in this + # function. Anything that is set here cannot be varied + # per test! + + if (defined $self->{services}->{http}) { + my $davhost = $self->{services}->{http}->host; + if (defined $self->{services}->{http}->port) { + $davhost .= ':' . $self->{services}->{http}->port; + } + $config->set( + webdav_attachments_baseurl => "http://$davhost" + ); + } + + my ($cyrus_major_version, $cyrus_minor_version) = + Cassandane::Instance->get_version($self->{installation}); + + $config->set_variables( + name => $self->{name}, + basedir => $self->{basedir}, + cyrus_prefix => $self->{cyrus_prefix}, + prefix => getcwd(), + ); + $config->set( + sasl_pwcheck_method => 'saslauthd', + sasl_saslauthd_path => "$self->{basedir}/run/mux", + notifysocket => "dlist:$self->{basedir}/run/notify", + event_notifier => 'pusher', + ); + if ($cyrus_major_version >= 3) { + $config->set_bits('event_groups', 'mailbox message flags calendar'); + } + else { + $config->set_bits('event_groups', 'mailbox message flags'); + } + if ($self->{buildinfo}->get('search', 'xapian')) { + my %xapian_defaults = ( + search_engine => 'xapian', + search_index_headers => 'no', + search_batchsize => '8192', + defaultsearchtier => 't1', + 't1searchpartition-default' => "$self->{basedir}/search", + 't2searchpartition-default' => "$self->{basedir}/search2", + 't3searchpartition-default' => "$self->{basedir}/search3", + ); + while (my ($k, $v) = each %xapian_defaults) { + if (not defined $config->get($k)) { + $config->set($k => $v); + } + } + } + + $config->generate($self->_imapd_conf($prefix)); +} + +sub _emit_master_entry +{ + my ($self, $entry) = @_; + + my $params = $entry->master_params(); + my $name = delete $params->{name}; + my $config = delete $params->{config}; + + # if this master entry has its own confix, it will have a prefixed name + my $imapd_conf = $self->_imapd_conf($config ? $name : undef); + + # Convert ->{argv} to ->{cmd} + my $argv = delete $params->{argv}; + die "No argv argument" + unless defined $argv; + # do not alter original argv + my @args = @$argv; + my $bin = shift @args; + $params->{cmd} = join(' ', + $self->_binary($bin), + '-C', $imapd_conf, + @args + ); + + print MASTER " $name"; + while (my ($k, $v) = each %$params) + { + $v = "\"$v\"" + if ($v =~ m/\s/); + print MASTER " $k=$v"; + } + print MASTER "\n"; +} + +sub _generate_master_conf +{ + my ($self) = @_; + + my $filename = $self->_master_conf(); + open MASTER,'>',$filename + or die "Cannot open $filename for writing: $!"; + + if (scalar @{$self->{starts}}) + { + print MASTER "START {\n"; + map { $self->_emit_master_entry($_); } @{$self->{starts}}; + print MASTER "}\n"; + } + + if (scalar %{$self->{services}}) + { + print MASTER "SERVICES {\n"; + map { $self->_emit_master_entry($_); } values %{$self->{services}}; + print MASTER "}\n"; + } + + if (scalar @{$self->{events}}) + { + print MASTER "EVENTS {\n"; + map { $self->_emit_master_entry($_); } @{$self->{events}}; + print MASTER "}\n"; + } + + if (scalar %{$self->{daemons}}) + { + print MASTER "DAEMON {\n"; + $self->_emit_master_entry($_) for values %{$self->{daemons}}; + print MASTER "}\n"; + } + + close MASTER; +} + +sub _add_services_from_cyrus_conf +{ + my ($self) = @_; + + my $filename = $self->_master_conf(); + open MASTER,'<',$filename + or die "Cannot open $filename for reading: $!"; + + my $in; + while () + { + chomp; + s/\s*#.*//; # strip comments + next if m/^\s*$/; # skip empty lines + my ($m) = m/^(START|SERVICES|EVENTS|DAEMON)\s*{/; + if ($m) + { + $in = $m; + next; + } + if ($in && m/^\s*}\s*$/) + { + $in = undef; + next; + } + next if !defined $in; + + my ($name, $rem) = m/^\s*([a-zA-Z0-9]+)\s+(.*)$/; + $_ = $rem; + my %params; + while (length $_) + { + my ($k, $rem2) = m/^([a-zA-Z0-9]+)=(.*)/; + die "Bad parameter name" if !defined $k; + $_ = $rem2; + + my ($v, $rem3) = m/^"([^"]*)"(.*)/; + if (!defined $v) + { + ($v, $rem3) = m/^(\S*)(.*)/; + } + die "Bad parameter value" if !defined $v; + $_ = $rem3; + + if ($k eq 'listen') + { + my $aa = Cassandane::GenericListener::parse_address($v); + $params{host} = $aa->{host}; + $params{port} = $aa->{port}; + } + elsif ($k eq 'cmd') + { + $params{argv} = [ split(/\s+/, $v) ]; + } + else + { + $params{$k} = $v; + } + s/^\s+//; + } + if ($in eq 'SERVICES') + { + $self->add_service(instance => $self, name => $name, %params); + } + } + + close MASTER; +} + +sub _fix_ownership +{ + my ($self, $path) = @_; + + $path ||= $self->{basedir}; + + return if geteuid() != 0; + my $uid = getpwnam('cyrus'); + my $gid = getgrnam('root'); + + find(sub { chown($uid, $gid, $File::Find::name) }, $path); +} + +sub _read_pid_file +{ + my ($self, $name) = @_; + my $file = $self->_pid_file($name); + my $pid; + + return undef if ( ! -f $file ); + + open PID,'<',$file + or return undef; + while() + { + chomp; + ($pid) = m/^(\d+)$/; + last; + } + close PID; + + return undef unless defined $pid; + return undef unless $pid > 1; + return undef unless kill(0, $pid) > 0; + return $pid; +} + +sub _start_master +{ + my ($self) = @_; + + # First check that nothing is listening on any of the ports + # we expect to be able to use. That would indicate a failure + # of test containment - i.e. we failed to shut something down + # earlier. Or it might indicate that someone is trying to run + # a second set of Cassandane tests on this machine, which is + # also going to fail miserably. In any case we want to know. + foreach my $srv (values %{$self->{services}}, + values %{$self->{generic_listeners}}) + { + die "Some process is already listening on " . $srv->address() + if $srv->is_listening(); + } + + # Now start the master process. + my @cmd = + ( + 'master', + # The following is added automatically by _fork_command: + # '-C', $self->_imapd_conf(), + '-l', '255', + '-p', $self->_pid_file(), + '-d', + '-M', $self->_master_conf(), + ); + if (get_verbose) { + my $logfile = $self->{basedir} . '/conf/master.log'; + xlog "_start_master: logging to $logfile"; + push(@cmd, '-L', $logfile); + } + unlink $self->_pid_file(); + # Start master daemon + $self->run_command({ cyrus => 1 }, @cmd); + + # wait until the pidfile exists and contains a PID + # that we can verify is still alive. + xlog "_start_master: waiting for PID file"; + timed_wait(sub { $self->_read_pid_file() }, + description => "the master PID file to exist"); + xlog "_start_master: PID file present and correct"; + + # Start any other defined listeners + foreach my $listener (values %{$self->{generic_listeners}}) + { + $self->run_command({ cyrus => 0 }, $listener->get_argv()); + } + + # Wait until all the defined services are reported as listening. + # That doesn't mean they're ready to use but it means that at least + # a client will be able to connect(), although the first response + # might be a bit slow. + xlog "_start_master: PID waiting for services"; + foreach my $srv (values %{$self->{services}}, + values %{$self->{generic_listeners}}) + { + timed_wait(sub + { + $self->is_running() + or die "Master no longer running"; + $srv->is_listening(); + }, + description => $srv->address() . " to be in LISTEN state"); + } + xlog "_start_master: all services listening"; +} + +sub _start_notifyd +{ + my ($self) = @_; + + my $basedir = $self->{basedir}; + + my $notifypid = fork(); + unless ($notifypid) { + $SIG{TERM} = sub { POSIX::_exit(0) }; + + POSIX::close( $_ ) for 3 .. 1024; ## Arbitrary upper bound + + # child; + $0 = "cassandane notifyd: $basedir"; + notifyd("$basedir/run"); + POSIX::_exit(0); + } + + xlog "started notifyd for $basedir as $notifypid"; + push @{$self->{_shutdowncallbacks}}, sub { + local *__ANON__ = "kill_notifyd"; + my $self = shift; + xlog "killing notifyd $notifypid"; + kill(15, $notifypid); + waitpid($notifypid, 0); + }; +} + +# +# Create a user, with a home folder +# +# Argument 'user' may be of the form 'user' or 'user@domain'. +# Following that are optional named parameters +# +# subdirs array of strings, lists folders +# to be created, relative to the new +# home folder +# +# Returns void, or dies if something went wrong +# +sub create_user +{ + my ($self, $user, %params) = @_; + + my $mb = Cassandane::Mboxname->new(config => $self->{config}, username => $user); + + xlog "create user $user"; + + my $srv = $self->get_service('imap'); + return + unless defined $srv; + + my $adminstore = $srv->create_store(username => 'admin'); + my $adminclient = $adminstore->get_client(); + + my @mboxes = ( $mb->to_external() ); + map { push(@mboxes, $mb->make_child($_)->to_external()); } @{$params{subdirs}} + if ($params{subdirs}); + + foreach my $mb (@mboxes) + { + $adminclient->create($mb) + or die "Cannot create $mb: $@"; + $adminclient->setacl($mb, admin => 'lrswipkxtecdan') + or die "Cannot setacl for $mb: $@"; + $adminclient->setacl($mb, $user => 'lrswipkxtecdn') + or die "Cannot setacl for $mb: $@"; + $adminclient->setacl($mb, anyone => 'p') + or die "Cannot setacl for $mb: $@"; + } +} + +sub set_smtpd { + my ($self, $data) = @_; + my $basedir = $self->{basedir}; + if ($data) { + open(FH, ">$basedir/conf/smtpd.json"); + print FH encode_json($data); + close(FH); + } + else { + unlink("$basedir/conf/smtpd.json"); + } +} + +sub _start_smtpd +{ + my ($self) = @_; + + return if not $self->{smtpdaemon}; + + my $smtp_host = $self->{config}->get('smtp_host'); + die "smtp_host requested but not configured" + if not $smtp_host or $smtp_host eq 'bogus:0'; + + my ($host, $port) = split /:/, $smtp_host; + + my $smtppid = $self->run_command({ + cyrus => 0, + background => 1, + }, + abs_path('utils/fakesmtpd'), + '-h', $host, + '-p', $port, + ); + + # give the child a moment to actually start up + sleep 1; + + # and then make sure it did! + my $waitstatus = waitpid($smtppid, WNOHANG); + if ($waitstatus == 0) { + xlog "started fakesmtpd as $smtppid"; + push @{$self->{_shutdowncallbacks}}, sub { + local *__ANON__ = "kill_smtpd"; + my $self = shift; + xlog "killing fakesmtpd $smtppid"; + kill(15, $smtppid); + $self->reap_command($smtppid); + }; + } + else { + # child process already exited, something has gone wrong + Cassandane::PortManager::free($port); + die "fakesmtpd with pid=$smtppid failed to start"; + } +} + +sub start_httpd { + my ($self, $handler, $port) = @_; + + my $basedir = $self->{basedir}; + + my $host = 'localhost'; + $port ||= Cassandane::PortManager::alloc($host); + + my $httpdpid = fork(); + unless ($httpdpid) { + # Child process. + # XXX This child still has the whole test's process space + # XXX still mapped, and when it exits, all our destructors + # XXX will be called, leaving the test in who knows what + # XXX state... + $SIG{TERM} = sub { exit 0; }; + + POSIX::close( $_ ) for 3 .. 1024; ## Arbitrary upper bound + + $0 = "cassandane httpd: $basedir"; + + my $httpd = HTTP::Daemon->new( + LocalAddr => $host, + LocalPort => $port, + ReuseAddr => 1, # Reuse ports left in TIME_WAIT + ) || die; + while (my $conn = $httpd->accept) { + while (my $req = $conn->get_request) { + $handler->($conn, $req); + } + $conn->close; + undef($conn); + } + + exit 0; # Never reached + } + + # Parent process. + $self->{httpdhost} = $host . ':' . $port; + + xlog "started httpd as $httpdpid"; + push @{$self->{_shutdowncallbacks}}, sub { + local *__ANON__ = "kill_httpd"; + my $self = shift; + xlog "killing httpd $httpdpid"; + kill(15, $httpdpid); + waitpid($httpdpid, 0); + }; + + return $port; +} + +sub start +{ + my ($self) = @_; + + my $created = 0; + + $self->_init_basedir_and_name(); + xlog "start $self->{description}: basedir $self->{basedir}"; + + # arrange for fakesmtpd to be started by Cassandane if we need it + # XXX should make it a Cyrus waitdaemon instead like fakesaslauthd + if ($self->{smtpdaemon}) { + my ($maj, $min) = + Cassandane::Instance->get_version($self->{installation}); + + if ($maj > 3 || ($maj == 3 && $min >= 1)) { + my $host = 'localhost'; + my $port = Cassandane::PortManager::alloc($host); + + $self->{config}->set( + smtp_host => "$host:$port", + ); + } + else { + die "smtpdaemon requested but Cyrus $maj.$min is too old"; + } + } + + # arrange for fakesaslauthd to be started by master + my $fakesaslauthd_socket = "$self->{basedir}/run/mux"; + my $fakesaslauthd_isdaemon = 1; + if ($self->{authdaemon}) { + my ($maj, $min) = Cassandane::Instance->get_version( + $self->{installation}); + if ($maj < 3 || ($maj == 3 && $min < 4)) { + $self->add_start( + name => 'fakesaslauthd', + argv => [ + abs_path('utils/fakesaslauthd'), + '-p', $fakesaslauthd_socket, + ], + ); + $fakesaslauthd_isdaemon = 0; + } + elsif (not exists $self->{daemons}->{fakesaslauthd}) { + $self->add_daemon( + name => 'fakesaslauthd', + argv => [ + abs_path('utils/fakesaslauthd'), + '-p', $fakesaslauthd_socket, + ], + wait => 'y', + ); + } + } + + $self->{buildinfo} = Cassandane::BuildInfo->new($self->{cyrus_destdir}, + $self->{cyrus_prefix}); + + if (!$self->{re_use_dir} || ! -d $self->{basedir}) + { + $created = 1; + rmtree $self->{basedir}; + $self->_build_skeleton(); + # TODO: system("echo 1 >/proc/sys/kernel/core_uses_pid"); + # TODO: system("echo 1 >/proc/sys/fs/suid_dumpable"); + + # the main imapd.conf + $self->_generate_imapd_conf($self->{config}); + + # individual prefix-imapd.conf for master entries that want one + foreach my $me (values %{$self->{services}}, + values %{$self->{daemons}}, + @{$self->{starts}}, + @{$self->{events}}) + { + if ($me->{config}) { + $self->_generate_imapd_conf($me->{config}, $me->{name}); + } + } + + $self->_generate_master_conf(); + $self->install_certificates() if $self->{install_certificates}; + $self->_fix_ownership(); + } + elsif (!scalar $self->{services}) + { + $self->_add_services_from_cyrus_conf(); + # XXX START, EVENTS, DAEMON entries will be missed here if reusing + # XXX the directory. Does it matter? Maybe not, since the master + # XXX conf already contains them, so they'll still run, just + # XXX cassandane won't know about it. + } + $self->setup_syslog_replacement(); + $self->_start_smtpd() if $self->{smtpdaemon}; + $self->_start_notifyd(); + $self->_uncompress_berkeley_crud(); + $self->_start_master(); + $self->{_stopped} = 0; + $self->{_started} = 1; + + # give fakesaslauthd a moment (but not more than 2s) to set up its + # socket before anything starts trying to connect to services + if ($self->{authdaemon} && !$fakesaslauthd_isdaemon) { + my $tries = 0; + while (not -S $fakesaslauthd_socket && $tries < 2_000_000) { + $tries += usleep(10_000); # 10ms as us + } + die "fakesaslauthd socket $fakesaslauthd_socket not ready after 2s!" + if not -S $fakesaslauthd_socket; + } + + if ($created && $self->{setup_mailbox}) + { + $self->create_user("cassandane"); + } + + xlog "started $self->{description}: cyrus version " + . Cassandane::Instance->get_version($self->{installation}); +} + +sub _compress_berkeley_crud +{ + my ($self) = @_; + + my @files; + my $dbdir = $self->{basedir} . "/conf/db"; + if ( -d $dbdir ) + { + opendir DBDIR, $dbdir + or return "Cannot open directory $dbdir: $!"; + while (my $e = readdir DBDIR) + { + push(@files, "$dbdir/$e") + if ($e =~ m/^__db\.\d+$/); + } + closedir DBDIR; + } + + if (scalar @files) + { + xlog "Compressing Berkeley environment files: " . join(' ', @files); + system('/bin/bzip2', @files); + } + + return; +} + +sub _uncompress_berkeley_crud +{ + my ($self) = @_; + + my @files; + my $dbdir = $self->{basedir} . "/conf/db"; + if ( -d $dbdir ) + { + opendir DBDIR, $dbdir + or die "Cannot open directory $dbdir: $!"; + while (my $e = readdir DBDIR) + { + push(@files, "$dbdir/$e") + if ($e =~ m/^__db\.\d+\.bz2$/); + } + closedir DBDIR; + } + + if (scalar @files) + { + xlog "Uncompressing Berkeley environment files: " . join(' ', @files); + system('/bin/bunzip2', @files); + } +} + +sub _check_valgrind_logs +{ + my ($self) = @_; + + return unless Cassandane::Cassini->instance()->bool_val('valgrind', 'enabled'); + + my $valgrind_logdir = $self->{basedir} . '/vglogs'; + + return unless -d $valgrind_logdir; + opendir VGLOGS, $valgrind_logdir + or return "Cannot open directory $valgrind_logdir for reading: $!"; + + my @nzlogs; + while ($_ = readdir VGLOGS) + { + next if m/^\./; + next if m/\.core\./; + my $log = "$valgrind_logdir/$_"; + next if -z $log; + push(@nzlogs, $_); + + if (open VG, "<$log") { + xlog "Valgrind errors from file $log"; + while () { + chomp; + xlog "$_"; + } + close VG; + } + else { + xlog "Cannot open Valgrind log $log for reading: $!"; + } + + } + closedir VGLOGS; + + return "Found Valgrind errors, see log for details" + if scalar @nzlogs; + + return; +} + +# The 'file' program seems to consistently misreport cores +# so we apply a heuristic that seems to work +sub _detect_core_program +{ + my ($core) = @_; + my $lines = 0; + my $prog; + + my $bindir_pattern = qr{ + \/ + (?:bin|sbin|libexec) + \/ + }x; + + open STRINGS, '-|', ('strings', '-a', $core) + or die "Cannot run strings on $core: $!"; + while () + { + chomp; + if (m/$bindir_pattern/) + { + $prog = $_; + last; + } + $lines++; + last if ($lines > 10); + } + close STRINGS; + + return $prog; +} + +sub find_cores +{ + my ($self) = @_; + my $coredir = $self->{basedir} . '/conf/cores'; + + my $cassini = Cassandane::Cassini->instance(); + my $core_pattern = $cassini->get_core_pattern(); + + my @cores; + + return unless -d $coredir; + opendir CORES, $coredir + or return "Cannot open directory $coredir for reading: $!"; + while ($_ = readdir CORES) + { + next if m/^\./; + next unless m/$core_pattern/; + my $core = "$coredir/$_"; + next if -z $core; + chmod(0644, $core); + push @cores, $core; + + my $prog = _detect_core_program($core); + + xlog "Found core file $core"; + if (defined $prog) { + xlog " from program $prog"; + my ($bin) = $prog =~ m/^(\S+)/; # binary only + xlog " debug: sudo gdb $bin $core"; + } + } + closedir CORES; + + return @cores; +} + +sub _check_cores +{ + my ($self) = @_; + my $coredir = $self->{basedir} . '/conf/cores'; + + return "Core files found in $coredir" if scalar $self->find_cores(); +} + +sub _check_mupdate +{ + my ($self) = @_; + + my $mupdate_server = $self->{config}->get('mupdate_server'); + return if not $mupdate_server; # not in a murder + + my $serverlist = $self->{config}->get('serverlist'); + return if $serverlist; # don't sync mboxlist on frontends + + # Run ctl_mboxlist -m to sync backend mailboxes with mupdate. + # + # You typically run this from START, and we do, but at test start + # there's no mailboxes yet, so there's nothing to sync, and if + # something is broken it probably won't be detected. + my $basedir = $self->{basedir}; + eval { + $self->run_command({ + redirects => { stdout => "$basedir/ctl_mboxlist.out", + stderr => "$basedir/ctl_mboxlist.err", + }, + cyrus => 1, + }, 'ctl_mboxlist', '-m'); + }; + if ($@) { + my @err = slurp_file("$basedir/ctl_mboxlist.err"); + chomp for @err; + xlog "ctl_mboxlist -m failed: " . Dumper \@err; + return "unable to sync local mailboxes with mupdate"; + } +} + +sub _check_sanity +{ + my ($self) = @_; + + # We added this check during 3.5 development... older versions + # probably fail these checks. If we backport fixes we can decrement + # this version check. + my ($maj, $min) = Cassandane::Instance->get_version($self->{installation}); + if ($maj < 3 || ($maj == 3 && $min < 5)) { + return; + } + + my $basedir = $self->{basedir}; + my $found = 0; + eval { + $self->run_command({redirects => {stdout => "$basedir/quota.out", stderr => "$basedir/quota.err"}, cyrus => 1}, 'quota', '-f', '-q'); + }; + if ($@) { + xlog "quota -f failed, $@"; + $found = 1; + } + eval { + $self->run_command({redirects => {stdout => "$basedir/reconstruct.out", stderr => "$basedir/reconstruct.err"}, cyrus => 1}, 'reconstruct', '-q', '-G'); + }; + if ($@) { + xlog "reconstruct failed, $@"; + $found = 1; + } + for my $file ("quota.out", "quota.err", "reconstruct.out", "reconstruct.err") { + next unless open(FH, "<$basedir/$file"); + while () { + next unless $_; + $found = 1; + xlog "INCONSISTENCY FOUND: $file $_"; + } + } + + return "INCONSISTENCIES FOUND IN SPOOL" if $found; + + return; +} + +sub _check_syslog +{ + my ($self, $pattern) = @_; + + if (defined $pattern) { + # pattern is optional but must be a regex if present + die "getsyslog: pattern is not a regular expression" + if lc ref($pattern) ne 'regexp'; + } + + my @lines = $self->getsyslog(); + my @errors = grep { + m/ERROR|TRACELOG|Unknown code ____/ || ($pattern && m/$pattern/) + } @lines; + + @errors = grep { not m/DBERROR.*skipstamp/ } @errors; + + $self->xlog("syslog error: $_") for @errors; + + return "Errors found in syslog" if @errors; + + return; +} + +# Stop a given PID. Returns 1 if the process died +# gracefully (i.e. soon after receiving SIGTERM) +# or wasn't even running beforehand. +sub _stop_pid +{ + my ($pid, $reaper) = @_; + + # Try to be nice, but leave open the option of not being nice should + # that be necessary. The signals we send are: + # + # SIGTERM - The standard Cyrus graceful shutdown signal, should + # be handled and propagated by master. + # SIGILL - Not handled by master; kernel's default action is to + # dump a core. We use this to try to get a core when + # something is wrong with master. + # SIGKILL - Hmm, something went wrong with our cunning SIGILL plan, + # let's take off and nuke it from orbit. We just don't + # want to leave processes around cluttering up the place. + # + my @sigs = ( SIGTERM, SIGILL, SIGKILL ); + my %signame = ( + SIGTERM, "TERM", + SIGILL, "ILL", + SIGKILL, "KILL", + ); + my $r = 1; + + foreach my $sig (@sigs) + { + xlog "_stop_pid: sending signal $signame{$sig} to $pid"; + kill($sig, $pid) or xlog "Can't send signal $signame{$sig} to pid $pid: $!"; + eval { + timed_wait(sub { + eval { $reaper->() if (defined $reaper) }; + return (kill(0, $pid) == 0); + }); + }; + last unless $@; + # Timed out -- No More Mr Nice Guy + xlog "_stop_pid: failed to shut down pid $pid with signal $signame{$sig}"; + $r = 0; + } + return $r; +} + +sub send_sighup +{ + my ($self) = @_; + + return if (!$self->{_started}); + return if ($self->{_stopped}); + xlog "sighup"; + + my $pid = $self->_read_pid_file('master') or return; + kill(SIGHUP, $pid) or die "Can't send signal SIGHUP to pid $pid: $!"; + return 1; +} + +# +# n.b. If you are stopping the instance intending to restart it again later, +# you must set: +# $instance->{'re_use_dir'} => 1 +# before restarting, otherwise it will wipe and re-initialise its basedir +# during startup, probably ruining whatever you were trying to do. It +# will still use the same directory name though, so it won't be obvious +# from the logs that this is happening! +# +sub stop +{ + my ($self, %params) = @_; + + $self->_init_basedir_and_name(); + + return if ($self->{_stopped} || !$self->{_started}); + $self->{_stopped} = 1; + + my @errors; + + push @errors, $self->_check_sanity(); + push @errors, $self->_check_mupdate(); + + xlog "stop $self->{description}: basedir $self->{basedir}"; + + foreach my $name ($self->_list_pid_files()) + { + my $pid = $self->_read_pid_file($name); + next if (!defined $pid); + _stop_pid($pid) + or push @errors, "Cannot shut down $name pid $pid"; + } + # Note: no need to reap this daemon which is not our child anymore + + foreach my $item (@{$self->{_shutdowncallbacks}}) { + eval { + $item->($self); + }; + if ($@) { + push @errors, "some shutdown callback died: $@"; + } + } + $self->{_shutdowncallbacks} = []; + + # n.b. still need this for testing 2.5 + push @errors, $self->_compress_berkeley_crud(); + + push @errors, $self->_check_valgrind_logs(); + push @errors, $self->_check_cores(); + push @errors, $self->_check_syslog() unless $params{no_check_syslog}; + + # filter out empty errors (shouldn't be any, but just in case) + @errors = grep { $_ } @errors; + + foreach my $e (@errors) { + xlog "$self->{description}: $e"; + } + + return @errors; +} + +sub DESTROY +{ + my ($self) = @_; + + if ($$ != $self->{_pid}) { + xlog "ignoring DESTROY from bad caller: $$"; + return; + } + + if (defined $self->{basedir} && + !$self->{persistent} && + !$self->{_stopped}) + { + # clean up any dangling master and daemon process + foreach my $name ($self->_list_pid_files()) + { + my $pid = $self->_read_pid_file($name); + next if (!defined $pid); + _stop_pid($pid); + } + + foreach my $item (@{$self->{_shutdowncallbacks}}) { + $item->($self); + } + $self->{_shutdowncallbacks} = []; + } +} + +sub is_running +{ + my ($self) = @_; + + my $pid = $self->_read_pid_file(); + return 0 unless defined $pid; + return kill(0, $pid); +} + +sub _setup_for_deliver +{ + my ($self) = @_; + + $self->add_service(name => 'lmtp', + argv => ['lmtpd', '-a'], + port => '@basedir@/conf/socket/lmtp'); +} + +sub deliver +{ + my ($self, $msg, %params) = @_; + my $str = $msg->as_string(); + my @cmd = ( 'deliver' ); + + my $folder = $params{folder}; + if (defined $folder) + { + $folder =~ s/^inbox.//i; + push(@cmd, '-m', $folder); + } + + my @users; + if (defined $params{users}) + { + push(@users, @{$params{users}}); + } + elsif (defined $params{user}) + { + push(@users, $params{user}) + } + else + { + push(@users, 'cassandane'); + } + push(@cmd, @users); + + my $ret = 0; + + $self->run_command({ + cyrus => 1, + redirects => { + stdin => \$str + }, + handlers => { + exited_abnormally => sub { (undef, $ret) = @_; }, + }, + }, @cmd); + + return $ret; +} + +# Runs a command with the given arguments. The first argument is an +# options hash: +# +# background whether to start the command in the background; you need +# to give returned arguments to reap_command afterwards +# +# cyrus whether it is a cyrus utility; if so, instance path is +# automatically prepended to the given command name +# +# handlers hash of coderefs to be called when various events +# are detected. Default is to 'die' on any event +# except exiting with code 0. The events are: +# +# exited_normally($child) +# exited_abnormally($child, $code) +# signaled($child, $sig) +# +# redirects hash for I/O redirections +# stdin feed stdin from; handles SCALAR data or filename, +# /dev/null by default +# stdout feed stdout to; /dev/null by default (or is unmolested +# if xlog is in verbose mode) +# stderr feed stderr to; /dev/null by default (or is unmolested +# if xlog is in verbose mode) +# +# workingdir path to launch the command from +# +sub run_command +{ + my ($self, @args) = @_; + + my $options = {}; + if (ref($args[0]) eq 'HASH') { + $options = shift(@args); + } + + my ($pid, $got_exit) = $self->_fork_command($options, @args); + + return $pid + if ($options->{background}); + + if (defined $got_exit) { + # Child already reaped, pass it on + $? = $got_exit; + + return $self->_handle_wait_status($pid); + } + + return $self->reap_command($pid); +} + +sub reap_command +{ + my ($self, $pid) = @_; + + # parent process...wait for child + my $child = waitpid($pid, 0); + # and deal with it's exit status + return $self->_handle_wait_status($pid) + if $child == $pid; + return undef; +} + +# returns the command's exit status, or -1 if something went wrong +sub stop_command +{ + my ($self, $pid) = @_; + my $child; + + # it's our child, so we must reap it, otherwise it'll never + # completely exit. but if it ignores the first sigterm, a normal + # waitpid will block forever, so we need to be WNOHANG here + my $r = _stop_pid($pid, sub { $child = waitpid($pid, WNOHANG); }); + return -1 if $r != 1; + + if ($child == $pid) { + return $self->_handle_wait_status($pid) + } + else { + return -1; + } +} + +my %default_command_handlers = ( + signaled => sub + { + my ($child, $sig) = @_; + my $desc = _describe_child($child); + die "child process $desc terminated by signal $sig"; + }, + exited_normally => sub + { + my ($child) = @_; + return 0; + }, + exited_abnormally => sub + { + my ($child, $code) = @_; + my $desc = _describe_child($child); + die "child process $desc exited with code $code"; + }, +); + +sub _add_child +{ + my ($self, $binary, $pid, $handlers) = @_; + my $key = $pid; + + $handlers ||= \%default_command_handlers; + + my $child = { + binary => $binary, + pid => $pid, + handlers => { %default_command_handlers, %$handlers }, + }; + $self->{_children}->{$key} = $child; + return $child; +} + +sub _describe_child +{ + my ($child) = @_; + return "unknown" unless $child; + return "(binary $child->{binary} pid $child->{pid})"; +} + +sub _cyrus_perl_search_path +{ + my ($self) = @_; + my @inc = ( + substr($Config{installvendorlib}, length($Config{vendorprefix})), + substr($Config{installvendorarch}, length($Config{vendorprefix})), + substr($Config{installsitelib}, length($Config{siteprefix})), + substr($Config{installsitearch}, length($Config{siteprefix})) + ); + return map { $self->{cyrus_destdir} . $self->{cyrus_prefix} . $_; } @inc; +} + +# +# Starts a new process to run a program. +# +# Returns launched $pid; you must call _handle_wait_status() to +# decode $?. Dies on errors. +# +sub _fork_command +{ + my ($self, $options, $binary, @argv) = @_; + + die "No binary specified" + unless defined $binary; + + my %redirects; + if (defined($options->{redirects})) { + %redirects = %{$options->{redirects}}; + } + # stdin is null, stdout is null or unmolested + $redirects{stdin} = '/dev/null' + unless(defined($redirects{stdin})); + $redirects{stdout} = '/dev/null' + unless(get_verbose || defined($redirects{stdout})); + $redirects{stderr} = '/dev/null' + unless(get_verbose || defined($redirects{stderr})); + + my @cmd = (); + if ($options->{cyrus}) + { + push(@cmd, $self->_binary($binary), '-C', $self->_imapd_conf()); + } + elsif ($binary =~ m/xapian.*$/) { + push(@cmd, $self->_binary($binary)); + } + else { + push(@cmd, $binary); + } + push(@cmd, @argv); + + xlog "Running: " . join(' ', map { "\"$_\"" } @cmd); + + if (defined($redirects{stdin}) && (ref($redirects{stdin}) eq 'SCALAR')) + { + my $fh; + my $data = $redirects{stdin}; + $redirects{stdin} = undef; + # Use the fork()ing form of open() + my $pid = open $fh,'|-'; + die "Cannot fork: $!" + if !defined $pid; + if ($pid) + { + xlog "child pid=$pid"; + # parent process + $self->_add_child($binary, $pid, $options->{handlers}); + print $fh ${$data}; + close ($fh); + return ($pid, $?); + } + } + else + { + # No capturing - just plain fork() + my $pid = fork(); + die "Cannot fork: $!" + if !defined $pid; + if ($pid) + { + # parent process + xlog "child pid=$pid"; + $self->_add_child($binary, $pid, $options->{handlers}); + return ($pid, undef); + } + } + + # child process + + my $cassroot = getcwd(); + $ENV{CASSANDANE_CYRUS_DESTDIR} = $self->{cyrus_destdir}; + $ENV{CASSANDANE_CYRUS_PREFIX} = $self->{cyrus_prefix}; + $ENV{CASSANDANE_PREFIX} = $cassroot; + $ENV{CASSANDANE_BASEDIR} = $self->{basedir}; + $ENV{CASSANDANE_VERBOSE} = 1 if get_verbose(); + $ENV{PERL5LIB} = join(':', ($cassroot, $self->_cyrus_perl_search_path())); + if ($self->{have_syslog_replacement}) { + $ENV{CASSANDANE_SYSLOG_FNAME} = abs_path($self->{syslog_fname}); + $ENV{LD_PRELOAD} = abs_path('utils/syslog.so') + } + +# xlog "\$PERL5LIB is"; map { xlog " $_"; } split(/:/, $ENV{PERL5LIB}); + + # Set up the runtime linker path to find the Cyrus shared libraries + # + # TODO: on some platforms we need lib64/ not lib/ but it's not + # entirely clear how to detect that - we could use readelf -d + # on an executable to discover what it thinks it's RPATH ought + # to be, then prepend destdir to that. + if ($self->{cyrus_destdir} ne "") + { + $ENV{LD_LIBRARY_PATH} = join(':', ( + $self->{cyrus_destdir} . $self->{cyrus_prefix} . "/lib", + split(/:/, $ENV{LD_LIBRARY_PATH} || "") + )); + } +# xlog "\$LD_LIBRARY_PATH is"; map { xlog " $_"; } split(/:/, $ENV{LD_LIBRARY_PATH}); + + my $cd = $options->{workingdir}; + $cd = $self->{basedir} . '/conf/cores' + unless defined($cd); + chdir($cd) + or die "Cannot cd to $cd: $!"; + + # ulimit -c ... + my $cassini = Cassandane::Cassini->instance(); + my $coresizelimit = 0 + $cassini->val("cyrus $self->{installation}", + 'coresizelimit', '100'); + if ($coresizelimit <= 0) { + $coresizelimit = RLIM_INFINITY; + } + else { + # convert megabytes to bytes + $coresizelimit *= (1024 * 1024); + } + xlog "setting core size limit to $coresizelimit"; + setrlimit(RLIMIT_CORE, $coresizelimit, $coresizelimit); + + # let's log our rlimits, might be useful for diagnosing weirdnesses + if (get_verbose() >= 4) { + my $limits = get_rlimits(); + foreach my $name (keys %{$limits}) { + $limits->{$name} = [ getrlimit($limits->{$name}) ]; + } + xlog "rlimits: " . Dumper $limits; + } + + # TODO: do any setuid, umask, or environment futzing here + + # implement redirects + if (defined $redirects{stdin}) + { + open STDIN,'<',$redirects{stdin} + or die "Cannot redirect STDIN from $redirects{stdin}: $!"; + } + if (defined $redirects{stdout}) + { + open STDOUT,'>',$redirects{stdout} + or die "Cannot redirect STDOUT to $redirects{stdout}: $!"; + } + if (defined $redirects{stderr}) + { + open STDERR,'>',$redirects{stderr} + or die "Cannot redirect STDERR to $redirects{stderr}: $!"; + } + + exec @cmd; + die "Cannot run $binary: $!"; +} + +sub _handle_wait_status +{ + my ($self, $key) = @_; + my $status = $?; + + my $child = delete $self->{_children}->{$key}; + + if (WIFSIGNALED($status)) + { + my $sig = WTERMSIG($status); + return $child->{handlers}->{signaled}->($child, $sig); + } + elsif (WIFEXITED($status)) + { + my $code = WEXITSTATUS($status); + return $child->{handlers}->{exited_abnormally}->($child, $code) + if $code != 0; + } + else + { + die "WTF? Cannot decode wait status $status"; + } + return $child->{handlers}->{exited_normally}->($child); +} + +sub describe +{ + my ($self) = @_; + + print "Cyrus instance\n"; + printf " name: %s\n", $self->{name}; + printf " imapd.conf: %s\n", $self->_imapd_conf(); + printf " services:\n"; + foreach my $srv (values %{$self->{services}}) + { + printf " "; + $srv->describe(); + } + printf " generic listeners:\n"; + foreach my $listener (values %{$self->{generic_listeners}}) + { + printf " "; + $listener->describe(); + } +} + +sub _quota_Z_file +{ + my ($self, $mboxname) = @_; + return $self->{basedir} . '/conf/quota-sync/' . $mboxname; +} + +sub quota_Z_go +{ + my ($self, $mboxname) = @_; + my $filename = $self->_quota_Z_file($mboxname); + + xlog "Allowing quota -Z to proceed for $mboxname"; + + my $dir = dirname($filename); + mkpath $dir + unless ( -d $dir ); + + my $fd = POSIX::creat($filename, 0600); + POSIX::close($fd); +} + +sub quota_Z_wait +{ + my ($self, $mboxname) = @_; + my $filename = $self->_quota_Z_file($mboxname); + + timed_wait(sub { return (! -f $filename); }, + description => "quota -Z to be finished with $mboxname"); +} + +# +# Unpacks file. Handles tar, gz, and bz2. +# +sub unpackfile +{ + my ($self, $src, $dst) = @_; + + if (!defined($dst)) { + # unpack in base directory + $dst = $self->{basedir}; + } + elsif ($dst !~ /^\//) { + # unpack relatively to base directory + $dst = $self->{basedir} . '/' . $dst; + } + # else: absolute path given + + my $options = {}; + my @cmd = (); + + my $file = [split(/\./, (split(/\//, $src))[-1])]; + if (grep { $_ eq 'tar' } @$file) { + push(@cmd, 'tar', '-x', '-f', $src, '-C', $dst); + } + elsif ($file->[-1] eq 'gz') { + $options->{redirects} = { + stdout => "$dst/" . join('.', splice(@$file, 0, -1)) + }; + push(@cmd, 'gunzip', '-c', $src); + } + elsif ($file->[-1] eq 'bz2') { + $options->{redirects} = { + stdout => "$dst/" . join('.', splice(@$file, 0, -1)) + }; + push(@cmd, 'bunzip2', '-c', $src); + } + else { + # we don't handle this combination + die "Unhandled packed file $src"; + } + + return $self->run_command($options, @cmd); +} + +sub folder_to_directory +{ + my ($self, $folder) = @_; + + $folder =~ s/^inbox\./user.cassandane./i; + $folder =~ s/^inbox$/user.cassandane/i; + + my $data = eval { $self->run_mbpath($folder) }; + return unless $data; + my $dir = $data->{data}; + return undef unless -d $dir; + return $dir; +} + +sub folder_to_deleted_directories +{ + my ($self, $folder) = @_; + + $folder =~ s/^inbox\./user.cassandane./i; + $folder =~ s/^inbox$/user.cassandane/i; + + # ideally we'd have a command-line way to do this, but imap works too + my $srv = $self->get_service('imap'); + my $adminstore = $srv->create_store(username => 'admin'); + my $adminclient = $adminstore->get_client(); + my @folders = $adminclient->list('', "DELETED.$folder.%"); + + my @res; + for my $item (@folders) { + next if grep { lc $_ eq '\\noselect' } @{$item->[0]}; + my $mailbox = $item->[2]; + my $data = eval { $self->run_mbpath($mailbox) }; + my $dir = $data->{data}; + next unless -d $dir; + push @res, $dir; + } + + return @res; +} + +sub notifyd +{ + my $dir = shift; + + $0 = "cassandane notifyd $dir"; + + my @EVENTS; + tcp_server("unix/", "$dir/notify", sub { + my $fh = shift; + my $Handle = AnyEvent::Handle->new( + fh => $fh, + ); + $Handle->push_read('Cyrus::DList' => 1, sub { + my $dlist = $_[1]; + my $event = $dlist->as_perl(); + #xlog "GOT EVENT: " . encode_json($event); + push @EVENTS, $event; + $Handle->push_write('Cyrus::DList' => scalar(Cyrus::DList->new_kvlist("OK")), 1); + $Handle->push_shutdown(); + }); + }); + + tcp_server("unix/", "$dir/getnotify", sub { + my $fh = shift; + my $Handle = AnyEvent::Handle->new( + fh => $fh, + ); + #xlog "REPLYING EVENTS: " . scalar(@EVENTS); + $Handle->push_write(json => \@EVENTS); + $Handle->push_shutdown(); + @EVENTS = (); + }); + + my $cv = AnyEvent->condvar(); + + $SIG{TERM} = sub { $cv->send() }; + + $cv->recv(); +} + +sub getnotify +{ + my ($self) = @_; + + my $basedir = $self->{basedir}; + my $path = "$basedir/run/getnotify"; + + my $data = eval { + my $sock = IO::Socket::UNIX->new( + Type => SOCK_STREAM(), + Peer => $path, + ) || die "Connection failed $!"; + my $line = $sock->getline(); + my $json = decode_json($line); + if (get_verbose) { + use Data::Dumper; + warn "NOTIFY " . Dumper($json); + } + return $json; + }; + if ($@) { + my $data = `ls -la $basedir/run; whoami; lsof -n | grep notify`; + xlog "Failed $@ ($data)"; + } + + return $data; +} + +sub setup_syslog_replacement +{ + my ($self) = @_; + + if (not(-e 'utils/syslog.so') || not(-e 'utils/syslog_probe')) { + xlog "utils/syslog.so not found (do you need to run 'make'?)"; + xlog "tests will not examine syslog output"; + $self->{have_syslog_replacement} = 0; + return; + } + + # Can't reliably replace syslog when source fortification is in play, + # and syslog_probe can't reliably detect whether the replacement has + # worked or not in this case, so just turn syslog replacement off if + # we detect source fortification + if ($self->{buildinfo}->get('version', 'FORTIFY_LEVEL')) { + xlog "Cyrus was built with -D_FORTIFY_SOURCE"; + xlog "tests will not examine syslog output"; + $self->{have_syslog_replacement} = 0; + return; + } + + $self->{syslog_fname} = "$self->{basedir}/conf/log/syslog"; + $self->{have_syslog_replacement} = 1; + + # if the syslog file already exists, remember how large it is + # so we can seek past existing content without missing the + # startup content! + my $syslog_start = 0; + $syslog_start = -s $self->{syslog_fname} if -e $self->{syslog_fname}; + + # check that we can syslog a message and find it again + my $syslog_probe = abs_path('utils/syslog_probe'); + $self->run_command($syslog_probe, $self->{name}); + + $self->{_syslogfh} = IO::File->new($self->{syslog_fname}, 'r'); + + if ($self->{_syslogfh}) { + $self->{_syslogfh}->seek($syslog_start, 0); + $self->{_syslogfh}->blocking(0); + + if (not scalar $self->getsyslog(qr/\bthe magic word\b/)) { + xlog "didn't find the magic word when probing syslog"; + xlog "tests will not examine syslog output"; + + $self->{have_syslog_replacement} = 0; + undef $self->{_syslogfh}; + } + } + else { + xlog "couldn't read $self->{syslog_fname} when probing syslog"; + xlog "tests will not examine syslog output"; + + $self->{have_syslog_replacement} = 0; + } +} + +# n.b. This only gives you syslog lines if we were able to successfully +# inject our syslog replacement. +# If you need to make sure an error WASN'T logged, it'll do approximately +# the right thing. +# But if you need to make sure an error WAS logged, first make sure that +# $instance->{have_syslog_replacement} is true, otherwise you will always +# fail on systems where the syslog replacement doesn't work. +sub getsyslog +{ + my ($self, $pattern) = @_; + + if (defined $pattern) { + # pattern is optional but must be a regex if present + die "getsyslog: pattern is not a regular expression" + if lc ref($pattern) ne 'regexp'; + } + my $logname = $self->{name}; + my @lines; + + if ($self->{have_syslog_replacement} && $self->{_syslogfh}) { + # https://github.com/Perl/perl5/issues/21240 + # eof status is no longer cleared automatically in newer perls + if ($self->{_syslogfh}->eof()) { + $self->{_syslogfh}->clearerr(); + } + if ($self->{_syslogfh}->error()) { + die "error reading $self->{syslog_fname}"; + } + + # hopefully unobtrusively, let busy log finish writing + usleep(100_000); # 100ms (0.1s) as us + @lines = grep { m/$logname/ } $self->{_syslogfh}->getlines(); + + if (defined $pattern) { + @lines = grep { m/$pattern/ } @lines; + } + + chomp for @lines; + } + + return @lines; +} + +sub _get_sqldb +{ + my $dbfile = shift; + my $dbh = DBI->connect("dbi:SQLite:$dbfile", undef, undef); + my @tables = map { s/"//gs; s/^main\.//; $_ } $dbh->tables(); + my %res; + foreach my $table (@tables) { + $res{$table} = $dbh->selectall_arrayref("SELECT * FROM $table", { Slice => {} }); + } + return \%res; +} + +sub getalarmdb +{ + my $self = shift; + my $file = "$self->{basedir}/conf/caldav_alarm.sqlite3"; + return [] unless -e $file; + my $data = _get_sqldb($file); + return $data->{events} || die "NO EVENTS IN CALDAV ALARM DB"; +} + +sub getdavdb +{ + my $self = shift; + my $user = shift; + my $file = $self->get_conf_user_file($user, 'dav'); + return unless -e $file; + return _get_sqldb($file); +} + +sub get_sieve_script_dir +{ + my ($self, $cyrusname) = @_; + + if ($cyrusname) { + my $data = eval { $self->run_mbpath('-u', $cyrusname) }; + return $data->{user}{sieve} if $data; + } + + $cyrusname //= ''; + + my $sieved = "$self->{basedir}/conf/sieve"; + + my ($user, $domain) = split '@', $cyrusname; + + if ($domain) { + my $dhash = substr($domain, 0, 1); + $sieved .= "/domain/$dhash/$domain"; + } + + if ($user ne '') + { + my $uhash = substr($user, 0, 1); + $sieved .= "/$uhash/$user/"; + } + else + { + # shared folder + $sieved .= '/global/'; + } + + return $sieved; +} + +sub get_conf_user_file +{ + my ($self, $cyrusname, $ext) = @_; + + my $data = eval { $self->run_mbpath('-u', $cyrusname) }; + return $data->{user}{$ext} if $data; +} + +sub install_sieve_script +{ + my ($self, $script, %params) = @_; + + my $user = (exists $params{username} ? $params{username} : 'cassandane'); + my $name = $params{name} || 'test1'; + my $sieved = $self->get_sieve_script_dir($user); + + xlog "Installing sieve script $name in $sieved"; + + -d $sieved or mkpath $sieved + or die "Cannot make path $sieved: $!"; + die "Path does not exist: $sieved" if not -d $sieved; + + open(FH, '>', "$sieved/$name.script") + or die "Cannot open $sieved/$name.script for writing: $!"; + print FH $script; + close(FH); + + $self->run_command({ cyrus => 1 }, + "sievec", + "$sieved/$name.script", + "$sieved/$name.bc"); + die "File does not exist: $sieved/$name.bc" if not -f "$sieved/$name.bc"; + + -e "$sieved/defaultbc" || symlink("$name.bc", "$sieved/defaultbc") + or die "Cannot symlink $name.bc to $sieved/defaultbc"; + die "Symlink does not exist: $sieved/defaultbc" if not -l "$sieved/defaultbc"; + + xlog "Sieve script installed successfully"; +} + +sub install_old_mailbox +{ + my ($self, $user, $version) = @_; + + my $data_file = abs_path("data/old-mailboxes/version$version.tar.gz"); + die "Old mailbox data does not exist: $data_file" if not -f $data_file; + + xlog "installing version $version mailbox for user $user"; + + my $dest_dir = "data/user/$user"; + + $self->unpackfile($data_file, $dest_dir); + $self->run_command({ cyrus => 1 }, 'reconstruct', '-f', "user.$user"); + + xlog "installed version $version mailbox for user $user: user.$user.version$version"; + + return "user.$user.version$version"; +} + +sub install_certificates +{ + my ($self) = @_; + + my $cert_file = abs_path("data/certs/cert.pem"); + my $key_file = abs_path("data/certs/key.pem"); + my $cacert_file = abs_path("data/certs/cacert.pem"); + + my $destdir = $self->get_basedir() . "/conf/certs"; + xlog "installing certificate files to $destdir ..."; + foreach my $f ($cert_file, $key_file, $cacert_file) { + copy($f, $destdir) + or die "cannot install $f to $destdir: $!"; + } + + $destdir = $self->get_basedir() . "/conf/certs/http_jwt"; + my $jwt_file = abs_path("data/certs/http_jwt/jwt.pem"); + xlog "installing JSON Web Token key file ..."; + copy($jwt_file, $destdir) + or die "cannot install $jwt_file to $destdir: $!"; +} + +sub get_servername +{ + my ($self) = @_; + + return $self->{config}->get('servername'); +} + +sub run_mbpath +{ + my ($self, @args) = @_; + my ($maj, $min) = Cassandane::Instance->get_version($self->{installation}); + my $basedir = $self->get_basedir(); + if ($maj < 3 || $maj == 3 && $min <= 4) { + my $folder = pop @args; + my $domain = ''; + if ($folder =~ s/\@([^@]+)$//) { + $domain = $1; + } + + # support -u $user, including users with dots + if (@args and $args[0] eq '-u') { + $folder =~ s/\./\^/g; + $folder = "user.$folder"; + } + + # translate to path + $folder =~ s/\./\//g; + my $user = ''; + if ($folder =~ m{user/([^/]+)}) { + $user = $1; + } + + my $dhash = substr($domain, 0, 1); + my $uhash = substr($user, 0, 1); + + my $dotuser = $user; + $dotuser =~ s/\^/\./g; + + # XXX - hashing smarts? + my $upath = ''; + $upath .= "domain/$dhash/$domain/" if $domain; + $upath .= "user/$uhash/$dotuser"; + my $spath = ''; + # fricking sieve, always different + $spath .= "domain/$dhash/$domain/" if $domain; + $spath .= "$uhash/$dotuser"; + my $xpath = ''; + # et tu xapian + $xpath .= "domain/$dhash/$domain/" if $domain; + $xpath .= "$uhash/user/$dotuser"; + + my $res = { + data => "$basedir/data/$folder", + archive => "$basedir/archive/$folder", + meta => "$basedir/meta/$folder", + # skip mbname, we're not using it + user => { + (map { $_ => "$basedir/conf/$upath.$_" } qw(conversations counters dav seen sub xapianactive)), + sieve => "$basedir/conf/sieve/$spath", + }, + xapian => { + t1 => "$basedir/search/$xpath", + t2 => "$basedir/search2/$xpath", + t3 => "$basedir/search3/$xpath", + }, + }; + return $res; + } + + my $filename = "$basedir/cyr_info.out"; + $self->run_command({ + cyrus => 1, + redirects => { + stdout => $filename, + }, + }, 'mbpath', '-j', @args); + + return decode_json(slurp_file($filename)); +} + +sub _mkastring +{ + my $string = shift; + return '{' . length($string) . '+}' . "\r\n" . $string; +} + +sub run_dbcommand_cb +{ + my ($self, $linecb, $dbname, $engine, @items) = @_; + + if (@items > 1) { + unshift @items, ['BEGIN']; + push @items, ['COMMIT']; + } + + my $input = ''; + foreach my $item (@items) { + $input .= $item->[0]; + for (1..2) { + $input .= ' ' . _mkastring($item->[$_]) if defined $item->[$_]; + } + $input .= "\r\n"; + } + + my $basedir = $self->{basedir}; + my $res = $self->run_command({ + redirects => { + stdin => \$input, + stdout => "$basedir/run_dbcommand.out", + }, + cyrus => 1, + handlers => { + exited_normally => sub { return 'ok'; }, + exited_abnormally => sub { return 'failure'; }, + }, + }, 'cyr_dbtool', $dbname, $engine, 'batch'); + return $res unless $res eq 'ok'; + + my $needbytes = 0; + my $buf = ''; + + # The output of `cyr_dbtool` is in theory one logical line at a time. + # However each logical line can have IMAP literals in them. In that + # case, you get a real line that ends with "{nbytes+}\r\n" and you then + # have to read that many bytes of data (including possibly \r's and + # \n's as well). This function potentially reads multiple real lines + # in $line to gather up a single logical line in $buf, and then parses + # that. + # It could be made simpler and more efficient by tokenising the line as + # it goes, but it was extracted from an original codebase which processed + # the entire response buffer from `cyr_dbtool` as a single giant string. + open(FH, "<$basedir/run_dbcommand.out"); + LINE: while (defined(my $line = )) { + $buf .= $line; + + # inside a literal, that's all we need + if ($needbytes) { + my $len = length($line); + if ($len <= $needbytes) { + $needbytes -= $len; + next LINE; + } + substr($line, 0, $needbytes, ''); + $needbytes = 0; + } + + # does this line include a literal, process it now + if ($line =~ m/\{(\d+)\+?\}\r?\n$/s) { + $needbytes = $1; + next LINE; + } + + # we have a line! + + my @array; + my $pos = 0; + my $length = length($buf); + while ($pos < $length) { + my $chr = substr($buf, $pos, 1); + + if ($chr eq ' ') { + $pos++; + next; + } + + if ($chr eq "\n") { + $pos++; + next; + } + + if ($chr eq '{') { + my $end = index($buf, '}', $pos); + die "Missing }" if $end < 0; + my $len = substr($buf, $pos + 1, $end - $pos - 1); + $len =~ s/\+//; + $pos = $end+1; + my $chr = substr($buf, $pos++, 1); + $chr = substr($buf, $pos++, 1) if $chr eq "\r"; + die "BOGUS LITERAL" unless $chr eq "\n"; + push @array, substr($buf, $pos, $len); + $pos += $len; + next; + } + + if ($chr eq '"') { + my $end = index($buf, '"', $pos+1); + die "Missing quote" if $end < 0; + push @array, substr($buf, $pos + 1, $end - $pos - 1); + $pos = $end + 1; + next; + } + + my $space = index($buf, ' ', $pos); + my $endline = index($buf, "\n", $pos); + + if ($space < 0) { + push @array, substr($buf, $pos, $endline - $pos); + $pos = $endline; + next; + } + + if ($endline < 0) { + push @array, substr($buf, $pos, $space - $pos); + $pos = $space; + next; + } + + if ($endline < $space) { + push @array, substr($buf, $pos, $endline - $pos); + $pos = $endline; + next; + } + + if ($space < $endline) { + push @array, substr($buf, $pos, $space - $pos); + $pos = $space; + next; + } + + die "shouldn't get here"; + } + + $linecb->(@array); + + $buf = ''; + } + close(FH); + + return 'ok'; +} + +sub run_dbcommand +{ + my ($self, $dbname, $engine, @items) = @_; + my @array; + $self->run_dbcommand_cb(sub { push @array, @_ }, $dbname, $engine, @items); + return @array; +} + +sub read_mailboxes_db +{ + my ($self, $params) = @_; + + # run ctl_mboxlist -d to dump mailboxes.db to a file + my $outfile = $params->{outfile} + || $self->get_basedir() . "/$$-ctl_mboxlist.out"; + $self->run_command({ + cyrus => 1, + redirects => { + stdout => $outfile, + }, + }, 'ctl_mboxlist', '-d'); + + return JSON::decode_json(slurp_file($outfile)); +} + +sub run_cyr_info +{ + my ($self, @args) = @_; + + my $filename = $self->{basedir} . "/cyr_info.out"; + + $self->run_command({ + cyrus => 1, + redirects => { stdout => $filename }, + }, + 'cyr_info', + # we get -C for free + '-M', $self->_master_conf(), + @args + ); + + open RESULTS, '<', $filename + or die "Cannot open $filename for reading: $!"; + my @res = readline(RESULTS); + close RESULTS; + + if ($args[0] eq 'proc') { + # if we see any of our fake daemons, no we didn't + my @fakedaemons = qw(fakesaslauthd fakeldapd); + my $pattern = q{\b(?:} . join(q{|}, @fakedaemons) . q{)\b}; + my $re = qr{$pattern}; + @res = grep { $_ !~ m/$re/ } @res; + } + + return @res; +} + +1; diff --git a/cassandane/Cassandane/MaildirMessageStore.pm b/cassandane/Cassandane/MaildirMessageStore.pm new file mode 100644 index 0000000000..cf2a7ddcb0 --- /dev/null +++ b/cassandane/Cassandane/MaildirMessageStore.pm @@ -0,0 +1,174 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::MaildirMessageStore; +use strict; +use warnings; +use File::Path qw(mkpath rmtree); + +use lib '.'; +use base qw(Cassandane::MessageStore); + +sub new +{ + my ($class, %params) = @_; + my %bits = ( + directory => delete $params{directory}, + next_uid => 0 + (delete $params{next_uid} || 1), + uids_to_read => [], + ); + my $self = $class->SUPER::new(%params); + map { $self->{$_} = $bits{$_}; } keys %bits; + return $self; +} + +sub write_begin +{ + my ($self) = @_; + + if (defined $self->{directory} && ! -d $self->{directory}) + { + mkpath($self->{directory}) + or die "Couldn't make path $self->{directory}"; + } +} + +sub write_message +{ + my ($self, $msg) = @_; + + # find a filename which doesn't exist -- we're appending + my $directory = ($self->{directory} || "."); + my $filename; + for (;;) + { + my $uid = $self->{next_uid}; + $self->{next_uid} = $self->{next_uid} + 1; + $filename = "$directory/$uid."; + last unless ( -f $filename ); + } + + my $fh; + open $fh,'>',$filename + or die "Cannot open $filename for writing: $!"; + print $fh $msg; + close $fh; +} + +sub write_end +{ + my ($self) = @_; + # Nothing to do +} + +sub read_begin +{ + my ($self) = @_; + + die "No such directory: $self->{directory}" + if (defined $self->{directory} && ! -d $self->{directory}); + + # Scan the directory for filenames. We need to read the + # whole directory and sort the results because the messages + # need to be returned in uid order not directory order. + $self->{uids_to_read} = []; + my @uids; + my $directory = ($self->{directory} || "."); + my $fh; + opendir $fh,$directory + or die "Cannot open directory $directory for reading: $!"; + + while (my $e = readdir $fh) + { + my ($uid) = ($e =~ m/^(\d+)\.$/); + next unless defined $uid; + push(@uids, 0+$uid); + } + + @uids = sort { $a <=> $b } @uids; + $self->{uids_to_read} = \@uids; + closedir $fh; +} + +sub read_message +{ + my ($self) = @_; + + my $directory = ($self->{directory} || "."); + my $filename; + + for (;;) + { + my $uid = shift(@{$self->{uids_to_read}}); + return undef + unless defined $uid; + $filename = "$directory/$uid."; + # keep trying if a message disappeared + last if ( -f $filename ); + } + + my $fh; + open $fh,'<',$filename + or die "Cannot open $filename for reading: $!"; + my $msg = Cassandane::Message->new(fh => $fh); + close $fh; + + return $msg; +} + +sub read_end +{ + my ($self) = @_; + + $self->{uids_to_read} = []; +} + +sub remove +{ + my ($self) = @_; + + if (defined $self->{directory}) + { + my $r = rmtree($self->{directory}); + die "rmtree failed: $!" + if (!$r && ! $!{ENOENT} ); + } +} + +1; diff --git a/cassandane/Cassandane/MasterDaemon.pm b/cassandane/Cassandane/MasterDaemon.pm new file mode 100644 index 0000000000..379cd66a25 --- /dev/null +++ b/cassandane/Cassandane/MasterDaemon.pm @@ -0,0 +1,58 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2023 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::MasterDaemon; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::MasterEntry); + +sub new +{ + return shift->SUPER::new(@_); +} + +sub _otherparams +{ + my ($self) = @_; + return ( qw(wait) ); +} + +1; diff --git a/cassandane/Cassandane/MasterEntry.pm b/cassandane/Cassandane/MasterEntry.pm new file mode 100644 index 0000000000..4e716b93bc --- /dev/null +++ b/cassandane/Cassandane/MasterEntry.pm @@ -0,0 +1,115 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::MasterEntry; +use strict; +use warnings; + +use lib '.'; +use Cassandane::Util::Log; + +my $next_tag = 1; + +sub new +{ + my ($class, %params) = @_; + + my $name = delete $params{name}; + if (!defined $name) + { + $name = "xx$next_tag"; + $next_tag++; + } + + my $argv = delete $params{argv}; + die "No argv= parameter" + unless defined $argv && scalar @$argv; + + my $config = delete $params{config}; + + my $self = bless + { + name => $name, + argv => $argv, + config => $config, + }, $class; + + foreach my $a ($self->_otherparams()) + { + $self->{$a} = delete $params{$a} + if defined $params{$a}; + } + die "Unexpected parameters: " . join(" ", keys %params) + if scalar %params; + + return $self; +} + +# Return a hash of key,value pairs which need to go into the line in the +# cyrus master config file. +sub master_params +{ + my ($self) = @_; + my $params = {}; + foreach my $a ('name', 'argv', 'config', $self->_otherparams()) + { + $params->{$a} = $self->{$a} + if defined $self->{$a}; + } + return $params; +} + +sub set_master_param +{ + my ($self, $param, $value) = @_; + + foreach my $a ('name', 'argv', 'config', $self->_otherparams()) + { + $self->{$a} = $value + if ($a eq $param); + } +} + +sub set_config +{ + my ($self, $config) = @_; + $self->{config} = $config; +} + +1; diff --git a/cassandane/Cassandane/MasterEvent.pm b/cassandane/Cassandane/MasterEvent.pm new file mode 100644 index 0000000000..dfb2d89942 --- /dev/null +++ b/cassandane/Cassandane/MasterEvent.pm @@ -0,0 +1,58 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::MasterEvent; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::MasterEntry); + +sub new +{ + return shift->SUPER::new(@_); +} + +sub _otherparams +{ + my ($self) = @_; + return ( qw(period at) ); +} + +1; diff --git a/cassandane/Cassandane/MasterStart.pm b/cassandane/Cassandane/MasterStart.pm new file mode 100644 index 0000000000..54abe7d3df --- /dev/null +++ b/cassandane/Cassandane/MasterStart.pm @@ -0,0 +1,58 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::MasterStart; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::MasterEntry); + +sub new +{ + return shift->SUPER::new(@_); +} + +sub _otherparams +{ + my ($self) = @_; + return (); +} + +1; diff --git a/cassandane/Cassandane/MboxMessageStore.pm b/cassandane/Cassandane/MboxMessageStore.pm new file mode 100644 index 0000000000..9444e3dd85 --- /dev/null +++ b/cassandane/Cassandane/MboxMessageStore.pm @@ -0,0 +1,185 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::MboxMessageStore; +use strict; +use warnings; +use POSIX qw(strftime); + +use lib '.'; +use base qw(Cassandane::MessageStore); +use Cassandane::Util::DateTime qw(from_rfc822); +use Cassandane::Message; + +sub new +{ + my ($class, %params) = @_; + my %bits = ( + filename => delete $params{filename}, + fh => undef, + ourfh => 0, + lineno => undef, + ); + my $self = $class->SUPER::new(%params); + map { $self->{$_} = $bits{$_}; } keys %bits; + return $self; +} + +sub write_begin +{ + my ($self) = @_; + if (defined $self->{filename}) + { + my $fh; + open $fh,'>>',$self->{filename} + or die "Cannot open $self->{filename} for appending: $!"; + $self->{fh} = $fh; + $self->{ourfh} = 1; + } + else + { + $self->{fh} = \*STDOUT; + $self->{ourfh} = 0; + } +} + +sub write_message +{ + my ($self, $msg) = @_; + my $fh = $self->{fh}; + + my $from = $msg->get_header('from'); + $from =~ s/^.*.*$//; + + my $dt = from_rfc822($msg->get_header('date')); + my $date = 'Mon Dec 1 00:03:08 2008'; + $date = strftime("%a %b %d %T %Y", localtime($dt->epoch)) + if defined $dt; + + printf $fh "From %s %s\r\n%s", $from, $date, $msg; +} + +sub write_end +{ + my ($self) = @_; + if ($self->{ourfh}) + { + close $self->{fh}; + } + $self->{fh} = undef; +} + +sub read_begin +{ + my ($self) = @_; + if (defined $self->{filename}) + { + my $fh; + + if ($self->{filename} =~ m/\.gz$/) + { + open $fh,'-|',('gunzip', '-dc', $self->{filename}) + or die "Cannot gunzip $self->{filename} for reading: $!"; + } + else + { + open $fh,'<',$self->{filename} + or die "Cannot open $self->{filename} for reading: $!"; + } + $self->{fh} = $fh; + $self->{ourfh} = 1; + } + else + { + $self->{fh} = \*STDIN; + $self->{ourfh} = 0; + } + $self->{lineno} = 0; +} + +sub read_message +{ + my ($self) = @_; + my @lines; + + my $fh = $self->{fh}; + while (<$fh>) + { + $self->{lineno}++; + + if ($self->{lineno} == 1) + { + die "Bad mbox format - missing From line" + unless m/^From /; + next; + } + return Cassandane::Message->new(lines => \@lines) + if m/^From /; + + push(@lines, $_); + } + + return undef; +} + +sub read_end +{ + my ($self) = @_; + if ($self->{ourfh}) + { + close $self->{fh}; + } + $self->{fh} = undef; + $self->{lineno} = undef; +} + +sub remove +{ + my ($self) = @_; + + if (defined $self->{filename}) + { + my $r = unlink($self->{filename}); + die "unlink failed: $!" + if (!$r && ! $!{ENOENT} ); + } +} + +1; diff --git a/cassandane/Cassandane/Mboxname.pm b/cassandane/Cassandane/Mboxname.pm new file mode 100644 index 0000000000..fe9f56c65e --- /dev/null +++ b/cassandane/Cassandane/Mboxname.pm @@ -0,0 +1,286 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Mboxname; +use strict; +use warnings; +use overload qw("") => \&to_internal; + +use lib '.'; +use Cassandane::Util::Log; + +sub new +{ + my ($class, %params) = @_; + + my $self = bless({ + domain => delete $params{domain}, + userid => delete $params{userid}, + box => delete $params{box}, # internal format, i.e. '.' separated + config => delete $params{config} + || Cassandane::Config::default(), + # TODO is_deleted + }, $class); + + my $s; + my $n = 0; + + $s = delete $params{external}; + if (defined $s) + { + $self->from_external($s); + $n++; + } + + $s = delete $params{internal}; + if (defined $s) + { + $self->from_internal($s); + $n++; + } + + $s = delete $params{username}; + if (defined $s) + { + $self->from_username($s); + $n++; + } + + die "Too many contradictory initialisers" + if $n > 1; + die "Unknown extra arguments" + if scalar(%params); + + return $self; +} + +# We don't just use Clone because we actually +# want a shallow clone here. We rely on the +# c'tor taking parameters which are the same +# as the field names. +sub clone +{ + my ($self) = @_; + return Cassandane::Mboxname->new(%$self); +} + +sub domain { return shift->{domain}; } +sub userid { return shift->{userid}; } +sub box { return shift->{box}; } + +sub _set +{ + my ($self, $domain, $userid, $box) = @_; + + die "No Config specified" + unless defined $self->{config}; + my $virtdomains = $self->{config}->get('virtdomains') || 'off'; + die "Domain specified but virtdomains not enabled in instance" + if (defined $domain && $virtdomains eq 'off'); + + $box = undef if defined $box && $box eq ''; + + $self->{domain} = $domain; + $self->{userid} = $userid; + $self->{box} = $box; +} + +sub _reset +{ + my ($self) = @_; + $self->_set(undef, undef, undef); +} + +sub _external_separator +{ + my ($self) = @_; + die "No Config specified" + unless defined $self->{config}; + return $self->{config}->get_bool('unixhierarchysep', 'off') ? '/' : '.'; +} + +sub _external_separator_regexp +{ + my ($self) = @_; + die "No Config specified" + unless defined $self->{config}; + return $self->{config}->get_bool('unixhierarchysep', 'off') ? qr/\// : qr/\./; +} + + +sub from_external +{ + my ($self, $s) = @_; + + if (!defined $s) + { + $self->_reset(); + return; + } + + my ($local, $domain) = ($s =~ m/^([^@]+)@([^@]+)$/); + $local ||= $s; + my $sep = $self->_external_separator_regexp; + my ($prefix, $userid, @comps) = split($sep, $local); + die "Bad external name \"$s\"" + if !defined $userid || $prefix ne 'user'; + + $self->_set($domain, $userid, join('.', @comps)); +} + +sub to_external +{ + my ($self) = @_; + + my @comps; + push(@comps, 'user', $self->{userid}) if defined $self->{userid}; + push(@comps, split(/\./, $self->{box})) if defined $self->{box}; + my $s = join($self->_external_separator, @comps); + $s .= '@' . $self->{domain} if defined $self->{domain}; + + return ($s eq '' ? undef : $s); +} + +sub from_internal +{ + my ($self, $s) = @_; + + if (!defined $s) + { + $self->_reset(); + return; + } + + my ($domain, $local) = ($s =~ m/^([^!]+)!([^!]+)$/); + $local ||= $s; + my ($userid, $box) = ($local =~ m/^user\.([^.]*)(.*)$/); + $box =~ s/^\.//; + + $self->_set($domain, $userid, $box); +} + +sub to_internal +{ + my ($self) = @_; + + my @comps; + push(@comps, 'user', $self->{userid}) if defined $self->{userid}; + push(@comps, $self->{box}) if defined $self->{box}; + my $s = join('.', @comps); + $s = $self->{domain} . '!' . $s if defined $self->{domain}; + + return ($s eq '' ? undef : $s); +} + +sub from_username +{ + my ($self, $s) = @_; + + if (!defined $s) + { + $self->_reset(); + return; + } + + my ($userid, $domain) = ($s =~ m/^([^@]+)@([^@]+)$/); + $userid ||= $s; + + $self->_set($domain, $userid, undef); +} + +sub to_username +{ + my ($self) = @_; + my $s = $self->{userid} || ''; + $s .= '@' . $self->{domain} if defined $self->{domain}; + return ($s eq '' ? undef : $s); +} + +sub make_child +{ + my ($self, @args) = @_; + + my $sep = $self->_external_separator; + + my @comps; + # Flatten out any array refs and stringify + foreach my $c (@args) + { + if (ref $c && ref $c eq 'ARRAY') + { + map { push(@comps, "" . $_); } @$c; + } + elsif (!ref $c) + { + push(@comps, "" . $c); + } + } + map { die "Bad mboxname component \"$_\"" if index($_, $sep) >= 0; } @comps; + + my $child = $self->clone(); + if (scalar @comps) + { + unshift(@comps, $child->{box}) if defined $child->{box}; + $child->{box} = join('.', @comps); + } + + return $child; +} + +sub make_parent +{ + my ($self, @args) = @_; + + my @comps = split(/\./, $self->{box} || ''); + pop(@comps); + + my $child = $self->clone(); + if (scalar @comps) + { + $child->{box} = join('.', @comps); + } + else + { + $child->{box} = undef; + } + + return $child; +} + +1; diff --git a/cassandane/Cassandane/Message.pm b/cassandane/Cassandane/Message.pm new file mode 100644 index 0000000000..a49d36cefd --- /dev/null +++ b/cassandane/Cassandane/Message.pm @@ -0,0 +1,524 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Message; +use strict; +use warnings; +use base qw(Clone Exporter); +use overload qw("") => \&as_string; +use Math::Int64; + +use lib '.'; +use Cassandane::Util::Log; +use Cassandane::Util::DateTime qw(to_rfc3501); +use Cassandane::Util::SHA; + +our @EXPORT = qw(base_subject); + +sub new +{ + my $class = shift; + my %params = @_; + my $self = { + headers => [], + headers_by_name => {}, + body => undef, + # Other message attributes - e.g. IMAP uid & internaldate + attrs => {}, + }; + + bless $self, $class; + + $self->set_lines(@{$params{lines}}) + if (defined $params{lines}); + $self->set_raw($params{raw}) + if (defined $params{raw}); + $self->set_fh($params{fh}) + if (defined $params{fh}); + # do these one by one to normalise the incoming keys + # and any other logic that set_attribute() wants to do. + if (defined $params{attrs}) + { + while (my ($n, $v) = each %{$params{attrs}}) + { + if (lc($n) eq 'annotation') + { + $self->_set_annotations_from_fetch($v); + next; + } + $self->set_attribute($n, $v); + } + } + + return $self; +} + +sub _clear() +{ + my ($self) = @_; + $self->{headers} = []; + $self->{headers_by_name} = {}; + $self->{body} = undef; +} + +sub _canon_name($) +{ + my ($name) = @_; + + my @cc = split(/([^[:alnum:]])+/, lc($name)); + map + { + $_ = ucfirst($_); + $_ = 'ID' if m/^Id$/; + } @cc; + return join('', @cc); +} + +sub _canon_value +{ + my ($value) = @_; + + # Lines in RFC2822 are separated by CR+LF. Lone CR or LF + # is not legal, so we replace them with CR+LF. + # Header field continuation lines (the 2nd or subsequent line) + # are marked with a leading linear whitespace, so we insert a TAB + # character if the input didn't have any. + my $res = ""; + foreach my $l (split(/[\r\n]+/, $value)) + { + $res .= ($res ne "" && !($l =~ m/^[ \t]/) ? "\t" : ""); + $res .= $l; + $res .= "\r\n"; + } + $res .= "\r\n" if ($res eq ""); + return $res; +} + +sub get_headers +{ + my ($self, $name) = @_; + $name = lc($name); + return $self->{headers_by_name}->{$name}; +} + +sub get_header +{ + my ($self, $name) = @_; + $name = lc($name); + my $values = $self->{headers_by_name}->{$name}; + return undef + unless defined $values; + die "Too many values for header \"$name\"" + unless (scalar @$values == 1); + return $values->[0]; +} + +sub set_headers +{ + my ($self, $name, @values) = @_; + + $name = lc($name); + map { $_ = "" . $_ } @values; + $self->{headers_by_name}->{$name} = \@values; + my @headers = grep { $_->{name} ne $name } @{$self->{headers}}; + foreach my $v (@values) + { + push(@headers, { name => $name, value => "" . $v }); + } + $self->{headers} = \@headers; +} + +sub remove_headers +{ + my ($self, $name) = @_; + + $name = lc($name); + delete $self->{headers_by_name}->{$name}; + my @headers = grep { $_->{name} ne $name } @{$self->{headers}}; + $self->{headers} = \@headers; +} + +sub add_header +{ + my ($self, $name, $value) = @_; + + $value = "" . $value; + + $name = lc($name); + my $values = $self->{headers_by_name}->{$name} || []; + push(@$values, $value); + $self->{headers_by_name}->{$name} = $values; + + # XXX This should probably be unshift rather than push, so that headers + # added chronologically later appear at the top rather than the bottom of + # the resulting header block. But changing it also requires changing a + # bunch of tests' expected results, so that's a project for another time. + push(@{$self->{headers}}, { name => $name, value => $value }); +} + +sub set_body +{ + my ($self, $text) = @_; + $self->{body} = $text; +} + +sub get_body +{ + my ($self) = @_; + return $self->{body}; +} + +sub set_attribute +{ + my ($self, $name, $value) = @_; + $self->{attrs}->{lc($name)} = $value; +} + +sub set_attributes +{ + my ($self, @args) = @_; + + while (my $name = shift @args) + { + my $value = shift @args; + $self->set_attribute($name, $value); + } +} + +sub has_attribute +{ + my ($self, $name) = @_; + return exists $self->{attrs}->{lc($name)}; +} + +sub get_attribute +{ + my ($self, $name) = @_; + return $self->{attrs}->{lc($name)}; +} + +sub _annotation_key +{ + my ($self, $ea) = @_; + return "annotation $ea->{entry} $ea->{attrib}"; +} + +sub _validate_ea +{ + my ($self, $ea) = @_; + + die "Bad entry \"$ea->{entry}\"" + unless $ea->{entry} =~ m/^(\/[a-z0-9.]+)*$/i; + die "Bad attrib \"$ea->{attrib}\"" + unless $ea->{attrib} =~ m/^value.(shared|priv)$/i; +} + +sub has_annotation +{ + my $self = shift; + my $ea = shift; + if (ref $ea ne 'HASH') + { + $ea = { entry => $ea, attrib => shift }; + } + + $self->_validate_ea($ea); + return $self->has_attribute($self->_annotation_key($ea)); +} + +sub get_annotation +{ + my $self = shift; + my $ea = shift; + if (ref $ea ne 'HASH') + { + $ea = { entry => $ea, attrib => shift }; + } + + $self->_validate_ea($ea); + return $self->get_attribute($self->_annotation_key($ea)); +} + +sub list_annotations +{ + my ($self) = @_; + my @res; + + foreach my $key (keys %{$self->{attrs}}) + { + my ($dummy, $entry, $attrib) = split / /,$key; + next unless defined $attrib && $dummy eq 'annotation'; + push (@res, { entry => $entry, attrib => $attrib }); + } + return @res; +} + +sub set_annotation +{ + my $self = shift; + my $ea = shift; + if (ref $ea ne 'HASH') + { + $ea = { entry => $ea, attrib => shift }; + } + my $value = shift; + + $self->_validate_ea($ea); + $self->set_attribute($self->_annotation_key($ea), $value); +} + +sub _set_annotations_from_fetch +{ + my ($self, $fetchitem) = @_; + my $ea = {}; + + foreach my $entry (keys %$fetchitem) + { + $ea->{entry} = $entry; + my $av = $fetchitem->{$entry}; + foreach my $attrib (keys %$av) + { + $ea->{attrib} = $attrib; + $self->set_annotation($ea, $av->{$attrib}); + } + } +} + +sub as_string +{ + my ($self) = @_; + my $s = ''; + + foreach my $h (@{$self->{headers}}) + { + $s .= _canon_name($h->{name}) . ": " . _canon_value($h->{value}); + } + $s .= "\r\n"; + $s .= $self->{body} + if defined $self->{body}; + + return $s; +} + +sub set_lines +{ + my ($self, @lines) = @_; + my @pending; + +# xlog "set_lines"; + $self->_clear(); + + # First parse the headers + while (scalar @lines) + { + my $line = shift @lines; + # remove trailing end of line chars + $line =~ s/[\r\n]*$//; + +# xlog " raw line \"$line\""; + + if ($line =~ m/^\s/) + { + # continuation line -- gather the line + push(@pending, $line); +# xlog " gathering continuation line"; + next; + } +# xlog " pending \"" . join("CRLF", @pending) . "\""; + + # Not a continuation line; handle the previous pending line + if (@pending) + { +# xlog " finished joined line \"$pending\""; + my $first = shift @pending; + my ($name, $value) = ($first =~ m/^([!-9;-~]+):(.*)$/); + + die "Malformed RFC822 header at or near \"$first\"" + unless defined $value; + + $value = join("\r\n", ($value, @pending)); + + # Lose a single SP after the : which we will be putting + # back when we canonicalise on output. This is technically + # wrong but does make for prettier output *and* circular + # consistency with most messages in the wild. + $value =~ s/^ //; + +# xlog " saving header $name=$value"; + $self->add_header($name, $value); + } + + last if ($line eq ''); + @pending = ( $line ); + } +# xlog " finished with headers, next line is \"" . $lines[0] . "\""; + + # Now collect the body...assuming any remains. + my $body = ''; + foreach my $line (@lines) + { + $line =~ s/[\r\n]*$//; + $body .= $line . "\r\n"; + } + $self->set_body($body); +} + +sub set_fh +{ + my ($self, $fh) = @_; + my @lines; + while (<$fh>) + { + push(@lines, $_); + } + $self->set_lines(@lines); +} + +sub set_raw +{ + my ($self, $raw) = @_; + my $fh; + open $fh,'<',\$raw + or die "Cannot open in-memory file for reading: $!"; + $self->set_fh($fh); + close $fh; +} + + +sub set_internaldate +{ + my ($self, $id) = @_; + + if (ref $id eq 'DateTime') + { + $id = to_rfc3501($id); + } + $self->set_attribute(internaldate => $id); +} + +# Calculate and return the GUID of the message +sub get_guid +{ + my ($self) = @_; + + return sha1_hex($self->as_string()); +} + +# Calculate a CID from a message - this is the CID that the +# first message in a new conversation will be assigned. +sub make_cid +{ + my ($self) = @_; + + my $sha1 = sha1($self->as_string()); + my $cid = Math::Int64::uint64(0); + for (0..7) { + $cid <<= 8; + $cid |= ord(substr($sha1, $_, 1)); + } + $cid ^= Math::Int64::string_to_uint64("0x91f3d9e10b690b12", 16); # chosen by fair dice roll + my $res = lc Math::Int64::uint64_to_string($cid, 16); + return sprintf("%016s", $res); +} + +# Handy accessors + +sub uid { return shift->get_attribute('uid'); } +sub cid { return shift->get_attribute('cid'); } +sub guid { return shift->get_guid(); } +sub from { return shift->get_header('from'); } +sub to { return shift->get_header('to'); } +sub subject { return shift->get_header('subject'); } +sub messageid { return shift->get_header('message-id'); } +sub date { return shift->get_header('date'); } +sub size { return length(shift->as_string); } + +# Utility functions + +# Given a subject string, return the "base subject" +# as defined by RFC5256. Used for SORT & THREAD. +sub base_subject +{ + my ($s) = @_; + + # Lexical $_ is a 5.10ism dammit + my $saved_ = $_; + $_ = $s; + + # (1) [ ignoring the RFC2047 decoding ] + # Convert all tabs and continuations to space. + # Convert all multiple spaces to a single space. + s/\s+/ /g; + + # (2) Remove all trailing text of the subject that + # matches the subj-trailer ABNF; repeat until no + # more matches are possible. + while (s/(\s|\(fwd\))$//i) { } + + for (;;) + { + # (3) Remove all prefix text of the subject that + # matches the subj-leader ABNF. + my $n = 0; + while (s/^\s+// || + s/^\[[^][]*\]\s*// || + s/^re\s*(\[[^][]*\])?://i || + s/^fw\s*(\[[^][]*\])?://i || + s/^fwd\s*(\[[^][]*\])?://i) + { + $n++; + } + last if !$n; + + # (4) If there is prefix text of the subject that + # matches the subj-blob ABNF, and removing that + # prefix leaves a non-empty subj-base, then remove + # the prefix text. + my ($prefix, $base) = m/^\[[^][]*\]\s*(.*)$/; + last if !defined $prefix; + $_ = $base if ($base ne ''); + } + # (5) Repeat (3) and (4) until no matches remain. + + $s = $_; + $_ = $saved_; + return $s; +} + +1; diff --git a/cassandane/Cassandane/MessageStore.pm b/cassandane/Cassandane/MessageStore.pm new file mode 100644 index 0000000000..327b43d674 --- /dev/null +++ b/cassandane/Cassandane/MessageStore.pm @@ -0,0 +1,119 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::MessageStore; +use strict; +use warnings; +use overload qw("") => \&as_string; + +use lib '.'; +use Cassandane::Util::Log; + +sub new +{ + my ($class, %params) = @_; + my $self = { + verbose => delete $params{verbose} || 0, + }; + die "Unknown parameters: " . join(' ', keys %params) + if scalar %params; + return bless $self, $class; +} + +sub connect +{ + my ($self) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub disconnect +{ + my ($self) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub write_begin +{ + my ($self) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub write_message +{ + my ($self, $msg, %opts) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub write_end +{ + my ($self) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub read_begin +{ + my ($self) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub read_message +{ + my ($self) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub read_end +{ + my ($self) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub get_client +{ + my ($self) = @_; + die "Unimplemented in base class " . __PACKAGE__; +} + +sub as_string +{ + my ($self) = @_; + return "unknown"; +} + +1; diff --git a/cassandane/Cassandane/MessageStoreFactory.pm b/cassandane/Cassandane/MessageStoreFactory.pm new file mode 100644 index 0000000000..6a194a95df --- /dev/null +++ b/cassandane/Cassandane/MessageStoreFactory.pm @@ -0,0 +1,199 @@ +#!/usr/bin/perl +# +# Copyright (c) 2017 FastMail Pty Ltd All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::MessageStoreFactory; +use strict; +use warnings; +use Mail::IMAPTalk; +use URI; +use URI::Escape qw(uri_unescape); +use Exporter (); + +use lib '.'; +use Cassandane::MboxMessageStore; +use Cassandane::MaildirMessageStore; +use Cassandane::IMAPMessageStore; +use Cassandane::POP3MessageStore; + +our @ISA = qw(Exporter); +our @EXPORT = qw(create); + +our %fmethods = +( + mbox => sub { return Cassandane::MboxMessageStore->new(@_); }, + maildir => sub { return Cassandane::MaildirMessageStore->new(@_); }, + imap => sub { return Cassandane::IMAPMessageStore->new(@_); }, + imaps => sub { return Cassandane::IMAPMessageStore->new(@_, ssl => 1); }, + pop3 => sub { return Cassandane::POP3MessageStore->new(@_); }, +); + +our %cleanups = +( + mbox => sub + { + my ($params) = @_; + if (defined $params->{path}) + { + $params->{filename} = $params->{path}; + delete $params->{path}; + } + }, + maildir => sub + { + my ($params) = @_; + if (defined $params->{path}) + { + $params->{directory} = $params->{path}; + delete $params->{path}; + } + } +); + +our %uriparsers = +( + file => sub + { + my ($uri, $params) = @_; + $params->{filename} = $uri->file(); + return 'mbox'; + }, + mbox => sub + { + my ($uri, $params) = @_; + $params->{filename} = $uri->path(); + return 'mbox'; + }, + maildir => sub + { + my ($uri, $params) = @_; + $params->{directory} = $uri->path(); + return 'maildir'; + }, + imap => sub + { + my ($uri, $params) = @_; + + # The URI module doesn't know how to parse imap: URIs. + # But it does know how to parse pop: URIs, and those + # are sufficiently close to work for us (as we ignore + # the special UIDVALIDITY and TYPE stuff anyway). So + # hackily recreate the URI object. + my $u = "" . $uri; + $u =~ s/^imap:/pop:/; + $uri = URI->new($u); + + $params->{host} = $uri->host(); + $uri->_port() and $params->{port} = 0 + $uri->_port(); + if ($uri->userinfo()) + { + my ($u, $p) = split(/:/, $uri->userinfo()); + $params->{username} = uri_unescape($u) + if defined $u; + $params->{password} = uri_unescape($p) + if defined $p; + } + $params->{folder} = substr($uri->path(),1) + if (defined $uri->path() && $uri->path() ne "/"); + return 'imap'; + }, + # XXX need to add a uriparser for imaps urls + 'pop' => sub + { + my ($uri, $params) = @_; + + $params->{host} = $uri->host(); + $uri->_port() and $params->{port} = 0 + $uri->_port(); + if ($uri->userinfo()) + { + my ($u, $p) = split(/:/, $uri->userinfo()); + $params->{username} = uri_unescape($u) + if defined $u; + $params->{password} = uri_unescape($p) + if defined $p; + } + $params->{folder} = substr($uri->path(),1) + if (defined $uri->path() && $uri->path() ne "/"); + return 'pop3'; + }, +); + +sub create +{ + my $class = shift; + my %params = @_; + my $type; + + if (defined $params{uri}) + { + my $uri = URI->new($params{uri}); + delete $params{uri}; + + die "Unsupported URI scheme \"$uri->scheme\"" + unless defined $uriparsers{$uri->scheme()}; + $type = $uriparsers{$uri->scheme()}->($uri, \%params); + } + + if (!defined $type && defined $params{type}) + { + $type = $params{type}; + delete $params{type}; + } + + # some heuristics + if (defined $params{directory}) + { + $type = 'maildir'; + } + elsif (defined $params{filename}) + { + $type = 'mbox'; + } + + $type = 'mbox' + unless defined $type; + + $cleanups{$type}->(\%params) + if defined $cleanups{$type}; + + die "No such type \"$type\"" + unless defined $fmethods{$type}; + return $fmethods{$type}->(%params); +} + +1; diff --git a/cassandane/Cassandane/Net/SMTPServer.pm b/cassandane/Cassandane/Net/SMTPServer.pm new file mode 100644 index 0000000000..c1ed6c5534 --- /dev/null +++ b/cassandane/Cassandane/Net/SMTPServer.pm @@ -0,0 +1,151 @@ +package Cassandane::Net::SMTPServer; + +use strict; +use warnings; +use Data::Dumper; +use File::Spec::Functions qw(catfile); +use File::Temp qw(mkstemps); +use Net::Server::PreFork; + +use lib "."; +use Net::XmtpServer; +use Cassandane::Util::Log; +use Cassandane::Util::Slurp; +use JSON; + +use base qw(Net::XmtpServer Net::Server::PreFork); + +sub new { + my ($class, $params, @args) = @_; + + my $messages_dir = delete $params->{messages_dir}; + + my $Self = $class->SUPER::new($params, @args); + $Self->{messages_dir} = $messages_dir; + $Self->{message_fh} = undef; + $Self->{_rcpt_to_count} = 0; + return $Self; +} + +sub override { + my $Self = shift; + my $stage = shift; + if ($Self->{server}{control_file} and -e $Self->{server}{control_file}) { + my $data = decode_json(slurp_file($Self->{server}{control_file})); + if ($data->{$stage}) { + $Self->send_client_resp(@{$data->{$stage}}); + return 1; + } + } + return 0; +} + +sub mylog { + my $Self = shift; + if ($Self->{server}->{cass_verbose}) { + xlog @_; + } +} + +sub new_connection { + my ($Self) = @_; + $Self->mylog("SMTP: new connection"); + return if $Self->override('new'); + $Self->send_client_resp(220, "localhost ESMTP"); +} + +sub helo { + my ($Self, $Host) = @_; + $Self->mylog("SMTP: HELO $Host"); + return if $Self->override('helo'); + $Self->send_client_resp(250, "localhost", + "AUTH", "DSN", "SIZE 10000", "ENHANCEDSTATUSCODES"); +} + +sub mail_from { + my ($Self, $From, @FromExtra) = @_; + $Self->mylog("SMTP: MAIL FROM $From @FromExtra"); + $Self->{_rcpt_to_count} = 0; + return if $Self->override('from'); + # don't just quietly accept garbage! + if ($From =~ m/[<>]/ || grep { m/[<>]/ } @FromExtra) { + $Self->send_client_resp(501, "Junk in parameters"); + } + $Self->send_client_resp(250, "ok"); +} + +sub rcpt_to { + my ($Self, $To, @ToExtra) = @_; + $Self->mylog("SMTP: RCPT TO $To @ToExtra"); + return if $Self->override('to'); + + $Self->{_rcpt_to_count}++; + if ($Self->{_rcpt_to_count} > 10) { + $Self->send_client_resp(550, "5.5.3 Too many recipients"); + } elsif ($To =~ /[<>]/ || $To =~ /\@fail\.to\.deliver$/i) { + $Self->send_client_resp(553, "5.1.1 Bad destination mailbox address"); + $Self->mylog("SMTP: 553 5.1.1"); + } else { + $Self->send_client_resp(250, "ok"); + } +} + +sub begin_data { + my ($Self) = @_; + $Self->mylog("SMTP: BEGIN DATA"); + if ($Self->{messages_dir} and not $Self->{message_fh}) { + my $template = catfile($Self->{messages_dir}, 'message_XXXXXX'); + eval { + $Self->{message_fh} = mkstemps($template, '.smtp'); + }; + if ($@) { + xlog "mkstemps($template, '.smtp') failed, not saving message"; + } + } + return if $Self->override('begin_data'); + $Self->send_client_resp(354, "ok"); + return 1; +} + +sub output_body { + my ($Self, $Fh, $Data) = @_; + + # n.b. we only receive this callback if we were configured with + # store_msg => 1, but note that, despite the name, that option + # has nothing to do with what we're doing here + if ($Self->{message_fh}) { + print { $Self->{message_fh} } $Data; + } + + $Self->SUPER::output_body($Fh, $Data); +} + +sub end_data { + my ($Self) = @_; + $Self->mylog("SMTP: END DATA"); + if ($Self->{message_fh}) { + close $Self->{message_fh}; + $Self->{message_fh} = undef; + } + return if $Self->override('end_data'); + $Self->send_client_resp(250, "ok"); + return 0; +} + +sub rset { + my ($Self) = @_; + $Self->mylog("SMTP: RSET"); + $Self->{_rcpt_to_count} = 0; + return if $Self->override('rset'); + $Self->send_client_resp(250, "ok"); + return 0; +} + +sub quit { + my ($Self) = @_; + $Self->mylog("SMTP: QUIT"); + return if $Self->override('quit'); + $Self->send_client_resp(221, "bye!"); +} + +1; diff --git a/cassandane/Cassandane/POP3MessageStore.pm b/cassandane/Cassandane/POP3MessageStore.pm new file mode 100644 index 0000000000..cec493ec60 --- /dev/null +++ b/cassandane/Cassandane/POP3MessageStore.pm @@ -0,0 +1,171 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::POP3MessageStore; +use strict; +use warnings; +use Net::POP3; + +use lib '.'; +use base qw(Cassandane::MessageStore); +use Cassandane::Util::Log; + +sub new +{ + my ($class, %params) = @_; + my %bits = ( + host => delete $params{host} || 'localhost', + port => 0 + (delete $params{port} || 110), + folder => delete $params{folder} || 'INBOX', + username => delete $params{username}, + password => delete $params{password}, + client => undef, + # state for streaming read + next_id => undef, + last_id => undef, + ); + + # Sadly, it seems the version of Net::POP3 I'm using has + # neither support for specifying inet6 nor a way of passing + # an already connected socket. So, no IPv6 for us. + my $af = delete $params{address_family}; + die "Sorry, only INET supported for POP3" + if (defined $af && $af ne 'inet'); + + my $self = $class->SUPER::new(%params); + map { $self->{$_} = $bits{$_}; } keys %bits; + return $self; +} + +sub connect +{ + my ($self) = @_; + + # if already successfully connected, do nothing + return + if (defined $self->{client}); + + # xlog "connect: creating POP3 object"; + my %opts; + $opts{Debug} = $self->{verbose} + if $self->{verbose}; + my $client = Net::POP3->new("$self->{host}:$self->{port}", %opts) + or die "Cannot create Net::POP3 object"; + + my ($uu, $ud) = split(/@/, $self->{username}); + + $ud = (defined $ud ? "\@$ud" : ""); + + my $ff = $self->{folder}; + if ($ff =~ m/^inbox$/i) + { + $ff = ''; + } + elsif ($ff =~ m/^inbox\./i) + { + $ff =~ s/^inbox\./+/i; + } + else + { + $ff = "+$ff"; + } + + my $pop3_username = "$uu$ff$ud"; + # xlog "connect: pop3_username=\"$pop3_username\"", ; + # xlog "connect: password=\"" . $self->{password} . "\""; + + my $res = $client->login($pop3_username, $self->{password}) + or die "Cannot login via POP3"; + $res = 0 if ($res eq '0E0'); + $res = 0 + $res; + + xlog "connect: found $res messages"; + + $self->{last_id} = $res; + $self->{client} = $client; +} + +sub disconnect +{ + my ($self) = @_; + + if (defined $self->{client}) + { + $self->{client}->quit(); + $self->{client} = undef; + } +} + +sub read_begin +{ + my ($self) = @_; + + $self->connect(); + $self->{next_id} = 1; +} + +sub read_message +{ + my ($self) = @_; + + my $id = $self->{next_id}; + return undef + if ($id > $self->{last_id}); + $self->{next_id}++; + + return Cassandane::Message->new(fh => $self->{client}->getfh($id)); +} + +sub read_end +{ + my ($self) = @_; + + $self->disconnect(); + $self->{next_id} = undef; +} + +sub get_client +{ + my ($self) = @_; + + $self->connect(); + return $self->{client}; +} + +1; diff --git a/cassandane/Cassandane/PortManager.pm b/cassandane/Cassandane/PortManager.pm new file mode 100644 index 0000000000..c664499a12 --- /dev/null +++ b/cassandane/Cassandane/PortManager.pm @@ -0,0 +1,131 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::PortManager; +use strict; +use warnings; + +use lib '.'; +use Cassandane::Cassini; +use IO::Socket::IP; +use POSIX qw(EADDRINUSE); + +my $base_port; +my $max_ports = 20; +my $next_port = 0; +my %allocated; + +sub alloc +{ + my $host = shift; + + if (!defined $base_port) + { + my $workerid = $ENV{TEST_UNIT_WORKER_ID} || '1'; + die "Invalid TEST_UNIT_WORKER_ID - code not run in Worker context" + if (defined($workerid) && $workerid eq 'invalid'); + my $cassini = Cassandane::Cassini->instance(); + my $cassini_base_port = $cassini->val('cassandane', 'base_port') // 0; + $base_port = 0 + $cassini_base_port || 9100; + $base_port += $max_ports * ($workerid-1); + } + for (my $i = 0 ; $i < $max_ports ; $i++) + { + my $port = $base_port + (($next_port + $i) % $max_ports); + if (!$allocated{$port} && port_is_free($host, $port)) + { + $allocated{$port} = 1; + $next_port++; + return $port; + } + } + die "No ports remaining"; +} + +sub port_is_free +{ + my $host = shift; + my $port = shift; + + # If we can bind to the port no one else is currently using it + my $socket = IO::Socket::IP->new( + LocalAddr => $host, + LocalPort => $port, + Proto => 'tcp', + ReuseAddr => 1, + ); + + unless ($socket) { + if ($! == EADDRINUSE) { + return 0; + } + + warn "Unknown error binding $host:$port: $!\n"; + return 0; + } + + return 1; +} + +sub free +{ + my ($port) = @_; + + return unless defined $base_port; + + $allocated{$port} = 0; +} + +sub free_all +{ + return unless defined $base_port; + my @freed; + for (my $i = 0 ; $i < $max_ports ; $i++) + { + my $port = $base_port + $i; + if ($allocated{$port}) + { + $allocated{$port} = 0; + push(@freed, $port); + } + } + return @freed; +} + +1; diff --git a/cassandane/Cassandane/SequenceGenerator.pm b/cassandane/Cassandane/SequenceGenerator.pm new file mode 100644 index 0000000000..22aafdbd62 --- /dev/null +++ b/cassandane/Cassandane/SequenceGenerator.pm @@ -0,0 +1,91 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::SequenceGenerator; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Generator); +use Cassandane::Util::DateTime qw(to_iso8601); +use Cassandane::Address; +use Cassandane::Message; +use Cassandane::Util::Log; +use Cassandane::Util::Words; + +my $NMESSAGES = 240; +my $DELTAT = 3600; # seconds + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + + $self->{nmessages} = $NMESSAGES; + $self->{deltat} = $DELTAT; + $self->{next_date} = DateTime->now->epoch - + $self->{deltat} * ($self->{nmessages}+1); + + return $self; +} + +# +# Generate a single email. +# Args: Generator, (param-key => param-value ... ) +# Returns: Message ref +# +sub generate +{ + my ($self, %params) = @_; + + return undef + if (!$self->{nmessages}); + + my $dt = DateTime->from_epoch( epoch => $self->{next_date} ); + $params{subject} = "message at " . to_iso8601($dt); + $params{date} = $dt; + $self->{next_date} += $self->{deltat}; + + my $msg = $self->SUPER::generate(%params); + $self->{nmessages}--; + + return $msg; +} + +1; diff --git a/cassandane/Cassandane/Service.pm b/cassandane/Cassandane/Service.pm new file mode 100644 index 0000000000..010ec123a9 --- /dev/null +++ b/cassandane/Cassandane/Service.pm @@ -0,0 +1,175 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Service; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::MasterEntry); +use Cassandane::GenericListener; +use Cassandane::Util::Log; +use Cassandane::MessageStoreFactory; +use Cassandane::Util::Socket; + +sub new +{ + my ($class, %params) = @_; + + my $host = '127.0.0.1'; + $host = delete $params{host} + if (exists $params{host}); + my $port = delete $params{port}; + my $type = delete $params{type} || 'unknown'; + my $instance = delete $params{instance}; + + my $self = $class->SUPER::new(%params); + + # GenericListener is a bit different from MasterEntry et al, and must + # always have a config specified, so pass through the default explicitly + my $listener_config = $params{config} || $instance->{config}; + + $self->{_listener} = Cassandane::GenericListener->new( + name => $params{name}, + host => $host, + port => $port, + config => $listener_config); + $self->{type} = $type; + + return $self; +} + +sub _otherparams +{ + my ($self) = @_; + return ( qw(prefork maxchild maxforkrate maxfds proto babysit) ); +} + +sub set_config +{ + my ($self, $config) = @_; + $self->SUPER::set_config($config); + $self->{_listener}->set_config($config); +} + +# Return the host +sub host +{ + my ($self) = @_; + return $self->{_listener}->host(); +} + +# Return the port +sub port +{ + my ($self) = @_; + return $self->{_listener}->port(); +} + +sub set_port +{ + my ($self, $port) = @_; + return $self->{_listener}->set_port($port); +} + +# Return a hash of parameters suitable for passing +# to MessageStoreFactory::create. +sub store_params +{ + my ($self, %params) = @_; + + my $pp = $self->{_listener}->connection_params(%params); + $pp->{type} ||= $self->{type}; + $pp->{username} ||= 'cassandane'; + $pp->{password} ||= 'testpw'; + return $pp; +} + +sub create_store +{ + my ($self, @args) = @_; + my $params = $self->store_params(@args); + return Cassandane::MessageStoreFactory->create(%$params); +} + +sub get_socket { + my ($self) = @_; + return create_client_socket( + $self->address_family(), + $self->host(), + $self->port() + ); +} + +# Return a hash of key,value pairs which need to go into the line in the +# cyrus master config file. +sub master_params +{ + my ($self) = @_; + my $params = $self->SUPER::master_params(); + $params->{listen} = $self->address(); + return $params; +} + +sub address +{ + my ($self) = @_; + return $self->{_listener}->address(); +} + +sub address_family +{ + my ($self) = @_; + return $self->{_listener}->address_family(); +} + +sub is_listening +{ + my ($self) = @_; + return $self->{_listener}->is_listening(); +} + +sub describe +{ + my ($self) = @_; + $self->{_listener}->describe(); +} + + +1; diff --git a/cassandane/Cassandane/ServiceFactory.pm b/cassandane/Cassandane/ServiceFactory.pm new file mode 100644 index 0000000000..5507431ee6 --- /dev/null +++ b/cassandane/Cassandane/ServiceFactory.pm @@ -0,0 +1,132 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::ServiceFactory; +use strict; +use warnings; + +use lib '.'; +use Cassandane::Util::Log; +use Cassandane::Service; +use Cassandane::IMAPService; + +sub create +{ + my ($class, %params) = @_; + + my $name = $params{name}; + die "No name specified" + unless defined $name; + + # if caller knows what they're asking for, don't try to guess + if (defined $params{argv}) { + return Cassandane::Service->new(%params); + } + + # try and guess some service-specific defaults + if ($name =~ m/imaps/) + { + return Cassandane::IMAPService->new( + argv => ['imapd', '-s'], + %params); + } + elsif ($name =~ m/imap/) + { + return Cassandane::IMAPService->new( + argv => ['imapd'], + %params); + } + elsif ($name =~ m/sync/) + { + return Cassandane::Service->new( + argv => ['imapd'], + %params); + } + elsif ($name =~ m/http/) + { + return Cassandane::Service->new( + argv => ['httpd'], + %params); + } + elsif ($name =~ m/lmtp/) + { + return Cassandane::Service->new( + argv => ['lmtpd'], + %params); + } + elsif ($name =~ m/sieve/) + { + return Cassandane::Service->new( + argv => ['timsieved'], + %params); + } + elsif ($name =~ m/nntp/) + { + return Cassandane::Service->new( + argv => ['nntpd'], + %params); + } + elsif ($name =~ m/smmap/) + { + return Cassandane::Service->new( + argv => ['smmapd'], + %params); + } + elsif ($name =~ m/pop/) + { + return Cassandane::Service->new( + type => 'pop3', + argv => ['pop3d'], + %params); + } + elsif ($name =~ m/ptloader/) + { + return Cassandane::Service->new( + type => 'ptloader', + argv => ['ptloader', '-d', '99'], + port => '@basedir@/conf/ptsock', + %params); + } + else + { + die "$name: No command specified and cannot guess a default"; + } +} + +1; diff --git a/cassandane/Cassandane/Test/Address.pm b/cassandane/Cassandane/Test/Address.pm new file mode 100644 index 0000000000..ceeb7cac41 --- /dev/null +++ b/cassandane/Cassandane/Test/Address.pm @@ -0,0 +1,83 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Address; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Address; + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +sub test_default_ctor +{ + my ($self) = @_; + my $a = Cassandane::Address->new(); + $self->assert(!defined $a->name); + $self->assert($a->localpart eq 'unknown-user'); + $self->assert($a->domain eq 'unspecified-domain'); + $self->assert($a->address eq 'unknown-user@unspecified-domain'); + $self->assert($a->as_string eq ''); + $self->assert("" . $a eq ''); +} + +sub test_full_ctor +{ + my ($self) = @_; + my $a = Cassandane::Address->new( + name => 'Fred J. Bloggs', + localpart => 'fbloggs', + domain => 'fastmail.fm', + ); + $self->assert($a->name eq 'Fred J. Bloggs'); + $self->assert($a->localpart eq 'fbloggs'); + $self->assert($a->domain eq 'fastmail.fm'); + $self->assert($a->address eq 'fbloggs@fastmail.fm'); + $self->assert($a->as_string eq 'Fred J. Bloggs '); + $self->assert("" . $a eq 'Fred J. Bloggs '); +} + +1; diff --git a/cassandane/Cassandane/Test/Cassini.pm b/cassandane/Cassandane/Test/Cassini.pm new file mode 100644 index 0000000000..673c71ef8b --- /dev/null +++ b/cassandane/Cassandane/Test/Cassini.pm @@ -0,0 +1,379 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Cassini; +use strict; +use warnings; +use File::chdir; +use File::Temp qw(tempdir); + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Cassini; +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + return $class->SUPER::new(@_); +} + +sub write_inifile +{ + my ($options, %contents) = @_; + + my $filename = $options->{filename} || 'cassandane.ini'; + + my %sections; + foreach my $k (keys %contents) + { + my ($sec, $param) = split(/\./, $k); + $sections{$sec} ||= {}; + $sections{$sec}->{$param} = $contents{$k}; + } + + open INIFILE, '>', $filename + or die "Cannot open file $filename for writing: $!"; + foreach my $sec (keys %sections) + { + printf INIFILE "[%s]\n", $sec; + foreach my $param (keys %{$sections{$sec}}) + { + printf INIFILE "%s=%s\n", $param, $sections{$sec}->{$param}; + } + } + close INIFILE; +} + +sub test_basic +{ + my ($self) = @_; + + local $CWD = tempdir(CLEANUP => 1); + + xlog "Working in temporary directory $CWD"; + # data thanks to hipsteripsum.me + write_inifile({}, + 'helvetica.blog' => 'ethical', + ); + + my $cassini = new Cassandane::Cassini; + + # Don't find non-existant param in non-existant section + $self->assert_null($cassini->val('swag', 'quinoa')); + # or return the default + $self->assert_str_equals('whatever', + $cassini->val('swag', 'quinoa', 'whatever')); + + # Don't find non-existant param in existant section + $self->assert_null($cassini->val('helvetica', 'quinoa')); + # or return the default + $self->assert_str_equals('whatever', + $cassini->val('helvetica', 'quinoa', 'whatever')); + + # Don't find param in non-existant section where the + # param does exist in another section + $self->assert_null($cassini->val('swag', 'blog')); + # or return the default + $self->assert_str_equals('whatever', + $cassini->val('swag', 'blog', 'whatever')); + + # Don't find case aliases for existant param + $self->assert_null($cassini->val('Helvetica', 'blog')); + $self->assert_null($cassini->val('helvetica', 'Blog')); + $self->assert_null($cassini->val('HELvEtIca', 'blOG')); + + # Do find exact match for existant param + $self->assert_str_equals('ethical', $cassini->val('helvetica', 'blog')); +} + +sub test_boolval +{ + my ($self) = @_; + + local $CWD = tempdir(CLEANUP => 1); + + xlog "Working in temporary directory $CWD"; + # data thanks to hipsteripsum.me + write_inifile({}, + 'narwhal.cardigan' => 'no', + 'narwhal.banksy' => 'yes', + 'narwhal.occupy' => 'NO', + 'narwhal.mustache' => 'YES', + 'narwhal.gentrify' => 'false', + 'narwhal.thundercats' => 'true', + 'narwhal.scenester' => 'FALSE', + 'narwhal.squid' => 'TRUE', + 'narwhal.selvage' => '0', + 'narwhal.portland' => '1', + 'narwhal.bunch' => 'off', + 'narwhal.bicycle' => 'on', + 'narwhal.organic' => 'OFF', + 'narwhal.leggings' => 'ON', + 'narwhal.mixtape' => '', + 'narwhal.vegan' => 'invalid', + ); + + my $cassini = new Cassandane::Cassini; + + $self->assert_equals(0, $cassini->bool_val('narwhal', 'cardigan')); + $self->assert_equals(1, $cassini->bool_val('narwhal', 'banksy')); + $self->assert_equals(0, $cassini->bool_val('narwhal', 'occupy')); + $self->assert_equals(1, $cassini->bool_val('narwhal', 'mustache')); + $self->assert_equals(0, $cassini->bool_val('narwhal', 'gentrify')); + $self->assert_equals(1, $cassini->bool_val('narwhal', 'thundercats')); + $self->assert_equals(0, $cassini->bool_val('narwhal', 'scenester')); + $self->assert_equals(1, $cassini->bool_val('narwhal', 'squid')); + $self->assert_equals(0, $cassini->bool_val('narwhal', 'selvage')); + $self->assert_equals(1, $cassini->bool_val('narwhal', 'portland')); + $self->assert_equals(0, $cassini->bool_val('narwhal', 'brunch')); + $self->assert_equals(1, $cassini->bool_val('narwhal', 'bicycle')); + $self->assert_equals(0, $cassini->bool_val('narwhal', 'organic')); + $self->assert_equals(1, $cassini->bool_val('narwhal', 'leggings')); + + eval { $cassini->bool_val('narwhal', 'mixtape'); }; + my $exception = $@; + $self->assert_matches(qr/Bad boolean/, $exception); + + eval { $cassini->bool_val('narwhal', 'vegan'); }; + $exception = $@; + $self->assert_matches(qr/Bad boolean/, $exception); +} + +sub test_environment_override +{ + my ($self) = @_; + + local $CWD = tempdir(CLEANUP => 1); + local %ENV = (); # stop real environment from interfering! + + xlog "Working in temporary directory $CWD"; + # artisinal handcrafted data + # okay that's a lie... these are the real options and their defaults + # as documented by cassandane.ini.example + write_inifile({}, + 'cassandane.rootdir' => '/var/tmp/cass', + 'cassandane.pwcheck' => 'alwaystrue', + 'cassandane.cleanup' => 'no', + 'cassandane.maxworkers' => '1', + 'cassandane.base_port' => '9100', + 'cassandane.suppress' => '', + 'valgrind.enabled' => 'no', + 'valgrind.binary' => '/usr/bin/valgrind', + 'valgrind.suppressions' => 'vg.supp', + 'valgrind.arguments' => '-q --tool=memcheck --leak-check=full --run-libc-freeres=no', + 'cyrus default.prefix' => '/usr/cyrus', + 'cyrus default.destdir' => '', + 'cyrus default.quota' => 'cyr_quota', + 'cyrus default.coresizelimit' => '100', + 'cyrus replica.prefix' => '/usr/cyrus', + 'cyrus replica.destdir' => '', + 'cyrus murder.prefix' => '/usr/cyrus', + 'cyrus murder.destdir' => '', + 'gdb.imapd' => 'yes', + 'gdb.sync_server' => 'yes', + 'gdb.lmtpd' => 'yes', + 'gdb.timsieved' => 'yes', + 'gdb.backupd' => 'yes', + 'config.sasl_mech_list' => 'PLAIN LOGIN', + 'config.debug_command' => '@prefix@/utils/gdbtramp %s %d', + 'caldavtalk.basedir' => '', + 'imaptest.basedir' => '', + 'imaptest.suppress' => 'listext subscribe', + 'caldavtester.basedir' => '', + 'caldavtester.suppress-caldav' => '', + 'caldavtester.suppress-carddav' => '', + 'jmaptestsuite.basedir' => '', + 'jmaptestsuite.suppress' => '', + ); + + my $cassini = new Cassandane::Cassini; + + # let's test the things we provide examples of + $self->assert_str_equals( + '/var/tmp/cass', + $cassini->val('cassandane', 'rootdir', 'ignored') + ); + + $ENV{CASSINI_CASSANDANE_ROOTDIR} = 'overridden!'; + $self->assert_str_equals( + 'overridden!', + $cassini->val('cassandane', 'rootdir', 'ignored') + ); + + $ENV{CASSINI_CASSANDANE_ROOTDIR} = ''; + $self->assert_str_equals( + '', + $cassini->val('cassandane', 'rootdir', 'ignored') + ); + + delete $ENV{CASSINI_CASSANDANE_ROOTDIR}; + $self->assert_str_equals( + '/var/tmp/cass', + $cassini->val('cassandane', 'rootdir', 'ignored') + ); + + # [cyrus default] is a section with a space in its name + $self->assert_str_equals( + '/usr/cyrus', + $cassini->val('cyrus default', 'prefix', 'ignored') + ); + + $ENV{CASSINI_CYRUS_DEFAULT_PREFIX} = 'overridden!'; + $self->assert_str_equals( + 'overridden!', + $cassini->val('cyrus default', 'prefix', 'ignored') + ); + + $ENV{CASSINI_CYRUS_DEFAULT_PREFIX} = ''; + $self->assert_str_equals( + '', + $cassini->val('cyrus default', 'prefix', 'ignored') + ); + + delete $ENV{CASSINI_CYRUS_DEFAULT_PREFIX}; + $self->assert_str_equals( + '/usr/cyrus', + $cassini->val('cyrus default', 'prefix', 'ignored') + ); + + # booleans should work too + $self->assert_str_equals( + 'no', + $cassini->val('cassandane', 'cleanup', 'ignored') + ); + + foreach my $x (qw( no NO false FALSE 0 off OFF )) { + $ENV{CASSINI_CASSANDANE_CLEANUP} = $x; + $self->assert_equals(0, $cassini->bool_val('cassandane', 'cleanup')); + } + + foreach my $x (qw( yes YES true TRUE 1 on ON )) { + $ENV{CASSINI_CASSANDANE_CLEANUP} = $x; + $self->assert_equals(1, $cassini->bool_val('cassandane', 'cleanup')); + } + + foreach my $x (q{}, 'invalid') { + $ENV{CASSINI_CASSANDANE_CLEANUP} = $x; + eval { $cassini->bool_val('cassandane', 'cleanup'); }; + my $exception = $@; + $self->assert_matches(qr/Bad boolean/, $exception); + } + + delete $ENV{CASSINI_CASSANDANE_CLEANUP}; + $self->assert_str_equals( + 'no', + $cassini->val('cassandane', 'cleanup', 'ignored') + ); +} + +sub test_environment_only +{ + my ($self) = @_; + + local $CWD = tempdir(CLEANUP => 1); + local %ENV = (); # stop real environment from interfering! + + my $cassini; + + # n.b. did not create an ini file! + + # allow_noinifile has not been set, so it should barf + eval { $cassini = new Cassandane::Cassini; }; + my $e = $@; + $self->assert_matches(qr/couldn't find a cassandane\.ini file/, $e); + + # set allow_noinifile and other config via environment + $ENV{CASSINI_CASSANDANE_ALLOW_NOINIFILE} = 'yes'; + $ENV{CASSINI_CASSANDANE_ROOTDIR} = 'overridden!'; + + # should work this time + $cassini = new Cassandane::Cassini; + + $self->assert_equals(1, + $cassini->bool_val('cassandane', 'allow_noinifile')); + + $self->assert_str_equals( + 'overridden!', + $cassini->val('cassandane', 'rootdir', 'ignored') + ); + + # we didn't change this one at all, and its default is 'no' + $self->assert_equals(0, + $cassini->bool_val('cassandane', 'cleanup')); +} + +sub test_override +{ + my ($self) = @_; + + local $CWD = tempdir(CLEANUP => 1); + + xlog "Working in temporary directory $CWD"; + # data thanks to hipsteripsum.me + write_inifile({}, + 'semiotics.skateboard' => 'flexitarian', + ); + + my $cassini = new Cassandane::Cassini; + + $self->assert_null($cassini->val('semiotics', 'typewriter')); + $self->assert_str_equals('whatever', + $cassini->val('semiotics', 'typewriter', 'whatever')); + $self->assert_str_equals('flexitarian', + $cassini->val('semiotics', 'skateboard', 'whatever')); + $self->assert_str_equals('flexitarian', + $cassini->val('semiotics', 'skateboard')); + $self->assert_null($cassini->val('twee', 'cliche')); + + $cassini->override('semiotics', 'typewriter', 'vegan'); + + $self->assert_str_equals('vegan', + $cassini->val('semiotics', 'typewriter')); + $self->assert_str_equals('vegan', + $cassini->val('semiotics', 'typewriter', 'whatever')); + $self->assert_str_equals('flexitarian', + $cassini->val('semiotics', 'skateboard', 'whatever')); + $self->assert_str_equals('flexitarian', + $cassini->val('semiotics', 'skateboard')); + $self->assert_null($cassini->val('twee', 'cliche')); +} + + +1; diff --git a/cassandane/Cassandane/Test/Clone.pm b/cassandane/Cassandane/Test/Clone.pm new file mode 100644 index 0000000000..02ba310da2 --- /dev/null +++ b/cassandane/Cassandane/Test/Clone.pm @@ -0,0 +1,115 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Clone; +use strict; +use warnings; +use Clone qw(clone); + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +sub test_undef +{ + my ($self) = @_; + my $a = undef; + my $b = clone($a); + $self->assert_null($a); + $self->assert_null($b); +} + +sub test_string +{ + my ($self) = @_; + my $a = "Hello World"; + my $b = clone($a); + $self->assert_str_equals("Hello World", $a); + $self->assert_str_equals("Hello World", $b); + $b = "Jeepers"; + $self->assert_str_equals("Hello World", $a); + $self->assert_str_equals("Jeepers", $b); +} + +sub test_hash +{ + my ($self) = @_; + my $a = { foo => 42 }; + my $b = clone($a); + $self->assert_deep_equals({ foo => 42 }, $a); + $self->assert_deep_equals({ foo => 42 }, $b); + $b->{bar} = 123; + $self->assert_deep_equals({ foo => 42 }, $a); + $self->assert_deep_equals({ foo => 42, bar => 123 }, $b); + delete $b->{foo}; + $self->assert_deep_equals({ foo => 42 }, $a); + $self->assert_deep_equals({ bar => 123 }, $b); +} + +sub test_array +{ + my ($self) = @_; + my $a = [ 42 ]; + my $b = clone($a); + $self->assert_deep_equals([ 42 ], $a); + $self->assert_deep_equals([ 42 ], $b); + push(@$b, 123); + $self->assert_deep_equals([ 42 ], $a); + $self->assert_deep_equals([ 42, 123 ], $b); + shift @$b; + $self->assert_deep_equals([ 42 ], $a); + $self->assert_deep_equals([ 123 ], $b); +} + +sub test_complex +{ + my ($self) = @_; + my $a = { foo => [ { x => 42, y => 123 } ], + bar => { quux => 37, foonly => 475 } }; + my $b = clone($a); + $self->assert_deep_equals($a, $b); +} + +1; diff --git a/cassandane/Cassandane/Test/Config.pm b/cassandane/Cassandane/Test/Config.pm new file mode 100644 index 0000000000..fc24d4001e --- /dev/null +++ b/cassandane/Cassandane/Test/Config.pm @@ -0,0 +1,433 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Config; +use strict; +use warnings; +use Data::Dumper; +use File::Temp qw(tempfile); + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Config; +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +sub test_default +{ + my ($self) = @_; + + my $c = Cassandane::Config->default(); + $self->assert(defined $c); + $self->assert(!defined $c->get('hello')); + + my $c2 = Cassandane::Config->default(); + $self->assert(defined $c2); + $self->assert($c2 eq $c); + $self->assert(!defined $c->get('hello')); + $self->assert(!defined $c2->get('hello')); + + $c->set(hello => 'world'); + $self->assert($c->get('hello') eq 'world'); + $self->assert($c2->get('hello') eq 'world'); + + $c->set(hello => undef); + $self->assert(!defined $c->get('hello')); + $self->assert(!defined $c2->get('hello')); +} + +sub test_clone +{ + my ($self) = @_; + + my $c = Cassandane::Config->new(); + $self->assert(!defined $c->get('hello')); + $self->assert(!defined $c->get('foo')); + + my $c2 = $c->clone(); + $self->assert($c2 ne $c); + $self->assert(!defined $c->get('hello')); + $self->assert(!defined $c2->get('hello')); + $self->assert(!defined $c->get('foo')); + $self->assert(!defined $c2->get('foo')); + + $c2->set(hello => 'world'); + $self->assert(!defined $c->get('hello')); + $self->assert($c2->get('hello') eq 'world'); + $self->assert(!defined $c->get('foo')); + $self->assert(!defined $c2->get('foo')); + + $c->set(foo => 'bar'); + $self->assert(!defined $c->get('hello')); + $self->assert($c2->get('hello') eq 'world'); + $self->assert($c->get('foo') eq 'bar'); + $self->assert($c2->get('foo') eq 'bar'); + + $c2->set(foo => 'baz'); + $self->assert(!defined $c->get('hello')); + $self->assert($c2->get('hello') eq 'world'); + $self->assert($c->get('foo') eq 'bar'); + $self->assert($c2->get('foo') eq 'baz'); + + $c2->set(foo => undef); + $self->assert(!defined $c->get('hello')); + $self->assert($c2->get('hello') eq 'world'); + $self->assert($c->get('foo') eq 'bar'); + $self->assert(!defined $c2->get('foo')); + + $c->set(foo => undef); + $self->assert(!defined $c->get('hello')); + $self->assert($c2->get('hello') eq 'world'); + $self->assert(!defined $c->get('foo')); + $self->assert(!defined $c2->get('foo')); + + $c2->set(hello => undef); + $self->assert(!defined $c->get('hello')); + $self->assert(!defined $c2->get('hello')); + $self->assert(!defined $c->get('foo')); + $self->assert(!defined $c2->get('foo')); +} + +sub _generate_and_read +{ + my ($self, $c) = @_; + + # Write the file + my ($fh, $filename) = tempfile() + or die "Cannot open temporary file: $!"; + $c->generate($filename); + + # read it back again to check + my %nv; + while (<$fh>) + { + chomp; + my ($n, $v) = m/^([^:\s]+):\s*(.+)*$/; + $self->assert(defined $v); + $nv{$n} = $v; + } + + close $fh; + unlink $filename; + + return \%nv; +} + +sub test_generate +{ + my ($self) = @_; + + my $c = Cassandane::Config->new(); + $c->set(foo => 'bar'); + $c->set(quux => 'foonly'); + $c->set('httpmodules', 'caldav jmap'); + $c->set('event_groups', [qw(quota)]); + + my $c2 = $c->clone(); + $c2->set(hello => 'world'); + $c2->set(foo => 'baz'); + $c2->set('event_groups', [qw(flags quota)]); + + my $nv = $self->_generate_and_read($c2); + + $self->assert_num_equals(5, scalar(keys(%$nv))); + $self->assert_str_equals('baz', $nv->{foo}); + $self->assert_str_equals('world', $nv->{hello}); + $self->assert_str_equals('foonly', $nv->{quux}); + $self->assert_str_equals('caldav jmap', $nv->{httpmodules}); + $self->assert_str_equals('flags quota', $nv->{event_groups}); +} + +sub test_variables +{ + my ($self) = @_; + + my $c = Cassandane::Config->new(); + $c->set(foo => 'b@grade@r'); + $c->set(quux => 'fo@grade@nly'); + my $c2 = $c->clone(); + $c2->set(hello => 'w@grade@rld'); + $c2->set(foo => 'baz'); + + # missing @grade@ variable throws an exception + my $nv; + eval + { + $nv = $self->_generate_and_read($c2); + }; + $self->assert(defined $@ && $@ =~ m/Variable grade not defined/i); + + # @grade@ on the parent affects all variable expansions + $c->set_variables('grade' => 'B'); + $nv = $self->_generate_and_read($c2); + $self->assert_num_equals(3, scalar(keys(%$nv))); + $self->assert_str_equals('baz', $nv->{foo}); + $self->assert_str_equals('wBrld', $nv->{hello}); + $self->assert_str_equals('foBnly', $nv->{quux}); + + # @grade@ on the child overrides @grade@ on the parent + $c2->set_variables('grade' => 'A'); + $nv = $self->_generate_and_read($c2); + $self->assert_num_equals(scalar(keys(%$nv)), 3); + $self->assert_str_equals('baz', $nv->{foo}); + $self->assert_str_equals('wArld', $nv->{hello}); + $self->assert_str_equals('foAnly', $nv->{quux}); +} + +sub test_bitfields +{ + my ($self) = @_; + + my $c = Cassandane::Config->new(); + + # can set bitfields as space separated strings + $c->set('httpmodules' => 'caldav jmap'); + # get in scalar context returns space separated string + $self->assert_str_equals('caldav jmap', scalar $c->get('httpmodules')); + # get in list context returns list + $self->assert_deep_equals([qw(caldav jmap)], [$c->get('httpmodules')]); + + # can clear a whole bitfield + $c->clear_all_bits('httpmodules'); + $self->assert_null($c->get('httpmodules')); + + # can set bitfields as array reference + $c->set('httpmodules' => [qw(caldav jmap)]); + # get in scalar context returns space separated string + $self->assert_str_equals('caldav jmap', scalar $c->get('httpmodules')); + # get in list context returns list + $self->assert_deep_equals([qw(caldav jmap)], [$c->get('httpmodules')]); + + # can clear one bit + $c->clear_bits('httpmodules', 'caldav'); + $self->assert_str_equals('jmap', $c->get('httpmodules')); + + # can set one bit + $c->set_bits('httpmodules', 'prometheus'); + $self->assert_str_equals('jmap prometheus', scalar $c->get('httpmodules')); + + # can get one bit + $self->assert($c->get_bit('httpmodules', 'prometheus')); + $self->assert($c->get_bit('httpmodules', 'jmap')); + # valid bits that aren't set are false + $self->assert(not $c->get_bit('httpmodules', 'caldav')); + $self->assert(not $c->get_bit('httpmodules', 'freebusy')); + + # can set a few bits + $c->set_bits('httpmodules', 'caldav', 'carddav'); + $self->assert_str_equals('caldav carddav jmap prometheus', + scalar $c->get('httpmodules')); + $c->set_bits('httpmodules', 'ischedule rss'); + $self->assert_str_equals('caldav carddav ischedule jmap prometheus rss', + scalar $c->get('httpmodules')); + + # can clear a few bits + $c->clear_bits('httpmodules', 'caldav', 'carddav'); + $self->assert_str_equals('ischedule jmap prometheus rss', + scalar $c->get('httpmodules')); + $c->clear_bits('httpmodules', 'ischedule rss'); + $self->assert_str_equals('jmap prometheus', + scalar $c->get('httpmodules')); + + # setting with set() should replace previous bit set + $c->set('httpmodules' => [qw(admin tzdist)]); + $self->assert(not $c->get_bit('httpmodules', 'prometheus')); + $self->assert(not $c->get_bit('httpmodules', 'jmap')); + $self->assert($c->get_bit('httpmodules', 'admin')); + $self->assert($c->get_bit('httpmodules', 'tzdist')); + $self->assert_str_equals('admin tzdist', scalar $c->get('httpmodules')); + + # cannot set bits on non-bitfield options + eval { + $c->set_bits('conversations', 'irrelevant'); + }; + my $e = $@; + $self->assert_matches(qr{conversations is not a bitfield option}, $e); + + # cannot set invalid bits on bitfield options + eval { + $c->set_bits('httpmodules', 'bogus'); + }; + $e = $@; + $self->assert_matches(qr{bogus is not a httpmodules value}, $e); + + # cannot mix and match bits from other bitfields + eval { + $c->set_bits('httpmodules', 'VEVENT'); + }; + $e = $@; + $self->assert_matches(qr{VEVENT is not a httpmodules value}, $e); + + # should be able to set valid bitfields in constructor + my $c2 = Cassandane::Config->new('foo' => 'bar', + 'httpmodules' => 'caldav jmap', + 'event_groups' => [qw(message quota)]); + $self->assert_not_null($c2); + + # expectations should still hold for bitfields set via constructor + $self->assert_str_equals('bar', $c2->get('foo')); + $self->assert_str_equals('caldav jmap', scalar $c2->get('httpmodules')); + $self->assert_deep_equals([qw(caldav jmap)], [$c2->get('httpmodules')]); + $self->assert_str_equals('message quota', scalar $c2->get('event_groups')); + $self->assert_deep_equals([qw(message quota)], [$c2->get('event_groups')]); + + # should be able to set bitfield values containing underscores + $c->set_bits('sieve_extensions', 'vnd.cyrus.implicit_keep_target'); + $self->assert_str_equals('vnd.cyrus.implicit_keep_target', + scalar $c->get('sieve_extensions')); + $self->assert_deep_equals([qw(vnd.cyrus.implicit_keep_target)], + [$c->get('sieve_extensions')]); +} + +sub test_clone_bitfields +{ + my ($self) = @_; + + my $c = Cassandane::Config->new(); + $self->assert_null($c->get('httpmodules')); + $self->assert_null($c->get('event_groups')); + + my $c2 = $c->clone(); + $self->assert($c2 ne $c); + $self->assert_null($c->get('httpmodules')); + $self->assert_null($c2->get('httpmodules')); + $self->assert_null($c->get('event_groups')); + $self->assert_null($c2->get('event_groups')); + + # set bit in clone doesn't affect parent + $c2->set_bits('httpmodules', 'caldav'); + $self->assert_null($c->get('httpmodules')); + $self->assert_str_equals('caldav', scalar $c2->get('httpmodules')); + $self->assert_null($c->get('event_groups')); + $self->assert_null($c2->get('event_groups')); + + # set bit in parent is inherited by child + $c->set_bits('event_groups', 'access', 'mailbox'); + $self->assert_null($c->get('httpmodules')); + $self->assert_str_equals('caldav', scalar $c2->get('httpmodules')); + $self->assert_str_equals('access mailbox', scalar $c->get('event_groups')); + $self->assert_str_equals('access mailbox', scalar $c2->get('event_groups')); + + # set bit in child supplements parent + $c2->set_bits('event_groups', 'quota'); + $self->assert_null($c->get('httpmodules')); + $self->assert_str_equals('caldav', scalar $c2->get('httpmodules')); + $self->assert_str_equals('access mailbox', scalar $c->get('event_groups')); + $self->assert_str_equals('access mailbox quota', scalar $c2->get('event_groups')); + + # clear bit in child overrides parent + $c2->clear_bits('event_groups', 'mailbox'); + $self->assert_null($c->get('httpmodules')); + $self->assert_str_equals('caldav', scalar $c2->get('httpmodules')); + $self->assert_str_equals('access mailbox', scalar $c->get('event_groups')); + $self->assert_str_equals('access quota', scalar $c2->get('event_groups')); + + # clear bit in parent updates inheriting child + $c->clear_bits('event_groups', 'access'); + $self->assert_null($c->get('httpmodules')); + $self->assert_str_equals('caldav', scalar $c2->get('httpmodules')); + $self->assert_str_equals('mailbox', scalar $c->get('event_groups')); + $self->assert_str_equals('quota', scalar $c2->get('event_groups')); + + # clear bit in child updates child + $c2->clear_bits('event_groups', 'quota'); + $self->assert_null($c->get('httpmodules')); + $self->assert_str_equals('caldav', scalar $c2->get('httpmodules')); + $self->assert_str_equals('mailbox', scalar $c->get('event_groups')); + $self->assert_null($c2->get('event_groups')); + + # set explicit list in parent updates child + $c->set('httpmodules', 'jmap prometheus carddav'); + $self->assert_str_equals('carddav jmap prometheus', + scalar $c->get('httpmodules')); + $self->assert_str_equals('caldav carddav jmap prometheus', + scalar $c2->get('httpmodules')); + $self->assert_str_equals('mailbox', scalar $c->get('event_groups')); + $self->assert_null($c2->get('event_groups')); + + # clear all in child overrides parent + $c2->clear_all_bits('httpmodules'); + $self->assert_str_equals('carddav jmap prometheus', + scalar $c->get('httpmodules')); + $self->assert_null($c2->get('httpmodules')); + $self->assert_str_equals('mailbox', scalar $c->get('event_groups')); + $self->assert_null($c2->get('event_groups')); + + # discard clone and recreate, clone should be the same as parent again + undef $c2; + $c2 = $c->clone(); + $self->assert_not_equals($c, $c2); + $self->assert_equals(scalar $c->get('httpmodules'), + scalar $c2->get('httpmodules')); + $self->assert_equals(scalar $c->get('event_groups'), + scalar $c2->get('event_groups')); + + # bit set in both parent and child is only listed once + $c2->set_bits('httpmodules', 'jmap'); + $self->assert_str_equals('carddav jmap prometheus', + scalar $c->get('httpmodules')); + $self->assert_str_equals('carddav jmap prometheus', + scalar $c2->get('httpmodules')); + $self->assert_str_equals('mailbox', scalar $c->get('event_groups')); + $self->assert_str_equals('mailbox', scalar $c2->get('event_groups')); + + # clearing bit in parent doesn't affect child who has it explicitly set + $c->clear_bits('httpmodules', 'jmap'); + $self->assert_str_equals('carddav prometheus', + scalar $c->get('httpmodules')); + $self->assert_str_equals('carddav jmap prometheus', + scalar $c2->get('httpmodules')); + $self->assert_str_equals('mailbox', scalar $c->get('event_groups')); + $self->assert_str_equals('mailbox', scalar $c2->get('event_groups')); + + # clearing all in parent doesn't affect child's explicit bits + $c->clear_all_bits('httpmodules'); + $self->assert_null($c->get('httpmodules')); + $self->assert_str_equals('jmap', scalar $c2->get('httpmodules')); + $self->assert_str_equals('mailbox', scalar $c->get('event_groups')); + $self->assert_str_equals('mailbox', scalar $c2->get('event_groups')); +} + +1; diff --git a/cassandane/Cassandane/Test/Core.pm b/cassandane/Cassandane/Test/Core.pm new file mode 100644 index 0000000000..757c551374 --- /dev/null +++ b/cassandane/Cassandane/Test/Core.pm @@ -0,0 +1,143 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Core; +use strict; +use warnings; +use Data::Dumper; +use POSIX qw(getcwd); + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); +use Cassandane::Util::Log; + +my $crash_bin = getcwd() . '/utils/crash'; + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub set_up +{ + my ($self) = @_; + die "No crash binary $crash_bin. Did you run \"make\" in the Cassandane directory?" + unless (-f $crash_bin); + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub _test_core_files_with_size +{ + my ($self, $alloc) = @_; + + my $instance = $self->{instance}; + my $signaled = 0; #n.b. spelling + my $pid; + + $instance->run_command( + { cyrus => 0, + handlers => { + signaled => sub { + my ($child, $sig) = @_; + $pid = $child->{pid}; + $signaled++; + } }, + }, + $crash_bin, $alloc); + + $self->assert_equals(1, $signaled); + $self->assert_not_null($pid); + + my @cores = $instance->find_cores(); + + # expect there's exactly one core + $self->assert_num_equals(1, scalar @cores); + + my $cassini = Cassandane::Cassini->instance(); + my $core_pattern = $cassini->get_core_pattern(); + + my $core = shift @cores; + if ($core =~ m/$core_pattern/ && $1) { + # if there's a pid in the filename, check it + $self->assert_num_equals($pid, $1); + } + my $size = -s $core; + + # clean up the core we expected, so we don't barf on it existing! + unlink $core or die "unlink $core: $!"; + # but don't clean up any other unexpected cores! + + $self->assert($size > $alloc); +} + +sub test_core_files_1KB +{ + shift->_test_core_files_with_size(1 * 1024); +} + +sub test_core_files_1MB +{ + shift->_test_core_files_with_size(1 * 1024 * 1024); +} + +sub test_core_files_5MB +{ + shift->_test_core_files_with_size(5 * 1024 * 1024); +} + +sub test_core_files_10MB +{ + shift->_test_core_files_with_size(10 * 1024 * 1024); +} + +sub test_core_files_50MB +{ + shift->_test_core_files_with_size(50 * 1024 * 1024); +} + +# Cassandane::Instance::_fork_command limits core sizes to 100MB + +1; diff --git a/cassandane/Cassandane/Test/DateTime.pm b/cassandane/Cassandane/Test/DateTime.pm new file mode 100644 index 0000000000..9932fe6b0d --- /dev/null +++ b/cassandane/Cassandane/Test/DateTime.pm @@ -0,0 +1,115 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::DateTime; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Util::DateTime; + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +sub test_basic +{ + my ($self) = @_; + $self->assert_num_equals(1287073192, from_iso8601('20101014T161952Z')->epoch); + $self->assert_num_equals(1287073192, from_rfc822('Fri, 15 Oct 2010 03:19:52 +1100')->epoch); + $self->assert_num_equals(1287073192, from_rfc3501('15-Oct-2010 03:19:52 +1100')->epoch); + $self->assert_str_equals('20101014T161952Z', to_iso8601(DateTime->from_epoch(epoch => 1287073192))); + local $ENV{TZ} = "Australia/Melbourne"; + $self->assert_str_equals('Fri, 15 Oct 2010 03:19:52 +1100', to_rfc822(DateTime->from_epoch(epoch => 1287073192))); + $self->assert_str_equals('15-Oct-2010 03:19:52 +1100', to_rfc3501(DateTime->from_epoch(epoch => 1287073192))); +} + +sub test_localtime_month_ahead_of_utc +{ + my ($self) = @_; + + # early morning 2023-09-01 UTC+10 + # late evening 2023-08-31 UTC + my $dt = DateTime->new( + year => 2023, + month => 9, + day => 1, + hour => 9, + minute => 30, + second => 0, + time_zone => 'Australia/Sydney', + ); + $dt->set_time_zone('Etc/UTC'); + + local $ENV{TZ} = 'Australia/Sydney'; + $self->assert_str_equals('Fri, 01 Sep 2023 09:30:00 +1000', + to_rfc822($dt)); + $self->assert_str_equals(' 1-Sep-2023 09:30:00 +1000', + to_rfc3501($dt)); +} + +sub test_localtime_month_behind_utc +{ + my ($self) = @_; + + # late evening 2023-08-31 UTC-4 + # early morning 2023-09-01 UTC + my $dt = DateTime->new( + year => 2023, + month => 8, + day => 31, + hour => 22, + minute => 30, + second => 0, + time_zone => 'America/New_York', + ); + $dt->set_time_zone('Etc/UTC'); + + local $ENV{TZ} = 'America/New_York'; + $self->assert_str_equals('Thu, 31 Aug 2023 22:30:00 -0400', + to_rfc822($dt)); + $self->assert_str_equals('31-Aug-2023 22:30:00 -0400', + to_rfc3501($dt)); +} + +1; diff --git a/cassandane/Cassandane/Test/Mboxname.pm b/cassandane/Cassandane/Test/Mboxname.pm new file mode 100644 index 0000000000..a831ce8669 --- /dev/null +++ b/cassandane/Cassandane/Test/Mboxname.pm @@ -0,0 +1,300 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Mboxname; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Mboxname; +use Cassandane::Config; + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +sub myconfig +{ + my $conf = Cassandane::Config::default(); + $conf->set(virtdomains => 'userid'); + return $conf; +} + +sub test_default_ctor +{ + my ($self) = @_; + my $mb = Cassandane::Mboxname->new(); + $self->assert_null($mb->domain); + $self->assert_null($mb->userid); + $self->assert_null($mb->box); + $self->assert_null($mb->to_internal); + $self->assert_null($mb->to_external); + $self->assert_null($mb->to_username); +} + +sub test_parts_ctor +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new( + domain => 'quinoa.com', + userid => 'pickled', + box => 'fanny.pack'); + $self->assert_str_equals($mb->domain, 'quinoa.com'); + $self->assert_str_equals($mb->userid, 'pickled'); + $self->assert_str_equals($mb->box, 'fanny.pack'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb->to_external, + 'user.pickled.fanny.pack@quinoa.com'); + $self->assert_str_equals($mb->to_username, + 'pickled@quinoa.com'); +} + +sub test_internal_ctor +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new( + config => myconfig(), + internal => 'quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb->domain, 'quinoa.com'); + $self->assert_str_equals($mb->userid, 'pickled'); + $self->assert_str_equals($mb->box, 'fanny.pack'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb->to_external, + 'user.pickled.fanny.pack@quinoa.com'); + $self->assert_str_equals($mb->to_username, + 'pickled@quinoa.com'); +} + +sub test_external_ctor +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new( + config => myconfig(), + external => 'user.pickled.fanny.pack@quinoa.com'); + $self->assert_str_equals($mb->domain, 'quinoa.com'); + $self->assert_str_equals($mb->userid, 'pickled'); + $self->assert_str_equals($mb->box, 'fanny.pack'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb->to_external, + 'user.pickled.fanny.pack@quinoa.com'); + $self->assert_str_equals($mb->to_username, + 'pickled@quinoa.com'); +} + +sub test_username_ctor +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new( + config => myconfig(), + username => 'pickled@quinoa.com'); + $self->assert_str_equals($mb->domain, 'quinoa.com'); + $self->assert_str_equals($mb->userid, 'pickled'); + $self->assert_null($mb->box); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled'); + $self->assert_str_equals($mb->to_external, + 'user.pickled@quinoa.com'); + $self->assert_str_equals($mb->to_username, + 'pickled@quinoa.com'); +} + +sub test_broken_ctor +{ + my ($self) = @_; + my $mb; + my $ex; + + eval + { + $mb = Cassandane::Mboxname->new( + config => myconfig(), + internal => 'quinoa.com!user.pickled.fanny.pack', + username => 'pickled@quinoa.com'); + }; + $ex = $@; + $self->assert_matches(qr/contradictory initialisers/, $ex); + + eval + { + $mb = Cassandane::Mboxname->new( + config => myconfig(), + external => 'user.pickled.fanny.pack@quinoa.com', + username => 'pickled@quinoa.com'); + }; + $ex = $@; + $self->assert_matches(qr/contradictory initialisers/, $ex); + + eval + { + $mb = Cassandane::Mboxname->new( + config => myconfig(), + internal => 'quinoa.com!user.pickled.fanny.pack', + external => 'user.pickled.fanny.pack@quinoa.com'); + }; + $ex = $@; + $self->assert_matches(qr/contradictory initialisers/, $ex); + + eval + { + $mb = Cassandane::Mboxname->new( + config => myconfig(), + internal => 'quinoa.com!user.pickled.fanny.pack', + selvage => 'sustainble'); + }; + $ex = $@; + $self->assert_matches(qr/extra arguments/, $ex); +} + +sub test_from_internal +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new(config => myconfig()); + $mb->from_internal('quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb->domain, 'quinoa.com'); + $self->assert_str_equals($mb->userid, 'pickled'); + $self->assert_str_equals($mb->box, 'fanny.pack'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb->to_external, + 'user.pickled.fanny.pack@quinoa.com'); + $self->assert_str_equals($mb->to_username, + 'pickled@quinoa.com'); +} + +sub test_from_external +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new(config => myconfig()); + $mb->from_external('user.pickled.fanny.pack@quinoa.com'); + $self->assert_str_equals($mb->domain, 'quinoa.com'); + $self->assert_str_equals($mb->userid, 'pickled'); + $self->assert_str_equals($mb->box, 'fanny.pack'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb->to_external, + 'user.pickled.fanny.pack@quinoa.com'); + $self->assert_str_equals($mb->to_username, + 'pickled@quinoa.com'); +} + +sub test_from_username +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new(config => myconfig()); + $mb->from_username('pickled@quinoa.com'); + $self->assert_str_equals($mb->domain, 'quinoa.com'); + $self->assert_str_equals($mb->userid, 'pickled'); + $self->assert_null($mb->box); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled'); + $self->assert_str_equals($mb->to_external, + 'user.pickled@quinoa.com'); + $self->assert_str_equals($mb->to_username, + 'pickled@quinoa.com'); +} + +sub test_make_child +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new( + config => myconfig(), + internal => 'quinoa.com!user.pickled'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled'); + + my $mb2 = $mb->make_child('fanny'); + $self->assert_str_equals($mb2->to_internal, + 'quinoa.com!user.pickled.fanny'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled'); + + my $mb3 = $mb2->make_child('pack'); + $self->assert_str_equals($mb3->to_internal, + 'quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb2->to_internal, + 'quinoa.com!user.pickled.fanny'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled'); +} + +sub test_make_parent +{ + my ($self) = @_; + + my $mb = Cassandane::Mboxname->new( + config => myconfig(), + internal => 'quinoa.com!user.pickled.fanny.pack'); + $self->assert_str_equals($mb->to_internal, + 'quinoa.com!user.pickled.fanny.pack'); + + my $mb2 = $mb->make_parent(); + $self->assert_str_equals($mb2->to_internal, + 'quinoa.com!user.pickled.fanny'); + + my $mb3 = $mb2->make_parent(); + $self->assert_str_equals($mb3->to_internal, + 'quinoa.com!user.pickled'); + $self->assert_str_equals($mb2->to_internal, + 'quinoa.com!user.pickled.fanny'); + + my $mb4 = $mb3->make_parent(); + $self->assert_str_equals($mb4->to_internal, + 'quinoa.com!user.pickled'); + $self->assert_str_equals($mb3->to_internal, + 'quinoa.com!user.pickled'); + $self->assert_str_equals($mb2->to_internal, + 'quinoa.com!user.pickled.fanny'); +} + +1; diff --git a/cassandane/Cassandane/Test/Message.pm b/cassandane/Cassandane/Test/Message.pm new file mode 100755 index 0000000000..2e335d00b5 --- /dev/null +++ b/cassandane/Cassandane/Test/Message.pm @@ -0,0 +1,880 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Message; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Message; +use Cassandane::Address; +use Cassandane::Util::Log; +use Cassandane::Util::DateTime qw(to_rfc3501); + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +# Test default ctor +sub test_empty +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $self->assert_null($m->get_headers('from')); + $self->assert_null($m->get_headers('to')); + $self->assert_null($m->get_body()); + + my $exp = <<'EOF'; + +EOF + $exp =~ s/\n/\r\n/g; + + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test case sensitivity of header names +sub test_header_case +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('SUBJECT', 'Hello World'); + $self->assert_null($m->get_headers('from')); + $self->assert_null($m->get_headers('to')); + $self->assert_null($m->get_body); + $self->assert_str_equals('Hello World', $m->get_headers('SUBJECT')->[0]); + $self->assert_str_equals('Hello World', $m->get_headers('Subject')->[0]); + $self->assert_str_equals('Hello World', $m->get_headers('subject')->[0]); + $self->assert_str_equals('Hello World', $m->get_headers('sUbJeCt')->[0]); + + my $exp = <<'EOF'; +Subject: Hello World + +EOF + $exp =~ s/\n/\r\n/g; + + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test implicit stringification of Addresses when passing to headers +sub test_address_stringification +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + $m->add_header('From', Cassandane::Address->new( + name => 'Fred J. Bloggs', + localpart => 'fbloggs', + domain => 'fastmail.fm')); + $self->assert_null($m->get_headers('to')); + $self->assert_null($m->get_body); + $self->assert_str_equals('Fred J. Bloggs ', + $m->get_headers('from')->[0]); + my $exp = <<'EOF'; +Subject: Hello World +From: Fred J. Bloggs + +EOF + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test stringification of a list of Addresses when passing to headers +sub test_address_list_stringification +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + my @tos = ( + Cassandane::Address->new( + name => 'Sarah Jane Smith', + localpart => 'sjsmith', + domain => 'tard.is'), + Cassandane::Address->new( + name => 'Genghis Khan', + localpart => 'gkhan', + domain => 'horde.mo'), + ); + $m->add_header('To', join(', ', @tos)); + $self->assert_null($m->get_body()); + $self->assert_null($m->get_headers('from')); + $self->assert_str_equals( + 'Sarah Jane Smith , Genghis Khan ', + $m->get_headers('to')->[0]); + my $exp = <<'EOF'; +Subject: Hello World +To: Sarah Jane Smith , Genghis Khan + +EOF + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test multiple headers with the same name +sub test_multiple_headers +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + $m->add_header("received", "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); Fri, 29 Oct 2010 13:05:01 +1100"); + $m->add_header("received", "from mail.bar.com (mail.bar.com [10.0.0.1]) by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100"); + $m->add_header("received", "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100"); + $self->assert_deep_equals([ + "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); Fri, 29 Oct 2010 13:05:01 +1100", + "from mail.bar.com (mail.bar.com [10.0.0.1]) by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100", + "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100", + ], $m->get_headers("received")); + my $exp = <<'EOF'; +Subject: Hello World +Received: from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); Fri, 29 Oct 2010 13:05:01 +1100 +Received: from mail.bar.com (mail.bar.com [10.0.0.1]) by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100 +Received: from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100 + +EOF + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + + +# Test replacing headers +sub test_replacing_headers +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + $self->assert_str_equals( + 'Hello World', + $m->get_header('subject')); + $m->set_headers('subject', 'No, scratch that'); + $self->assert_str_equals( + 'No, scratch that', + $m->get_header('subject')); + my $exp = <<'EOF'; +Subject: No, scratch that + +EOF + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test deleting headers +sub test_deleting_headers +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + $m->add_header("received", "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); Fri, 29 Oct 2010 13:05:01 +1100"); + $m->add_header("received", "from mail.bar.com (mail.bar.com [10.0.0.1]) by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100"); + $m->add_header("received", "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100"); + $self->assert_str_equals('Hello World', $m->get_header('subject')); + $m->remove_headers('subject'); + $self->assert_null($m->get_header('subject')); + my $exp = <<'EOF'; +Received: from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); Fri, 29 Oct 2010 13:05:01 +1100 +Received: from mail.bar.com (mail.bar.com [10.0.0.1]) by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100 +Received: from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100 + +EOF + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test adding a body -- only plain text for now, no MIME +sub test_add_body +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + $m->set_body("This is a message to let you know\r\nthat I'm alive and well\r\n"); + my $exp = <<'EOF'; +Subject: Hello World + +This is a message to let you know +that I'm alive and well +EOF + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test setting lines. +sub test_setting_lines +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + + my $txt = <<'EOF'; +From: Fred J. Bloggs +To: Sarah Jane Smith , Genghis Khan +Subject: Hello World +Received: from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); + Fri, 29 Oct 2010 13:05:01 +1100 +Received: from mail.bar.com (mail.bar.com [10.0.0.1]) + by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100 +Received: from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by + mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100 + +This is a message to let you know +that I'm alive and well +EOF + my @lines = split(/\n/, $txt); + map { $_ .= "\r\n" } @lines; + + my $exp = $txt; + $exp =~ s/\n/\r\n/g; + + $m->set_lines(@lines); + $self->assert_str_equals( + 'Fred J. Bloggs ', + $m->get_headers('from')->[0]); + $self->assert_str_equals( + 'Sarah Jane Smith , Genghis Khan ', + $m->get_headers('to')->[0]); + $self->assert_str_equals( + 'Hello World', + $m->get_headers('Subject')->[0]); + $self->assert_deep_equals([ + "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software);\r\n\tFri, 29 Oct 2010 13:05:01 +1100", + "from mail.bar.com (mail.bar.com [10.0.0.1])\r\n\tby mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100", + "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by\r\n\tmail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100", + ], $m->get_headers('received')); + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); + + $m = Cassandane::Message->new(lines => \@lines); + $self->assert_str_equals( + 'Fred J. Bloggs ', + $m->get_headers('from')->[0]); + $self->assert_str_equals( + 'Sarah Jane Smith , Genghis Khan ', + $m->get_headers('to')->[0]); + $self->assert_str_equals( + 'Hello World', + $m->get_headers('Subject')->[0]); + $self->assert_deep_equals([ + "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software);\r\n\tFri, 29 Oct 2010 13:05:01 +1100", + "from mail.bar.com (mail.bar.com [10.0.0.1])\r\n\tby mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100", + "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by\r\n\tmail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100", + ], $m->get_headers('received')); + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test setting raw text +sub test_setting_raw +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + + my $txt = <<'EOF'; +From: Fred J. Bloggs +To: Sarah Jane Smith , Genghis Khan +Subject: Hello World +Received: from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); + Fri, 29 Oct 2010 13:05:01 +1100 +Received: from mail.bar.com (mail.bar.com [10.0.0.1]) + by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100 +Received: from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by + mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100 + +This is a message to let you know +that I'm alive and well +EOF + $txt =~ s/\n/\r\n/g; + + my $exp = $txt; + + $m->set_raw($txt); + $self->assert_str_equals( + 'Fred J. Bloggs ', + $m->get_headers('from')->[0]); + $self->assert_str_equals( + 'Sarah Jane Smith , Genghis Khan ', + $m->get_headers('to')->[0]); + $self->assert_str_equals( + 'Hello World', + $m->get_headers('Subject')->[0]); + $self->assert_deep_equals([ + "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software);\r\n\tFri, 29 Oct 2010 13:05:01 +1100", + "from mail.bar.com (mail.bar.com [10.0.0.1])\r\n\tby mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100", + "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by\r\n\tmail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100", + ], $m->get_headers('received')); + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); + + $m = Cassandane::Message->new(raw => $txt); + $self->assert_str_equals( + 'Fred J. Bloggs ', + $m->get_headers('from')->[0]); + $self->assert_str_equals( + 'Sarah Jane Smith , Genghis Khan ', + $m->get_headers('to')->[0]); + $self->assert_str_equals( + 'Hello World', + $m->get_headers('Subject')->[0]); + $self->assert_deep_equals([ + "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software);\r\n\tFri, 29 Oct 2010 13:05:01 +1100", + "from mail.bar.com (mail.bar.com [10.0.0.1])\r\n\tby mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100", + "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by\r\n\tmail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100", + ], $m->get_headers('received')); + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test message attributes +sub test_attributes +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + + $self->assert(!$m->has_attribute('uid')); + $self->assert_null($m->get_attribute('uid')); + $self->assert_null($m->get_attribute('UID')); + $self->assert_null($m->get_attribute('uId')); + $self->assert(!$m->has_attribute('internaldate')); + $self->assert_null($m->get_attribute('internaldate')); + + $m->set_attribute('uid', 123); + $self->assert($m->has_attribute('uid')); + $self->assert($m->get_attribute('uid') == 123); + $self->assert($m->get_attribute('UID') == 123); + $self->assert($m->get_attribute('uId') == 123); + $self->assert(!$m->has_attribute('internaldate')); + $self->assert_null($m->get_attribute('internaldate')); + + $m->set_attribute('uid'); + $self->assert($m->has_attribute('uid')); + $self->assert_null($m->get_attribute('uid')); + $self->assert_null($m->get_attribute('UID')); + $self->assert_null($m->get_attribute('uId')); + $self->assert(!$m->has_attribute('internaldate')); + $self->assert_null($m->get_attribute('internaldate')); + + $m->set_internaldate('15-Oct-2010 03:19:52 +1100'); + $self->assert($m->has_attribute('internaldate')); + $self->assert_str_equals('15-Oct-2010 03:19:52 +1100', + $m->get_attribute('internaldate')); + $m->set_internaldate(undef); + $self->assert($m->has_attribute('internaldate')); + $self->assert_null($m->get_attribute('internaldate')); + my $dt = DateTime->new( + year => 2010, + month => 10, + day => 15, + hour => 3, + minute => 19, + second => 47, + time_zone => 'Australia/Melbourne'); + $m->set_internaldate($dt); + $self->assert($m->has_attribute('internaldate')); + $self->assert_str_equals(to_rfc3501($dt), + $m->get_attribute('internaldate')); + + $m = Cassandane::Message->new(attrs => { UID => 456 }); + $self->assert($m->has_attribute('uid')); + $self->assert($m->get_attribute('uid') == 456); + $self->assert($m->get_attribute('UID') == 456); + $self->assert($m->get_attribute('uId') == 456); + $self->assert(!$m->has_attribute('internaldate')); + $self->assert_null($m->get_attribute('internaldate')); +} + +# Test parsing lines with unusually but validly named headers +sub test_strange_headers +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + + my $txt = <<'EOF'; +From: Fred J. Bloggs +To: Sarah Jane Smith , Genghis Khan +X-Foo_Bar.Baz&Quux: Foonly +Subject: Hello World +Received: from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); + Fri, 29 Oct 2010 13:05:01 +1100 +Received: from mail.bar.com (mail.bar.com [10.0.0.1]) + by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100 +Received: from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by + mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100 + +This is a message to let you know +that I'm alive and well +EOF + my @lines = split(/\n/, $txt); + map { $_ .= "\r\n" } @lines; + + my $exp = $txt; + $exp =~ s/\n/\r\n/g; + + $m->set_lines(@lines); + $self->assert_str_equals( + 'Fred J. Bloggs ', + $m->get_headers('from')->[0]); + $self->assert_str_equals( + 'Sarah Jane Smith , Genghis Khan ', + $m->get_headers('to')->[0]); + $self->assert_str_equals( + 'Foonly', + $m->get_headers('X-Foo_Bar.Baz&Quux')->[0]); + $self->assert_str_equals( + 'Hello World', + $m->get_headers('Subject')->[0]); + $self->assert_deep_equals([ + "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software);\r\n\tFri, 29 Oct 2010 13:05:01 +1100", + "from mail.bar.com (mail.bar.com [10.0.0.1])\r\n\tby mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100", + "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by\r\n\tmail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100", + ], $m->get_headers('received')); + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); + + $m = Cassandane::Message->new(lines => \@lines); + $self->assert_str_equals( + 'Fred J. Bloggs ', + $m->get_headers('from')->[0]); + $self->assert_str_equals( + 'Sarah Jane Smith , Genghis Khan ', + $m->get_headers('to')->[0]); + $self->assert_str_equals( + 'Foonly', + $m->get_headers('X-Foo_Bar.Baz&Quux')->[0]); + $self->assert_str_equals( + 'Hello World', + $m->get_headers('Subject')->[0]); + $self->assert_deep_equals([ + "from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software);\r\n\tFri, 29 Oct 2010 13:05:01 +1100", + "from mail.bar.com (mail.bar.com [10.0.0.1])\r\n\tby mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100", + "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by\r\n\tmail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100", + ], $m->get_headers('received')); + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test parsing lines with unusually but validly named headers +sub test_clone +{ + my ($self) = @_; + + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + $m->set_body("This is a message to let you know\r\nthat I'm alive and well\r\n"); + $m->set_attribute('uid', 42); + + my $exp = <<'EOF'; +Subject: Hello World + +This is a message to let you know +that I'm alive and well +EOF + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_num_equals(42, $m->get_attribute('uid')); + $self->assert_str_equals('Hello World', $m->get_header('subject')); + + my $m2 = $m->clone(); + $self->assert_str_equals($exp, $m2->as_string); + $self->assert_num_equals(42, $m2->get_attribute('uid')); + $self->assert_str_equals('Hello World', $m2->get_header('subject')); + + my $addr = Cassandane::Address->new( + name => 'Fred J. Bloggs', + localpart => 'fbloggs', + domain => 'fastmail.fm'); + $m->add_header('From', $addr); + $self->assert_str_equals($addr->as_string, $m->get_header('from')); + $self->assert_null($m2->get_header('from')); + my $exp2 = <<'EOF'; +Subject: Hello World +From: Fred J. Bloggs + +This is a message to let you know +that I'm alive and well +EOF + $exp2 =~ s/\n/\r\n/g; + $self->assert_str_equals($exp2, $m->as_string); + $self->assert_str_equals($exp, $m2->as_string); +} + +# Test base_subject() +sub test_base_subject +{ + my ($self) = @_; + + my @testcases = ( + 'Hello World' => 'Hello World', + ' Hello World' => 'Hello World', + 'Hello World ' => 'Hello World', + ' Hello World ' => 'Hello World', + ' Hello World ' => 'Hello World', + " \t\t Hello \t\t World \t\t " => 'Hello World', + 're: Hello World' => 'Hello World', + 'Re: Hello World' => 'Hello World', + 'RE: Hello World' => 'Hello World', + 're : Hello World' => 'Hello World', + 'Re : Hello World' => 'Hello World', + 'RE : Hello World' => 'Hello World', + "re \t : Hello World" => 'Hello World', + "Re \t : Hello World" => 'Hello World', + "RE \t : Hello World" => 'Hello World', + 'fw: Hello World' => 'Hello World', + 'Fw: Hello World' => 'Hello World', + 'FW: Hello World' => 'Hello World', + 'fw : Hello World' => 'Hello World', + 'Fw : Hello World' => 'Hello World', + 'FW : Hello World' => 'Hello World', + "fw \t : Hello World" => 'Hello World', + "Fw \t : Hello World" => 'Hello World', + "FW \t : Hello World" => 'Hello World', + 'fwd: Hello World' => 'Hello World', + 'Fwd: Hello World' => 'Hello World', + 'FWD: Hello World' => 'Hello World', + 'fwd : Hello World' => 'Hello World', + 'Fwd : Hello World' => 'Hello World', + 'FWD : Hello World' => 'Hello World', + "fwd \t : Hello World" => 'Hello World', + "Fwd \t : Hello World" => 'Hello World', + "FWD \t : Hello World" => 'Hello World', + "Hello World (fwd)" => 'Hello World', + "Hello World (fwd) \t " => 'Hello World', + "Hello World \t \t (fwd)" => 'Hello World', + "Hello World (FWD) \t" => 'Hello World', + "Hello World \t\t (FWD)" => 'Hello World', + "Hello World (FWD) " => 'Hello World', + " \t\t Hello World" => "Hello World", + "[PATCH]Hello World" => "Hello World", + "[PATCH] Hello World" => "Hello World", + "\t [PATCH] \t Hello World" => "Hello World", + "[RFC][PATCH][WTF]Hello World" => "Hello World", + " [RFC] [PATCH] [WTF] Hello World" => "Hello World", + ); + + while (@testcases) + { + my $in = shift @testcases; + my $exp = shift @testcases; + my $out = base_subject($in); + xlog "base_subject(\"$in\") = \"$out\""; + $self->assert_str_equals($exp, $out); + } + +} + +sub test_attributes2 +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + + $self->assert(!$m->has_attribute('foo')); + $self->assert(!$m->has_attribute('bar')); + $self->assert(!$m->has_attribute('baz')); + + # set_attribute() sets an attribute to the given value + $m->set_attribute(foo => 'cosby'); + $self->assert($m->has_attribute('foo')); + $self->assert($m->has_attribute('Foo')); + $self->assert($m->has_attribute('FOO')); + # attribute names are case-insensitive + $self->assert_str_equals('cosby', $m->get_attribute('foo')); + $self->assert_str_equals('cosby', $m->get_attribute('Foo')); + $self->assert_str_equals('cosby', $m->get_attribute('FOO')); + # other attributes unchanged + $self->assert(!$m->has_attribute('bar')); + $self->assert(!$m->has_attribute('baz')); + + # set_attributes() sets a list of attributes from the + # given list of attribute,value pairs + $m->set_attributes(bar => 'sweater', foo => 'etsy'); + $self->assert($m->has_attribute('foo')); + $self->assert_str_equals('etsy', $m->get_attribute('foo')); + $self->assert($m->has_attribute('bar')); + $self->assert_str_equals('sweater', $m->get_attribute('bar')); + $self->assert(!$m->has_attribute('baz')); + + # set_attribute to an undef value doesn't remove the attribute + # but remembers the undef - this is necessary for strict checking + # of IMAP server responses in a number of cases. + $m->set_attribute(foo => undef); + $self->assert($m->has_attribute('foo')); + $self->assert_null($m->get_attribute('foo')); + $self->assert($m->has_attribute('bar')); + $self->assert_str_equals('sweater', $m->get_attribute('bar')); + $self->assert(!$m->has_attribute('baz')); +} + +sub test_attributes_from_fetch +{ + my ($self) = @_; + my $m = Cassandane::Message->new(attrs => { + foo => 'ethical', + bar => 'pitchfork', + }); + + $self->assert($m->has_attribute('foo')); + $self->assert_str_equals('ethical', $m->get_attribute('foo')); + $self->assert($m->has_attribute('bar')); + $self->assert_str_equals('pitchfork', $m->get_attribute('bar')); + $self->assert(!$m->has_attribute('baz')); +} + +sub test_annotations +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + + my $e1 = '/comment'; + my $a1 = 'value.shared'; + my $e2 = '/vendor/hipsteripsum.me/buzzword'; + my $a2 = 'value.priv'; + + # no annotations on empty message + $self->assert(!$m->has_annotation($e1, $a1)); + $self->assert(!$m->has_annotation($e2, $a2)); + # alternate syntax for has_annotation + $self->assert(!$m->has_annotation({ entry => $e1, attrib => $a1 })); + $self->assert(!$m->has_annotation({ entry => $e2, attrib => $a2 })); + # get_annotation returns no annotations + $self->assert_null($m->get_annotation($e1, $a1)); + $self->assert_null($m->get_annotation($e2, $a2)); + # alternate syntax for get_annotation + $self->assert_null($m->get_annotation({ entry => $e1, attrib => $a1 })); + $self->assert_null($m->get_annotation({ entry => $e2, attrib => $a2 })); + # list_annotations returns no annotations + my @aa = $m->list_annotations(); + $self->assert_deep_equals([], \@aa); + + # set_annotation() sets an annotation to the given value + $m->set_annotation($e1, $a1, 'wayfarers'); + $self->assert($m->has_annotation($e1, $a1)); + $self->assert(!$m->has_annotation($e2, $a2)); + $self->assert($m->has_annotation({ entry => $e1, attrib => $a1 })); + $self->assert(!$m->has_annotation({ entry => $e2, attrib => $a2 })); + $self->assert_str_equals('wayfarers', $m->get_annotation($e1, $a1)); + $self->assert_null($m->get_annotation($e2, $a2)); + $self->assert_str_equals('wayfarers', $m->get_annotation({ entry => $e1, attrib => $a1 })); + $self->assert_null($m->get_annotation({ entry => $e2, attrib => $a2 })); + @aa = $m->list_annotations(); + $self->assert_deep_equals([{entry => $e1, attrib => $a1}], \@aa); + + # set_annotation to an undef value doesn't remove the annotation + # but remembers the undef - this is necessary for strict checking + # of IMAP server responses in a number of cases. + $m->set_annotation($e1, $a1, undef); + $self->assert($m->has_annotation($e1, $a1)); + $self->assert(!$m->has_annotation($e2, $a2)); + $self->assert($m->has_annotation({ entry => $e1, attrib => $a1 })); + $self->assert(!$m->has_annotation({ entry => $e2, attrib => $a2 })); + $self->assert_null($m->get_annotation($e1, $a1)); + $self->assert_null($m->get_annotation($e2, $a2)); + $self->assert_null($m->get_annotation({ entry => $e1, attrib => $a1 })); + $self->assert_null($m->get_annotation({ entry => $e2, attrib => $a2 })); + @aa = $m->list_annotations(); + $self->assert_deep_equals([{entry => $e1, attrib => $a1}], \@aa); + + # Can set two annotations + $m->set_annotation($e1, $a1, 'brooklyn'); + $m->set_annotation($e2, $a2, 'sustainable'); + $self->assert($m->has_annotation($e1, $a1)); + $self->assert($m->has_annotation($e2, $a2)); + $self->assert($m->has_annotation({ entry => $e1, attrib => $a1 })); + $self->assert($m->has_annotation({ entry => $e2, attrib => $a2 })); + $self->assert_str_equals('brooklyn', $m->get_annotation($e1, $a1)); + $self->assert_str_equals('sustainable', $m->get_annotation($e2, $a2)); + $self->assert_str_equals('brooklyn', $m->get_annotation({ entry => $e1, attrib => $a1 })); + $self->assert_str_equals('sustainable', $m->get_annotation({ entry => $e2, attrib => $a2 })); + @aa = $m->list_annotations(); + @aa = sort { $a->{entry} cmp $b->{entry} } @aa; + $self->assert_deep_equals([ + {entry => $e1, attrib => $a1}, + {entry => $e2, attrib => $a2}, + ], \@aa); +} + +sub test_annotations_from_fetch +{ + my ($self) = @_; + + my $e1 = '/comment'; + my $a1 = 'value.shared'; + my $e2 = '/vendor/hipsteripsum.me/buzzword'; + my $a2 = 'value.priv'; + + my $m = Cassandane::Message->new(attrs => { + annotation => { + $e1 => { $a1 => 'whatever' }, + $e2 => { $a2 => 'sartorial' } + }}); + + $self->assert($m->has_annotation($e1, $a1)); + $self->assert($m->has_annotation($e2, $a2)); + $self->assert($m->has_annotation({ entry => $e1, attrib => $a1 })); + $self->assert($m->has_annotation({ entry => $e2, attrib => $a2 })); + $self->assert_str_equals('whatever', $m->get_annotation($e1, $a1)); + $self->assert_str_equals('sartorial', $m->get_annotation($e2, $a2)); + $self->assert_str_equals('whatever', $m->get_annotation({ entry => $e1, attrib => $a1 })); + $self->assert_str_equals('sartorial', $m->get_annotation({ entry => $e2, attrib => $a2 })); + my @aa = $m->list_annotations(); + @aa = sort { $a->{entry} cmp $b->{entry} } @aa; + $self->assert_deep_equals([ + {entry => $e1, attrib => $a1}, + {entry => $e2, attrib => $a2}, + ], \@aa); + +} + +sub test_accessors +{ + my ($self) = @_; + + my $txt = <<'EOF'; +From: Fred J. Bloggs +To: Sarah Jane Smith , Genghis Khan +Subject: Hello World +Date: Tue, 06 Dec 2011 13:57:57 +1100 +Received: from mail.quux.com (mail.quux.com [10.0.0.1]) by mail.gmail.com (Software); + Fri, 29 Oct 2010 13:05:01 +1100 +Received: from mail.bar.com (mail.bar.com [10.0.0.1]) + by mail.quux.com (Software); Fri, 29 Oct 2010 13:03:03 +1100 +Received: from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by + mail.bar.com (Software); Fri, 29 Oct 2010 13:01:01 +1100 +Message-ID: + +This is a message to let you know +that I'm alive and well +EOF + my @lines = split(/\n/, $txt); + map { $_ .= "\r\n" } @lines; + + my $m = Cassandane::Message->new( + lines => \@lines, + attrs => { + uid => 42 + }); + + $self->assert_str_equals('Fred J. Bloggs ', $m->from()); + $self->assert_str_equals('Sarah Jane Smith , Genghis Khan ', $m->to()); + $self->assert_str_equals('Hello World', $m->subject()); + $self->assert_str_equals('Tue, 06 Dec 2011 13:57:57 +1100', $m->date()); + $self->assert_str_equals('', $m->messageid()); + $self->assert_num_equals(42, $m->uid()); + $self->assert_num_equals(651, $m->size()); + $self->assert_str_equals('e2f2c19a8097587d54745801621d4bde4fa664b3', $m->guid()); + $self->assert_null($m->cid()); + + # make_cid() returns a new CID but doesn't set the attribute + $self->assert_str_equals('7301187b8bfe536f', $m->make_cid()); + $self->assert_null($m->cid()); + $m->set_attribute(cid => $m->make_cid()); + $self->assert_str_equals('7301187b8bfe536f', $m->cid()); +} + +sub test_header_normalisation +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + + # data thanks to hipsteripsum.me + $m->add_header('x-cliche', "sartorial"); + $m->add_header('x-cliche', "mixtape\nfreegan"); + $m->add_header('x-cliche', "leggings\r\nreadymade quinoa"); + $m->add_header('x-cliche', "chambray\rdenim"); + + $m->set_headers('x-vegan', + "helvetica\rwayfarers keytar\nshoreditch\r\n \t portland"); + + $m->set_body("This is a message to let you know\r\nthat I'm alive and well\r\n"); + my $exp = <<'EOF'; +Subject: Hello World +X-Cliche: sartorial +X-Cliche: mixtape + freegan +X-Cliche: leggings + readymade quinoa +X-Cliche: chambray + denim +X-Vegan: helvetica + wayfarers keytar + shoreditch + portland + +This is a message to let you know +that I'm alive and well +EOF + + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + +# Test a header field which is present but with an empty value +sub test_add_empty +{ + my ($self) = @_; + my $m = Cassandane::Message->new(); + $m->add_header('subject', 'Hello World'); + $m->add_header('X-Justin-Beiber', ""); + $m->add_header('From', Cassandane::Address->new( + name => 'Fred J. Bloggs', + localpart => 'fbloggs', + domain => 'fastmail.fm')); + $m->set_body("This is a message to let you know\r\nthat I'm alive and well\r\n"); + my $exp = <<'EOF'; +Subject: Hello World +X-Justin-Beiber: +From: Fred J. Bloggs + +This is a message to let you know +that I'm alive and well +EOF + $exp =~ s/\n/\r\n/g; + $self->assert_str_equals($exp, $m->as_string); + $self->assert_str_equals($exp, "" . $m); +} + + +1; diff --git a/cassandane/Cassandane/Test/MessageStoreFactory.pm b/cassandane/Cassandane/Test/MessageStoreFactory.pm new file mode 100644 index 0000000000..2d1b235c7b --- /dev/null +++ b/cassandane/Cassandane/Test/MessageStoreFactory.pm @@ -0,0 +1,154 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::MessageStoreFactory; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::MessageStoreFactory; + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +# Test no args at all - default is mbox to stdout/stdin +sub test_no_args +{ + my ($self) = @_; + my $ms = Cassandane::MessageStoreFactory->create(); + $self->assert(ref $ms eq 'Cassandane::MboxMessageStore'); + $self->assert( !defined $ms->{filename}); +} + +# Test guessing type from single attribute, one of 'filename' +# 'directory' or 'host'. +sub test_single_attr +{ + my ($self) = @_; + my $ms = Cassandane::MessageStoreFactory->create(filename => 'foo'); + $self->assert(ref $ms eq 'Cassandane::MboxMessageStore'); + $self->assert($ms->{filename} eq 'foo'); + + $ms = Cassandane::MessageStoreFactory->create(directory => 'foo'); + $self->assert(ref $ms eq 'Cassandane::MaildirMessageStore'); + $self->assert($ms->{directory} eq 'foo'); +} + +# Test creating from a URI +sub test_uri +{ + my ($self) = @_; + my $ms = Cassandane::MessageStoreFactory->create(uri => 'mbox:///foo/bar'); + $self->assert(ref $ms eq 'Cassandane::MboxMessageStore'); + $self->assert($ms->{filename} eq '/foo/bar'); + + $ms = Cassandane::MessageStoreFactory->create(uri => 'file:///foo/bar'); + $self->assert(ref $ms eq 'Cassandane::MboxMessageStore'); + $self->assert($ms->{filename} eq '/foo/bar'); + + $ms = Cassandane::MessageStoreFactory->create(uri => 'maildir:///foo/bar'); + $self->assert(ref $ms eq 'Cassandane::MaildirMessageStore'); + $self->assert($ms->{directory} eq '/foo/bar'); + + $ms = Cassandane::MessageStoreFactory->create(uri => 'imap://victoria:secret@foo.com:9143/inbox.foo'); + $self->assert(ref $ms eq 'Cassandane::IMAPMessageStore'); + $self->assert($ms->{username} eq 'victoria'); + $self->assert($ms->{password} eq 'secret'); + $self->assert($ms->{host} eq 'foo.com'); + $self->assert($ms->{port} == 9143); + $self->assert($ms->{folder} eq 'inbox.foo'); + + $ms = Cassandane::MessageStoreFactory->create(uri => 'imap://victoria@foo.com:9143/inbox.foo'); + $self->assert(ref $ms eq 'Cassandane::IMAPMessageStore'); + $self->assert($ms->{username} eq 'victoria'); + $self->assert(!defined $ms->{password}); + $self->assert($ms->{host} eq 'foo.com'); + $self->assert($ms->{port} == 9143); + $self->assert($ms->{folder} eq 'inbox.foo'); + + $ms = Cassandane::MessageStoreFactory->create(uri => 'imap://foo.com:9143/inbox.foo'); + $self->assert(ref $ms eq 'Cassandane::IMAPMessageStore'); + $self->assert(!defined $ms->{username}); + $self->assert(!defined $ms->{password}); + $self->assert($ms->{host} eq 'foo.com'); + $self->assert($ms->{port} == 9143); + $self->assert($ms->{folder} eq 'inbox.foo'); + + $ms = Cassandane::MessageStoreFactory->create(uri => 'imap://foo.com/inbox.foo'); + $self->assert(ref $ms eq 'Cassandane::IMAPMessageStore'); + $self->assert(!defined $ms->{username}); + $self->assert(!defined $ms->{password}); + $self->assert($ms->{host} eq 'foo.com'); + $self->assert($ms->{port} == 143); + $self->assert($ms->{folder} eq 'inbox.foo'); + + $ms = Cassandane::MessageStoreFactory->create(uri => 'imap://foo.com/'); + $self->assert(ref $ms eq 'Cassandane::IMAPMessageStore'); + $self->assert(!defined $ms->{username}); + $self->assert(!defined $ms->{password}); + $self->assert($ms->{host} eq 'foo.com'); + $self->assert($ms->{port} == 143); + $self->assert($ms->{folder} eq 'INBOX'); +} + +# Test creation with the 'path' and 'type' attribute - default +# arguments for genmail3.pl +sub test_path +{ + my ($self) = @_; + + my $ms = Cassandane::MessageStoreFactory->create(path => 'foo'); + $self->assert(ref $ms eq 'Cassandane::MboxMessageStore'); + $self->assert($ms->{filename} eq 'foo'); + + $ms = Cassandane::MessageStoreFactory->create(type => 'mbox', path => 'foo'); + $self->assert(ref $ms eq 'Cassandane::MboxMessageStore'); + $self->assert($ms->{filename} eq 'foo'); + + $ms = Cassandane::MessageStoreFactory->create(type => 'maildir', path => 'foo'); + $self->assert(ref $ms eq 'Cassandane::MaildirMessageStore'); + $self->assert($ms->{directory} eq 'foo'); +} + +1; diff --git a/cassandane/Cassandane/Test/Metronome.pm b/cassandane/Cassandane/Test/Metronome.pm new file mode 100644 index 0000000000..be5b93e432 --- /dev/null +++ b/cassandane/Cassandane/Test/Metronome.pm @@ -0,0 +1,88 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Metronome; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Util::Metronome; +use Cassandane::Util::Sample; +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +sub test_basic +{ + my ($self) = @_; + + return unless $ENV{METRONOME_ENABLED}; + + my $rate = 100.0; + my $epsilon = 0.05; + my $m = Cassandane::Util::Metronome->new(rate => $rate); + + my $ss = new Cassandane::Util::Sample; + + for (1..$rate) + { + $m->tick(); + my $r = $m->actual_rate(); + xlog "Actual rate $r"; + # Be forgiving of early samples to let the + # metronome stabilise. + $ss->add($r) if ($_ >= 20) + } + + xlog "Rates: $ss"; + my $avg = $ss->average(); + my $std = $ss->sample_deviation(); + $self->assert($avg >= (1.0-$epsilon)*$rate && $avg <= (1.0+$epsilon)*$rate, + "Average $avg is outside expected range"); + $self->assert($std/$rate < $epsilon, + "Standard deviation $std is too high"); +} + +1; diff --git a/cassandane/Cassandane/Test/NewTestUrl.pm b/cassandane/Cassandane/Test/NewTestUrl.pm new file mode 100644 index 0000000000..f3dcb260cc --- /dev/null +++ b/cassandane/Cassandane/Test/NewTestUrl.pm @@ -0,0 +1,133 @@ +#!/usr/bin/perl + +package Cassandane::Test::NewTestUrl; +use strict; +use warnings; + +use JSON; +use LWP::UserAgent; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +sub test_basic +{ + my ($self) = @_; + + my $string_based_url = $self->new_test_url("string based"); + my $code_based_url = $self->new_test_url(sub { + return [ + 201, + [], + [ "code based" ], + ], + }); + + my $lwp = LWP::UserAgent->new; + + { + my $res = $lwp->get($string_based_url->url); + $self->assert_str_equals('200', $res->code); + $self->assert_str_equals('string based', $res->decoded_content); + } + + { + my $res = $lwp->get($code_based_url->url); + $self->assert_str_equals('201', $res->code); + $self->assert_str_equals('code based', $res->decoded_content); + } + + $string_based_url->unregister; + + { + # Unregistering one shouldn't affect the other + my $res = $lwp->get($code_based_url->url); + $self->assert_str_equals('201', $res->code); + $self->assert_str_equals('code based', $res->decoded_content); + } + + $code_based_url->update("newval"); + + { + my $res = $lwp->get($code_based_url->url); + $self->assert_str_equals('200', $res->code); + $self->assert_str_equals('newval', $res->decoded_content); + } + + { + eval { $string_based_url->url }; + my $err = $@; + $self->assert_matches( + qr/\QCannot call ->url after ->unregister has been called\E/, + $err, + ); + } + + { + eval { $string_based_url->update("foo") }; + my $err = $@; + $self->assert_matches( + qr/\QCannot call ->update after ->unregister has been called\E/, + $err, + ); + } + + # Plack example + my $plack_based_url = $self->new_test_url(sub { + my $env = shift; + my $req = Plack::Request->new($env); + + my $payload = decode_json($req->raw_body); + + my $res; + + if ($payload->{good}) { + $res = Plack::Response->new(200); + $res->content_type('application/json'); + $res->body(encode_json({ good => "job" })); + } else { + $res = Plack::Response->new(400); + $res->content_type('application/json'); + $res->body(encode_json({ tough => "luck" })); + } + + return $res->finalize; + }); + + { + my $res = $lwp->post( + $plack_based_url->url, + Content => encode_json({ good => 1 }), + ); + $self->assert_str_equals('200', $res->code); + + my $json = decode_json($res->decoded_content); + $self->assert_deep_equals( + { good => "job" }, + $json, + ); + } + + { + my $res = $lwp->post( + $plack_based_url->url, + Content => encode_json({ good => 0 }), + ); + $self->assert_str_equals('400', $res->code); + + my $json = decode_json($res->decoded_content); + $self->assert_deep_equals( + { tough => "luck" }, + $json, + ); + } +} + +1; diff --git a/cassandane/Cassandane/Test/Parameter.pm b/cassandane/Cassandane/Test/Parameter.pm new file mode 100644 index 0000000000..7b17a68705 --- /dev/null +++ b/cassandane/Cassandane/Test/Parameter.pm @@ -0,0 +1,69 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Parameter; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + my $self = $class->SUPER::new(@_); + return $self; +} + +my $mustache; +Cassandane::Unit::TestCase::parameter(\$mustache, 'walrus', 'toothbrush', 'waxed'); + +my $nose; +Cassandane::Unit::TestCase::parameter(\$nose, 'roman'); + +my $eyes; +Cassandane::Unit::TestCase::parameter(\$eyes, 'brown', 'cat'); + +sub test_face +{ + xlog "XXX face: mustache=$mustache eyes=$eyes nose=$nose"; +} + +1; diff --git a/cassandane/Cassandane/Test/Sample.pm b/cassandane/Cassandane/Test/Sample.pm new file mode 100644 index 0000000000..31f99f86cf --- /dev/null +++ b/cassandane/Cassandane/Test/Sample.pm @@ -0,0 +1,109 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Sample; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::TestCase); +use Cassandane::Util::Sample; +use Cassandane::Util::Log; + +sub new +{ + my $class = shift; + return $class->SUPER::new(@_); +} + +sub check_expected +{ + my ($self, $ss, $ecount, $eavg, $emin, $emax, $estd) = @_; + + my $epsilon = 0.01; + + $self->assert_equals($ss->nsamples(), $ecount); + + my $avg = $ss->average(); + $self->assert(abs($eavg - $avg) < $epsilon, + "Average: expecting $eavg got $avg"); + + my $min = $ss->minimum(); + $self->assert(abs($emin - $min) < $epsilon, + "Minimum: expecting $emin got $min"); + + my $max = $ss->maximum(); + $self->assert(abs($emax - $max) < $epsilon, + "Maximum: expecting $emax got $max"); + + my $std = $ss->sample_deviation(); + $self->assert(abs($estd - $std) < $epsilon, + "Sample Deviation: expecting $estd got $std"); +} + +sub test_uniform +{ + my ($self) = @_; + + xlog "Sample with 4 x 10.0"; + my $ss = new Cassandane::Util::Sample; + $ss->add(10.0); + $ss->add(10.0); + $ss->add(10.0); + $ss->add(10.0); + xlog "Sample: $ss"; + $self->check_expected($ss, 4, 10.0, 10.0, 10.0, 0.0); +} + +sub test_ramp +{ + my ($self) = @_; + + xlog "Sample with ramp from 1 to 5"; + my $ss = new Cassandane::Util::Sample; + $ss->add(1.0); + $ss->add(2.0); + $ss->add(3.0); + $ss->add(4.0); + $ss->add(5.0); + xlog "Sample: $ss"; + $self->check_expected($ss, 5, 3.0, 1.0, 5.0, 1.5811); +} + +1; diff --git a/cassandane/Cassandane/Test/Skip.pm b/cassandane/Cassandane/Test/Skip.pm new file mode 100644 index 0000000000..1b0db99aae --- /dev/null +++ b/cassandane/Cassandane/Test/Skip.pm @@ -0,0 +1,113 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Test::Skip; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Cyrus::TestCase); + +sub new +{ + my $class = shift; + return $class->SUPER::new({}, @_); +} + +sub set_up +{ + my ($self) = @_; + $self->SUPER::set_up(); +} + +sub tear_down +{ + my ($self) = @_; + $self->SUPER::tear_down(); +} + +sub test_skip_old_version + :min_version_3_0 +{ + my ($self) = @_; + + my ($maj, $min) = Cassandane::Instance->get_version(); + + $self->assert($maj >= 3); + $self->assert($min >= 0); +} + +sub test_skip_new_version + :max_version_2_5 +{ + my ($self) = @_; + + my ($maj, $min) = Cassandane::Instance->get_version(); + + $self->assert($maj <= 2); + $self->assert($min <= 5); +} + +sub test_skip_outside_range + :min_version_2_5_0 :max_version_2_5_9 +{ + my ($self) = @_; + + my ($maj, $min, $rev) = Cassandane::Instance->get_version(); + + $self->assert_equals($maj, 2); + $self->assert_equals($min, 5); + $self->assert($rev >= 0); + $self->assert($rev <= 9); +} + +# Don't actually use this device in real tests. This is meant to exercise the +# skip mechanism, not as an example of its proper use :) +sub test_skip_everything + :min_version_3_0 :max_version_2_5 +{ + my ($self) = @_; + + my ($maj, $min, $rev) = Cassandane::Instance->get_version(); + + # should never get here -- if we do, we've failed + $self->assert(0); +} + +1; diff --git a/cassandane/Cassandane/ThreadedGenerator.pm b/cassandane/Cassandane/ThreadedGenerator.pm new file mode 100644 index 0000000000..f633872f2a --- /dev/null +++ b/cassandane/Cassandane/ThreadedGenerator.pm @@ -0,0 +1,176 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::ThreadedGenerator; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Generator); +use Cassandane::Address; +use Cassandane::Message; +use Cassandane::Util::Log; +use Cassandane::Util::Words; + +my $NTHREADS = 5; +my $NMESSAGES = 20 * $NTHREADS; +my $DELTAT = 300; # seconds +my $FINISH_CHANCE = 0.08; +my $FOLLOW_CHANCE = 0.30; + +sub new +{ + my ($class, %params) = @_; + + my $nmessages = $NMESSAGES; + $nmessages = delete $params{nmessages} + if defined $params{nmessages}; + my $deltat = $DELTAT; + $deltat = delete $params{deltat} + if defined $params{deltat}; + my $nthreads = $NTHREADS; + $nthreads = delete $params{nthreads} + if defined $params{nthreads}; + + my $self = $class->SUPER::new(%params); + + $self->{nmessages} = $nmessages; + $self->{deltat} = $deltat; + + $self->{threads} = []; + for (my $i = 1 ; $i <= $nthreads ; $i++) + { + my $thread = + { + id => $i, + subject => ucfirst(random_word()) . " " . random_word(), + cid => undef, + last_message => undef, + }; + push(@{$self->{threads}}, $thread); + } + + $self->{next_date} = DateTime->now->epoch - + $self->{deltat} * ($self->{nmessages}+1); + $self->{last_thread} = undef; + + return $self; +} + +sub _choose_thread +{ + my ($self) = @_; + + my $dice = rand; + my $thread; + if ($dice <= $FINISH_CHANCE) + { + # follow-up on the last thread + $thread = $self->{last_thread}; + } + if (!defined $thread) + { + my $i = int(rand(scalar(@{$self->{threads}}))); + $thread = $self->{threads}->[$i]; + } + + $dice = rand; + if ($dice <= $FINISH_CHANCE) + { + # detach from the generator...we won't find it again + my @tt = grep { $thread != $_ } @{$self->{threads}}; + $self->{threads} = \@tt; + $self->{last_thread} = undef + if defined $self->{last_thread} && $thread == $self->{last_thread}; + } + else + { + $self->{last_thread} = $thread; + } + + return $thread; +} + +# +# Generate a single email. +# Args: Generator, (param-key => param-value ... ) +# Returns: Message ref +# +sub generate +{ + my ($self, %params) = @_; + + return undef + if (!$self->{nmessages}); + + my $thread = $self->_choose_thread(); + return undef + if (!defined $thread); + + my $last = $thread->{last_message}; + if (defined $last) + { + $params{subject} = "Re: " . $thread->{subject}; + $params{references} = [ $last ]; + } + else + { + $params{subject} = $thread->{subject}; + } + $params{date} = DateTime->from_epoch( epoch => $self->{next_date} ); + $self->{next_date} += $self->{deltat}; + + my $msg = $self->SUPER::generate(%params); + $msg->add_header('X-Cassandane-Thread', $thread->{id}); + + my $cid = $thread->{cid}; + $cid = $thread->{cid} = $msg->make_cid() + unless defined $cid; + $msg->set_attributes(cid => $cid); + + $thread->{last_message} = $msg; + $self->{nmessages}--; + + return $msg; +} + +# TODO: test that both References: and In-Reply-To: are tracked in the server +# TODO: test that Subject: isnt tracked in the server + +1; diff --git a/cassandane/Cassandane/Tiny.pm b/cassandane/Cassandane/Tiny.pm new file mode 100644 index 0000000000..eeef00eebe --- /dev/null +++ b/cassandane/Cassandane/Tiny.pm @@ -0,0 +1,11 @@ +package Cassandane::Tiny; +use strict; +use warnings; + +sub import { + no warnings 'once'; + $Cassandane::Tiny::Loader::RELOADED = 1; + return; +} + +1; diff --git a/cassandane/Cassandane/Tiny/Loader.pm b/cassandane/Cassandane/Tiny/Loader.pm new file mode 100644 index 0000000000..30218b383f --- /dev/null +++ b/cassandane/Cassandane/Tiny/Loader.pm @@ -0,0 +1,41 @@ +package Cassandane::Tiny::Loader; +use strict; +use warnings; + +use Carp (); + +our $RELOADED; + +sub import { + my ($class, $path) = @_; + + my $into = caller; + + unless (-d $path) { + Carp::confess(qq{can't find path "$path" for loading tests; Cassandane expects to be run from the ./cyrus-imapd/cassandane directory"}); + } + + my @tests = `find $path -type f \! -name "*~" \! -name ".*"`; + + if ($?) { + Carp::confess("couldn't use find(1) to find tiny test files in $path"); + } + + chomp @tests; + + for my $test (sort @tests) { + local $RELOADED; + + unless (eval "package $into; do qq{$test}; die \$@ if \$@; 1") { + Carp::confess("tried to load $test but it failed: $@"); + } + + unless ($RELOADED) { + Carp::confess("tried to load $test but it did not 'use Cassandane::Tiny'"); + } + } + + return; +} + +1; diff --git a/cassandane/Cassandane/Unit/FormatPretty.pm b/cassandane/Cassandane/Unit/FormatPretty.pm new file mode 100644 index 0000000000..1c7f9c8c8b --- /dev/null +++ b/cassandane/Cassandane/Unit/FormatPretty.pm @@ -0,0 +1,239 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Unit::FormatPretty; +use strict; +use warnings; + +use lib '.'; +use base qw(Cassandane::Unit::Formatter); + +sub new +{ + my ($class, $params, @args) = @_; + my $self = $class->SUPER::new(@args); + if ($params->{quiet}) { + # if we're in quiet ("prettier") mode, write detailed error/failure + # reports to $rootdir/reports (if we can) rather than terminal + $self->{_quiet} = 1; + + my $cassini = Cassandane::Cassini->instance(); + my $rootdir = $cassini->val('cassandane', 'rootdir', '/var/tmp/cass'); + my $quiet_report_file = "$rootdir/reports"; + + $self->{_quiet_report_fh} = IO::File->new($quiet_report_file, 'w'); + # if we can't write there, just don't do it + } + return $self; +} + +sub ansi +{ + my ($self, $codes, @args) = @_; + my $isatty = -t $self->{fh}; + + my $ansi; + + $ansi .= "\e[" . join(',', @{$codes}) . 'm' if $isatty; + $ansi .= join ('', @args); + $ansi .= "\e[0m" if $isatty; + + return $ansi; +} + +sub add_pass +{ + my $self = shift; + my $test = shift; + + my $line = sprintf "%s %s\n", + $self->ansi([32], '[ OK ]'), + _getname($test); + $self->_print($line); +} + +sub add_error +{ + my $self = shift; + my $test = shift; + + my $line = sprintf "%s %s\n", + $self->ansi([31], '[ERROR ]'), + _getname($test); + $self->_print($line); +} + +sub add_failure +{ + my $self = shift; + my $test = shift; + + my $line = sprintf "%s %s\n", + $self->ansi([33], '[FAILED]'), + _getname($test); + $self->_print($line); +} + +sub _getname +{ + my $test = shift; + my $suite = ref($test); + $suite =~ s/^Cassandane:://; + + my $testname = $test->{"Test::Unit::TestCase_name"}; + $testname =~ s/^test_//; + + return "$suite.$testname"; +} + +sub _prettytest +{ + my $test = shift; + die "WEIRD TEST $test" unless $test =~ m/^test_(.*)\((.*)\)$/; + my $item = $1; + my $suite = $2; + $suite =~ s/^Cassandane::Cyrus:://; + return "$suite.$item"; +} + +sub print_errors +{ + my $self = shift; + + my $saved_output_stream; + if ($self->{_quiet}) { + if ($self->{_quiet_report_fh}) { + $saved_output_stream = $self->{fh}; + $self->{fh} = $self->{_quiet_report_fh}; + } + else { + return; + } + } + + my ($result) = @_; + return unless my $error_count = $result->error_count(); + my $msg = "\nThere " . + ($error_count == 1 ? + "was 1 error" + : "were $error_count errors") . + ":\n"; + $self->_print($msg); + + my $i = 0; + for my $e (@{$result->errors()}) { + my ($test, $errors) = split(/\n/, $e->to_string(), 2); + chomp $errors; + my $prettytest = _prettytest($test); + $self->_print("\n") if $i++; + $self->_print($self->ansi([31], "$i) $prettytest") . "\n$errors\n"); + $self->_print("\nAnnotations:\n", $e->object->annotations()) + if $e->object->annotations(); + } + + if ($saved_output_stream) { + $self->{fh} = $saved_output_stream; + } +} + +sub print_failures +{ + my $self = shift; + + my $saved_output_stream; + if ($self->{_quiet}) { + if ($self->{_quiet_report_fh}) { + $saved_output_stream = $self->{fh}; + $self->{fh} = $self->{_quiet_report_fh}; + } + else { + return; + } + } + + my ($result) = @_; + return unless my $failure_count = $result->failure_count; + my $msg = "\nThere " . + ($failure_count == 1 ? + "was 1 failure" + : "were $failure_count failures") . + ":\n"; + $self->_print($msg); + + my $i = 0; + for my $f (@{$result->failures()}) { + my ($test, $failures) = split(/\n/, $f->to_string(), 2); + chomp $failures; + my $prettytest = _prettytest($test); + $self->_print("\n") if $i++; + $self->_print($self->ansi([33], "$i) $prettytest") . "\n$failures\n"); + $self->_print("\nAnnotations:\n", $f->object->annotations()) + if $f->object->annotations(); + } + + if ($saved_output_stream) { + $self->{fh} = $saved_output_stream; + } +} + +sub print_header { + my $self = shift; + my ($result) = @_; + if ($result->was_successful()) { + $self->_print("\n", + $self->ansi([32], "OK"), + " (", $result->run_count(), " tests)\n"); + } else { + my $failure_count = $result->failure_count() + ? $self->ansi([33], $result->failure_count) + : "0"; + my $error_count = $result->error_count() + ? $self->ansi([31], $result->error_count) + : "0"; + + $self->_print("\n", $self->ansi([31], "!!!FAILURES!!!"), "\n", + "Test Results:\n", + "Run: ", $result->run_count(), + ", Failures: $failure_count", + ", Errors: $error_count", + "\n"); + } +} + +1; diff --git a/cassandane/Cassandane/Unit/FormatTAP.pm b/cassandane/Cassandane/Unit/FormatTAP.pm new file mode 100644 index 0000000000..c6d9b220a7 --- /dev/null +++ b/cassandane/Cassandane/Unit/FormatTAP.pm @@ -0,0 +1,73 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Unit::FormatTAP; +use strict; +use warnings; +use Data::Dumper; +use IO::File; + +use lib '.'; +use base qw(Cassandane::Unit::Formatter); + +sub new +{ + my ($class, $fh) = @_; + return $class->SUPER::new($fh); +} + +sub start_test +{ + my ($self, $test) = @_; + $self->_print('.'); +} + +sub add_error +{ + my ($self, $test, $exception) = @_; + $self->_print('E'); +} + +sub add_failure +{ + my ($self, $test, $exception) = @_; + $self->_print('F'); +} + +1; diff --git a/cassandane/Cassandane/Unit/FormatXML.pm b/cassandane/Cassandane/Unit/FormatXML.pm new file mode 100644 index 0000000000..0770661cb2 --- /dev/null +++ b/cassandane/Cassandane/Unit/FormatXML.pm @@ -0,0 +1,200 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Unit::FormatXML; +use strict; +use warnings; +use vars qw($VERSION); + +use XML::Generator; +use Time::HiRes qw(time); +use Sys::Hostname; +use POSIX qw(strftime); + +use lib '.'; +use base qw(Cassandane::Unit::Formatter); + +$VERSION = '0.1'; + +sub new { + my ($class, $params, @args) = @_; + + my $self = $class->SUPER::new(@args); + + $params->{generator} ||= XML::Generator->new(escape => 'always', + pretty => 2); + + $self->{directory} = $params->{directory}; + $self->{gen} = $params->{generator}; + $self->{classrecs} = {}; + + return $self; +} + +sub _classrec { + my ($self, $test) = @_; + + return $self->{classrecs}->{ref($test)} ||= { + testrecs => {}, tests => 0, + errors => 0, failures => 0, + timestamp => strftime("%Y-%m-%dT%H:%M:%S", gmtime(time())), + }; +} + +sub _testrec { + my ($self, $test) = @_; + + my $cr = $self->_classrec($test); + return $cr->{testrecs}->{$test->name()} ||= + { start_time => 0, node => undef, child_nodes => [] }; +} + +sub _extype +{ + my ($exception) = @_; + my $o = $exception->object(); + return $o->to_string() + if (defined $o && $o->can('to_string')); + return "unknown"; +} + +sub add_failure { + my ($self, $test, $exception) = @_; + + my $cr = $self->_classrec($test); + my $tr = $self->_testrec($test); + $cr->{failures}++; + push(@{$tr->{child_nodes}}, + $self->{gen}->failure({type => _extype($exception), + message => $exception->get_message()}, + $exception->stringify())); +} + +sub add_error { + my ($self, $test, $exception) = @_; + + my $cr = $self->_classrec($test); + my $tr = $self->_testrec($test); + $cr->{errors}++; + push(@{$tr->{child_nodes}}, + $self->{gen}->error({type => _extype($exception), + message => $exception->get_message()}, + $exception->stringify())); +} + +sub start_test { + my ($self, $test) = @_; + + my $cr = $self->_classrec($test); + my $tr = $self->_testrec($test); + $tr->{start_time} = time(); + $cr->{tests}++; +} + +sub fake_start_time { + my ($self, $test, $time) = @_; + + my $tr = $self->_testrec($test); + $tr->{start_time} = $time; +} + +sub end_test { + my ($self, $test) = @_; + + my $cr = $self->_classrec($test); + my $tr = $self->_testrec($test); + my $time = time() - $tr->{start_time}; + $tr->{node} = $self->{gen}->testcase({name => $test->name(), + classname => ref($test), + time => sprintf('%.4f', $time)}, + @{$tr->{child_nodes}}); + $cr->{time} += $time; +} + +sub _emit_xml { + my ($self) = @_; + + my $hostname = hostname(); + + foreach my $class (keys %{$self->{classrecs}}) { + my $cr = $self->{classrecs}->{$class}; + + my $output = IO::File->new(">" . $self->_xml_filename($class)); + unless(defined($output)) { + die("Can't open " . $self->_xml_filename($class) . ": $!"); + } + + my $time = sprintf('%.4f', $cr->{time}); + my @child_nodes = map { $_->{node}; } (values %{$cr->{testrecs}}); + unshift(@child_nodes, $self->{gen}->properties()); + my $system_out = 'system-out'; + push(@child_nodes, $self->{gen}->$system_out()); + my $system_err = 'system-err'; + push(@child_nodes, $self->{gen}->$system_err()); + my $xml = $self->{gen}->testsuite({tests => $cr->{tests}, + failures => $cr->{failures}, + errors => $cr->{errors}, + time => $time, + name => $class, + hostname => $hostname, + timestamp => $cr->{timestamp}}, + @child_nodes); + $output->print($xml); + $output->close(); + } +} + +sub finished +{ + my ($self, $result, $start_time, $end_time) = @_; + + # XXX This class does all its own accounting, which is probably + # XXX redundant since it doesn't report anything that it couldn't + # XXX just get from the usual $result/$start_time/$end_time args. + $self->_emit_xml(); +} + +sub _xml_filename { + my ($self, $class) = @_; + + $class =~ s/::/./g; + return File::Spec->catfile($self->{directory}, "TEST-${class}.xml"); +} + +1; diff --git a/cassandane/Cassandane/Unit/Formatter.pm b/cassandane/Cassandane/Unit/Formatter.pm new file mode 100644 index 0000000000..f892c122be --- /dev/null +++ b/cassandane/Cassandane/Unit/Formatter.pm @@ -0,0 +1,185 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Unit::Formatter; +use strict; +use warnings; + +use base 'Test::Unit::Listener'; +use Benchmark; +use IO::Handle; + +sub new +{ + my ($class, $fh) = @_; + + $fh //= \*STDOUT; + $fh->autoflush(1); + + return bless { + remove_me_in_cassandane_child => 1, + fh => $fh, + }, $class; +} + +sub _print +{ + my ($self, @args) = @_; + $self->{fh}->print(@args); +} + +# No-op implementations of Listener interface. To create a new output +# format, subclass from this and override the appropriate event handlers + +sub start_suite +{ + my ($self, $suite) = @_; +} + +sub start_test +{ + my ($self, $test) = @_; +} + +sub add_pass +{ + my ($self, $test) = @_; +} + +sub add_error +{ + my ($self, $test, $exception) = @_; +} + +sub add_failure +{ + my ($self, $test, $exception) = @_; +} + +sub end_test +{ + my ($self, $test) = @_; +} + +# Override this with your output format's end-of-tests handling. The +# default is to print a summary. +sub finished +{ + my ($self, $result, $start_time, $end_time) = @_; + $self->print_summary($result, $start_time, $end_time); +} + +# Override this, and/or subs print_header, print_errors, print_failures +# to change how the summary is presented. +sub print_summary +{ + my ($self, $result, $start_time, $end_time) = @_; + + my $run_time = timediff($end_time, $start_time); + print "\n", "Time: ", timestr($run_time), "\n"; + + $self->print_header($result); + $self->print_errors($result); + $self->print_failures($result); +} + +sub print_header +{ + my ($self, $result) = @_; + + if ($result->was_successful()) { + $self->_print("\n", "OK", " (", $result->run_count(), " tests)\n"); + } + else { + $self->_print("\n", "!!!FAILURES!!!", "\n", + "Test Results:\n", + "Run: ", $result->run_count(), + ", Failures: ", $result->failure_count(), + ", Errors: ", $result->error_count(), + "\n"); + } +} + +sub print_errors +{ + my ($self, $result) = @_; + + return unless my $error_count = $result->error_count(); + + my $msg = "\nThere " . + ($error_count == 1 ? + "was 1 error" + : "were $error_count errors") . + ":\n"; + $self->_print($msg); + + my $i = 0; + for my $e (@{$result->errors()}) { + chomp(my $e_to_str = $e); + $i++; + $self->_print("$i) $e_to_str\n"); + $self->_print("\nAnnotations:\n", $e->object->annotations()) + if $e->object->annotations(); + } +} + +sub print_failures +{ + my ($self, $result) = @_; + + return unless my $failure_count = $result->failure_count; + + my $msg = "\nThere " . + ($failure_count == 1 ? + "was 1 failure" + : "were $failure_count failures") . + ":\n"; + $self->_print($msg); + + my $i = 0; + for my $f (@{$result->failures()}) { + chomp(my $f_to_str = $f); + $self->_print("\n") if $i++; + $self->_print("$i) $f_to_str\n"); + $self->_print("\nAnnotations:\n", $f->object->annotations()) + if $f->object->annotations(); + } +} + +1; diff --git a/cassandane/Cassandane/Unit/Runner.pm b/cassandane/Cassandane/Unit/Runner.pm new file mode 100644 index 0000000000..65522a2d82 --- /dev/null +++ b/cassandane/Cassandane/Unit/Runner.pm @@ -0,0 +1,149 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Unit::Runner; +use strict; +use warnings; +use base qw(Test::Unit::Runner); +use Test::Unit::Result; +use Benchmark; +use IO::File; + +use lib '.'; +use Cassandane::Cassini; + +sub new +{ + my ($class) = @_; + + my $cassini = Cassandane::Cassini->instance(); + my $rootdir = $cassini->val('cassandane', 'rootdir', '/var/tmp/cass'); + my $failed_file = "$rootdir/failed"; + # if we can't write there, we just won't record failed tests! + + return bless { + remove_me_in_cassandane_child => 1, + formatters => [], + failed_fh => IO::File->new($failed_file, 'w'), + }, $class; +} + +sub create_test_result +{ + my ($self) = @_; + $self->{_result} = Test::Unit::Result->new(); + return $self->{_result}; +} + +sub add_formatter +{ + my ($self, $formatter) = @_; + + push @{$self->{formatters}}, $formatter; +} + +# this is very similar to Test::Unit::Result's tell_listeners(), except +# without the annoying crash when the listener doesn't care about the event +sub tell_formatters +{ + my ($self, $method, @args) = @_; + + foreach my $formatter (@{$self->{formatters}}) { + if ($formatter->can($method)) { + $formatter->$method(@args); + } + } +} + +sub do_run +{ + my ($self, $suite) = @_; + my $result = $self->create_test_result(); + + $result->add_listener($self); + foreach my $f (@{$self->{formatters}}) { + $result->add_listener($f); + } + + my $start_time = new Benchmark(); + $suite->run($result, $self); + my $end_time = new Benchmark(); + + foreach my $f (@{$self->{formatters}}) { + $f->finished($result, $start_time, $end_time); + } + + return $result->was_successful; +} + +sub start_suite { } + +sub start_test { } + +sub end_test { } + +sub add_pass { } + +sub record_failed +{ + my ($self, $test) = @_; + return if not $self->{failed_fh}; + + my $suite = ref($test); + $suite =~ s/^Cassandane:://; + + my $testname = $test->{"Test::Unit::TestCase_name"}; + $testname =~ s/^test_//; + + $self->{failed_fh}->print("$suite.$testname\n"); +} + +sub add_error +{ + my ($self, $test) = @_; + $self->record_failed($test); +} + +sub add_failure +{ + my ($self, $test) = @_; + $self->record_failed($test); +} + +1; diff --git a/cassandane/Cassandane/Unit/TestCase.pm b/cassandane/Cassandane/Unit/TestCase.pm new file mode 100644 index 0000000000..0332feddb4 --- /dev/null +++ b/cassandane/Cassandane/Unit/TestCase.pm @@ -0,0 +1,461 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Unit::TestCase; +use strict; +use warnings; + +use base qw(Test::Unit::TestCase); +use Data::Dumper; +use DateTime; +use DateTime::Format::ISO8601; + +use lib '.'; +use Cassandane::Util::Log; +use Cassandane::Util::TestUrl; + +my $enabled; +my $buildinfo; + +sub new +{ + my $class = shift; + if (not $buildinfo) { + $buildinfo = Cassandane::BuildInfo->new(); + } + return $class->SUPER::new(@_); +} + +sub enable_test +{ + my ($class, $test) = @_; + $enabled = $test; +} + +sub _skip_version +{ + my ($str) = @_; + + return if not $str =~ m/^(min|max)_version_([\d_]+)$/; + my $minmax = $1; + my ($lim_major, $lim_minor, $lim_revision, $lim_commits) + = map { 0 + $_ } split /_/, $2; + return if not defined $lim_major; + + my ($major, $minor, $revision, $commits) = Cassandane::Instance->get_version(); + + if ($minmax eq 'min') { + return 1 if $major < $lim_major; # too old, skip! + return if $major > $lim_major; # definitely new enough + + return if not defined $lim_minor; # don't check deeper if caller doesn't care + return 1 if $minor < $lim_minor; + return if $minor > $lim_minor; + + return if not defined $lim_revision; + return 1 if $revision < $lim_revision; + + return if not defined $lim_commits; + return 1 if $commits < $lim_commits; + } + else { + return 1 if $major > $lim_major; # too new, skip! + return if $major < $lim_major; # definitely old enough + + return if not defined $lim_minor; # don't check deeper if caller doesn't care + return 1 if $minor > $lim_minor; + return if $minor < $lim_minor; + + return if not defined $lim_revision; + return 1 if $revision > $lim_revision; + + return if not defined $lim_commits; + return 1 if $commits > $lim_commits; + } + + return; +} + +sub filter +{ + my ($self) = @_; + return + { + # filters return 1 if the test should be skipped, or undef otherwise + x => sub + { + my $method = shift; + $method =~ s/^test_//; + # Only the explicitly enabled test runs + return ($enabled eq $method ? undef : 1); + }, + skip_version => sub + { + return if not exists $self->{_name}; + my $sub = $self->can($self->{_name}); + return if not defined $sub; + foreach my $attr (attributes::get($sub)) { + next if $attr !~ m/^(?:min|max)_version_[\d_]+$/; + return 1 if _skip_version($attr); + } + return; + }, + skip_missing_features => sub + { + return if not exists $self->{_name}; + my $sub = $self->can($self->{_name}); + return if not defined $sub; + foreach my $attr (attributes::get($sub)) { + next if $attr !~ + m/^needs_([A-Za-z0-9]+)_(\w+)(?:\(([^\)]*)\))?$/; + + if (defined $3) { + my $actual = $buildinfo->get($1, $2); + if ($actual ne $3) { + xlog "$1.$2 not '$3' (is '$actual'),", + "$self->{_name} will be skipped"; + return 1; + } + } + elsif (not $buildinfo->get($1, $2)) { + xlog "$1.$2 not enabled, $self->{_name} will be skipped"; + return 1; + } + } + return; + }, + skip_slow => sub + { + my ($method) = @_; + return 1 if $method =~ m/_slow$/; + return; + }, + slow_only => sub + { + my ($method) = @_; + return 1 if $method !~ m/_slow$/; + return; + }, + skip_runtime_check => sub + { + # To use: add a skip_check method to your test suite that + # implements logic to determine whether some test should run or + # not (perhaps by examining $self->{_name}). Return undef if + # the test should run, or a message explaining why the test is + # being skipped + return if not $self->can('skip_check'); + my $reason = $self->skip_check(); + if ($reason) { + xlog "$self->{_name} will be skipped:", + "skip_check said '$reason'"; + return 1; + } + return; + }, + }; +} + +sub annotate_from_file +{ + my ($self, $filename) = @_; + return if !defined $filename; + + open LOG, '<', $filename + or die "Cannot open $filename for reading: $!"; + while () + { + $self->annotate($_); + } + close LOG; +} + +my @params; + +sub parameter +{ + my ($ref, @values) = @_; + + return if (!scalar(@values)); + + my $param = { + id => scalar(@params), + package => caller, + values => \@values, + maxvidx => scalar(@values)-1, + reference => $ref, + }; + push(@params, $param); + +# xlog "XXX registering parameter id $param->{id} in package $param->{package}"; +} + +sub _describe_setting +{ + my ($setting) = @_; + $setting ||= []; + + my @parts; + my @ss = ( @$setting ); + while (scalar @ss) + { + my $id = shift @ss; + my $value = $params[$id]->{values}->[shift @ss]; + push(@parts, "$id:\"$value\""); + } + return '[' . join(' ', @parts) . ']'; +} + +sub make_parameter_settings +{ + my ($class, $package) = @_; + +# xlog "XXX making parameter settings for package $package"; + + my @settings; + my @stack; + foreach my $param (grep { $_->{package} eq $package } @params) + { + push(@stack, { param => $param, vidx => 0 }); + } + return [] if !scalar(@stack); + + SETTING: while (1) + { + # save a setting + my $setting = [ map { $_->{param}->{id}, $_->{vidx} } @stack ]; +# xlog "XXX making setting " . _describe_setting($setting); + push(@settings, $setting); + # increment indexes, wrapping and overflowing + foreach my $s (@stack) + { + $s->{vidx}++; + if ($s->{vidx} > $s->{param}->{maxvidx}) + { + $s->{vidx} = 0; + } + else + { + next SETTING; + } + } + last; + } + + return @settings; +} + +sub apply_parameter_setting +{ + my ($class, $setting) = @_; + +# xlog "XXX applying setting " . _describe_setting($setting); + + foreach my $param (@params) + { + ${$param->{reference}} = undef; + } + + my @ss = ( @$setting ); + while (scalar @ss) + { + my $param = $params[shift @ss]; + my $value = $param->{values}->[shift @ss]; +# xlog "XXX setting parameter id $param->{id} to value \"$value\""; + ${$param->{reference}} = $value; + } +} + +# n.b. it's okay for unexpected bits to also be set! +# if you need to test that ONLY specific bits are set, try: +# +# assert_bits_set($want, $got); +# assert_bits_not_set(~$want, $got); +# +sub assert_bits_set +{ + my ($self, $expected_bits, $actual_bitfield) = @_; + + # force args to be numeric + # XXX use feature 'bitwise'; + $expected_bits += 0; + $actual_bitfield += 0; + + my $fail_msg = sprintf("%#.8b does not have all of %#.8b bits set", + $actual_bitfield, $expected_bits); + + $self->assert((($actual_bitfield & $expected_bits) == $expected_bits), + $fail_msg); +} + +sub assert_bits_not_set +{ + my ($self, $expected_bits, $actual_bitfield) = @_; + + # force args to be numeric + # XXX use feature 'bitwise'; + $expected_bits += 0; + $actual_bitfield += 0; + + my $fail_msg = sprintf("%#.8b has some of %#.8b bits set", + $actual_bitfield, $expected_bits); + + $self->assert((($actual_bitfield & $expected_bits) == 0), $fail_msg); +} + +sub assert_num_gte +{ + my ($self, $expected, $actual) = @_; + + $self->assert(($actual >= $expected), + "$actual is not greater-than-or-equal-to $expected"); +} + +sub assert_num_lte +{ + my ($self, $expected, $actual) = @_; + + $self->assert(($actual <= $expected), + "$actual is not less-than-or-equal-to $expected"); +} + +sub assert_num_gt +{ + my ($self, $expected, $actual) = @_; + + $self->assert(($actual > $expected), + "$actual is not greater-than $expected"); +} + +sub assert_num_lt +{ + my ($self, $expected, $actual) = @_; + + $self->assert(($actual < $expected), + "$actual is not less-than $expected"); +} + +sub assert_date_matches +{ + my ($self, $expected, $actual, $tolerance) = @_; + + my ($expected_dt, $expected_str, $actual_dt, $actual_str); + + # $expected may be a DateTime object or an ISO8601 string + my $reftype = ref $expected; + if (not $reftype) { + $expected_str = $expected; + $expected_dt = DateTime::Format::ISO8601->parse_datetime($expected); + } + elsif ($reftype ne 'DateTime') { + die "wanted string or 'DateTime' for expected, got '$reftype'"; + } + else { + $expected_dt = $expected; + $expected_str = $expected_dt->stringify(); + } + + # $actual may be a DateTime object or an ISO8601 string + $reftype = ref $actual; + if (not $reftype) { + $actual_str = $actual; + $actual_dt = DateTime::Format::ISO8601->parse_datetime($actual); + } + elsif ($reftype ne 'DateTime') { + die "wanted string or 'DateTime' for actual, got '$reftype'"; + } + else { + $actual_dt = $actual; + $actual_str = $actual_dt->stringify(); + } + + # $tolerance is in seconds, default 0 + $tolerance //= 0; + + # XXX here is where to check that timezones match: + # XXX * if one has a timezone and the other doesn't, fail + # XXX * if both have timezones but they're different, fail + # XXX otherwise, carry on... + + my $diff = $expected_dt->epoch() - $actual_dt->epoch(); + + my $msg = "expected '$expected_str', got '$actual_str'"; + if ($tolerance) { + $msg .= " (difference $diff is greater than $tolerance)"; + } + + $self->assert((abs($diff) <= $tolerance), $msg); +} + +sub assert_file_test +{ + my ($self, $path, $test_type) = @_; + + # see `perldoc -f -X` for valid test types + $test_type ||= '-e'; + my $test = "$test_type \$path"; + xlog "XXX test=<$test> path=<$path>"; + my $result = eval $test; + die $@ if $@; + $self->assert($result, "'$path' failed '$test_type' test"); +} + +sub assert_not_file_test +{ + my ($self, $path, $test_type) = @_; + + # see `perldoc -f -X` for valid test types + $test_type ||= '-e'; + my $test = "$test_type \$path"; + xlog "XXX test=<$test> path=<$path>"; + my $result = eval $test; + die $@ if $@; + $self->assert(!$result, + "'$path' unexpectedly passed '$test_type' test"); +} + +sub new_test_url +{ + my ($self, $content_or_app) = @_; + + return Cassandane::Util::TestURL->new({ + app => $content_or_app, + }); +} + +1; diff --git a/cassandane/Cassandane/Unit/TestPlan.pm b/cassandane/Cassandane/Unit/TestPlan.pm new file mode 100644 index 0000000000..afce9dbecc --- /dev/null +++ b/cassandane/Cassandane/Unit/TestPlan.pm @@ -0,0 +1,1087 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Unit::TestPlanItem; +use strict; +use warnings; +use IO::Handle; +use POSIX; +use Time::HiRes qw(time); + +use lib '.'; +use Cassandane::Unit::TestCase; + +sub new +{ + my ($class, $suite) = @_; + my $self = { + suite => $suite, + loaded_suite => undef, + denied => {}, + allowed => {}, + }; + return bless $self, $class; +} + +sub _get_loaded_suite +{ + my ($self) = @_; + return $self->{loaded_suite} ||= Test::Unit::Loader::load($self->{suite}); +} + +sub _is_allowed +{ + my ($self, $name) = @_; + + # Rules are: + # deny if method has been explicitly denied + return 0 if $self->{denied}->{$name}; + + # deny if test name matches any denied pattern + $name =~ $_ && return 0 for @{ $self->{denied_patterns} // [] }; + + # allow if method has been explicitly allowed + return 1 if $self->{allowed}->{$name}; + + # allow if test name matches any allowed patterns + my @allow_patterns = @{ $self->{allowed_patterns} // []}; + $name =~ $_ && return 1 for @allow_patterns; + + # deny if anything is explicitly allowed + return 0 if %{$self->{allowed}} || @allow_patterns; + + # finally, allow + return 1; +} + +sub _deny +{ + my ($self, $test) = @_; + if (ref $test) { + push @{ $self->{denied_patterns} }, $test; + } else { + $self->{denied}->{$test} = 1; + } + return; +} + +sub _allow +{ + my ($self, $test) = @_; + if (ref $test) { + push @{ $self->{allowed_patterns} }, $test; + } else { + $self->{allowed}->{$test} = 1; + } + return; +} + +package Cassandane::Unit::Worker; +use Storable qw(freeze thaw); +use MIME::Base64; + +my $nextid = 1; + +sub new +{ + my ($class) = @_; + my $self = { + id => $nextid++, + pid => undef, + downpipe => undef, + uppipe => undef, + busy => 0, + handler => undef, + }; + return bless $self, $class; +} + +sub _pipe_read_fh +{ + my ($r, $w) = @_; + + POSIX::close($w); + my $fh = IO::Handle->new_from_fd($r, "r"); + $fh->autoflush(1); + return $fh; +} + +sub _pipe_write_fh +{ + my ($r, $w) = @_; + + POSIX::close($r); + my $fh = IO::Handle->new_from_fd($w, "w"); + $fh->autoflush(1); + return $fh; +} + +sub start +{ + my ($self) = @_; + + my ($dr, $dw) = POSIX::pipe(); + die "Cannot create down pipe: $!" + unless defined $dw; + + my ($ur, $uw) = POSIX::pipe(); + die "Cannot create up pipe: $!" + unless defined $uw; + + my $pid = fork(); + die "Cannot fork: $!" unless defined $pid; + + if ($pid) + { + # parent + $self->{downpipe} = _pipe_write_fh($dr, $dw); + $self->{uppipe} = _pipe_read_fh($ur, $uw); + $self->{pid} = $pid; + } + else + { + # child + $self->{downpipe} = _pipe_read_fh($dr, $dw); + $self->{uppipe} = _pipe_write_fh($ur, $uw); + $ENV{TEST_UNIT_WORKER_ID} = $self->{id}; # 1, 2, 3... + $ENV{TEST_UNIT_BASENAME} = $0; + $0 = "$ENV{TEST_UNIT_BASENAME} ($ENV{TEST_UNIT_WORKER_ID})"; + $self->_mainloop(); + exit(0); + } +} + +sub _send +{ + my ($fh, $fmt, @args) = @_; + my $msg = sprintf($fmt, @args); +# print STDERR "--> \"$msg\"\n"; + syswrite($fh, $msg) + or die "Cannot write to pipe: $!"; +} + +sub _receive +{ + my ($fh) = @_; + my $msg = $fh->gets() + or return; +# print STDERR "<-- \"$msg\"\n"; + chomp $msg; + return $msg; +} + +sub _mainloop +{ + my ($self) = @_; + + while (my $msg = _receive($self->{downpipe})) + { + my ($command, @args) = split(/\s+/, $msg); + + if ($command eq 'stop') + { + return; + } + elsif ($command eq 'run') + { + my ($witem) = thaw(decode_base64($args[0])); + $0 = "$ENV{TEST_UNIT_BASENAME} ($ENV{TEST_UNIT_WORKER_ID}) $witem->{suite}.$witem->{testname}"; + $self->{handler}->($witem); + $0 = "$ENV{TEST_UNIT_BASENAME} ($ENV{TEST_UNIT_WORKER_ID})"; + _send($self->{uppipe}, + "done %s\n", encode_base64(freeze($witem), '')); + } + else + { + print STDERR "_mainloop: unknown command '$command'\n"; + } + } +} + +sub get_reply +{ + my ($self) = @_; + return if !$self->{busy}; + my $msg = _receive($self->{uppipe}); + return if !defined $msg; + my ($command, @args) = split(/\s+/, $msg); + die "Unknown message \"$msg\"" + if ($command ne 'done'); + $self->{busy} = 0; + my ($witem) = thaw(decode_base64($args[0])); + return $witem; +} + +sub assign +{ + my ($self, $witem) = @_; + $witem->{start_time} = time(); + _send($self->{downpipe}, + "run %s\n", encode_base64(freeze($witem), '')); + $self->{busy} = 1; +} + +sub stop +{ + my ($self) = @_; + eval + { + # We don't care if this dies, it just + # means the Worker has died prematurely. + _send($self->{downpipe}, "stop\n"); + }; + while (1) + { + my $res = waitpid($self->{pid}, 0); + last if ($res < 0 || $res == $self->{pid}); + } + $self->_cleanup(); +} + +sub _cleanup +{ + my ($self) = @_; + + if ($self->{downpipe}) + { + close $self->{downpipe}; + $self->{downpipe} = undef; + } + + if ($self->{uppipe}) + { + close $self->{uppipe}; + $self->{uppipe} = undef; + } + + $self->{pid} = undef; +} + +sub DESTROY +{ + my ($self) = @_; + $self->_cleanup(); +} + +package Cassandane::Unit::WorkerPool; + +use Errno qw(EINTR); + +sub new +{ + my ($class, %params) = @_; + my $self = { + workers => [], + maxworkers => 2, + pending => [], + handler => sub { die "This should not happen"; }, + }; + foreach my $p (qw(maxworkers handler)) + { + $self->{$p} = $params{$p} if $params{$p}; + } + return bless $self, $class; +} + +sub start +{ + my ($self) = @_; + + while (scalar @{$self->{workers}} < $self->{maxworkers}) + { + my $w = Cassandane::Unit::Worker->new(); + $w->{handler} = $self->{handler}; + $w->start(); + push(@{$self->{workers}}, $w); + } +} + +# Assign an work item to an idle worker if necessary +# block until a worker is idle. +sub assign +{ + my ($self, $witem) = @_; + + my @idle = grep { !$_->{busy}; } @{$self->{workers}}; + my $w = shift @idle || $self->_wait(); + $w->assign($witem); +} + +# Wait for a Worker to send back a completed work item. +# Mark the Worker idle, remember its work item where +# retrieve() will find it, and returns the Worker. +sub _wait +{ + my ($self) = @_; + + + # Build the bit mask for select() + my $rbits = ''; + foreach my $w (@{$self->{workers}}) + { + next if (!$w->{busy}); + vec($rbits, fileno($w->{uppipe}), 1) = 1; + } + + # select() with no timeout + my $res; + do { + $res = select($rbits, undef, undef, undef); + } while ($res < 0 && $! == EINTR); + die "select failed: $!" if ($res < 0); + + # discover which of our workers has responded + foreach my $w (@{$self->{workers}}) + { + if (vec($rbits, fileno($w->{uppipe}), 1)) + { + push(@{$self->{pending}}, $w->get_reply()); + return $w; + } + } + die "Unexpected result from select: $res"; +} + +# Retrieve a completed work item. If $blocking is true, +# wait if necessary (used when draining i.e. no more work +# items will be made available). +sub retrieve +{ + my ($self, $blocking) = @_; + + if ($blocking && !scalar @{$self->{pending}}) + { + my @busy = grep { $_->{busy}; } @{$self->{workers}}; + $self->_wait() if (scalar @busy); + } + return shift @{$self->{pending}}; +} + +# reap all workers +sub stop +{ + my ($self) = @_; + + while (my $w = pop @{$self->{workers}}) + { + $w->stop(); + } +} + +sub DESTROY +{ + my ($self) = @_; + $self->stop(); +} + +package Cassandane::Unit::WorkerListener; +use base qw(Test::Unit::Listener); +use Cassandane::Util::Log; + +sub new +{ + my ($class) = shift; + my $self = { + witem => undef, + }; + return bless $self, $class; +} + +sub start_suite +{ + my ($self, $suite) = @_; + # nothing to see here +} + +sub start_test +{ + my ($self, $test) = @_; + # nothing to see here +} + +sub end_test +{ + my ($self, $test) = @_; + # nothing to see here +} + +sub end_suite +{ + my ($self, $suite) = @_; + # nothing to see here +} + +sub add_error +{ + my ($self, $test, $exception) = @_; + my $witem = $self->{witem}; + $witem->{result} = 'error'; + + # Remove '-object' which points at the TestCase, which will have all + # sorts of stuff we can't thaw. We have enough information to + # discover the right TestCase in the parent process. + $exception->{'-object'} = undef; + $witem->{exception} = $exception; +} + +sub add_failure +{ + my ($self, $test, $exception) = @_; + my $witem = $self->{witem}; + $witem->{result} = 'fail'; + + # Remove '-object' which points at the TestCase, which will have all + # sorts of stuff we can't thaw. We have enough information to + # discover the right TestCase in the parent process. + $exception->{'-object'} = undef; + $witem->{exception} = $exception; +} + +sub add_pass +{ + my ($self, $test) = @_; + my $witem = $self->{witem}; + $witem->{result} = 'pass'; +} + +package Cassandane::Unit::TestPlan; +use File::Find; +use File::Temp qw(tempfile); +use File::Path qw(mkpath); +use Data::Dumper; +use Cassandane::Util::Log; + +my @test_roots = ( + 'Cassandane/Test', + 'Cassandane/Cyrus', +); + +sub new +{ + my ($class, %opts) = @_; + my $self = { + schedule => {}, + keep_going => delete $opts{keep_going} || 0, + log_directory => delete $opts{log_directory}, + maxworkers => delete $opts{maxworkers} || 1, + skip_slow => delete $opts{skip_slow} // 1, + slow_only => delete $opts{slow_only} // 0, + }; + die "Unknown options: " . join(' ', keys %opts) + if scalar %opts; + return bless $self, $class; +} + +sub _get_item +{ + my ($self, $suite) = @_; + return $self->{schedule}->{$suite} ||= + Cassandane::Unit::TestPlanItem->new($suite); +} + +sub _schedule +{ + my ($self, $neg, $path, $testname) = @_; + return if ($path =~ m/\/TestCase\.pm$/); + + my $suite = $path; + $suite =~ s/\.pm$//; + $suite =~ s/\//::/g; + + if ($neg eq '!') + { + if (defined $testname) + { + # disable a specific test + $self->_get_item($suite)->_deny($testname); + } + else + { + # remove entire suite + delete $self->{schedule}->{$suite}; + } + } + else + { + # add to the schedule + my $item = $self->_get_item($suite); + if (defined $testname) + { + $item->_allow($testname) if $testname; + } + } +} + +# Returns ($neg, $ostype, $ospath, $testname) +sub _parse_test_spec +{ + my ($name) = @_; + + my ($neg, $path) = ($name =~ m/^([~!]?)(.*)$/); + $path =~ s/\.pm$//g; + $path =~ s/::/\//g; + $path =~ s/\./\//g; + $path =~ s/\/+/\//g; + $path =~ s/^\/*//; + $path =~ s/\/*$//; + + $neg = '!' if $neg eq '~'; + + # Allow Cyrus::TesterJMAP and TesterJMAP to work + my @paths; + + my @dirs = split('/', $path); + + while (@dirs) { + push @paths, join('/', @dirs); + shift @dirs; + } + + foreach my $candidate (@paths) { + foreach my $root (@test_roots) + { + return ($neg, 'd', $candidate, undef) + if ($root eq $candidate); + + my $fpath = $candidate; + $fpath = "$root/$candidate" + if ("$root/" ne substr($candidate, 0, length($root)+1)); + + return ($neg, 'd', $fpath, undef) + if ( -d $fpath ); + return ($neg, 'f', "$fpath.pm", undef) + if ( -f "$fpath.pm" ); + + my $test; + ($fpath, $test) = ($fpath =~ m/^(.*)\/([^\/]+)$/); + next unless defined $test; + + if ( -f "$fpath.pm" ) { + if ($test =~ /\*/) { + my @hunks = split /\*+/, $test, -1; + my $regex = join q{.*}, map {; quotemeta } @hunks; + return ($neg, 'f', "$fpath.pm", qr{\A$regex\z}); + } else { + return ($neg, 'f', "$fpath.pm", $test) + } + } + } + } + + die "Unrecognised test specification: $name"; +} + +sub _default_test_list +{ + my ($self) = @_; + + my $cassini = Cassandane::Cassini->instance(); + my @tosuppress = split /\s+/, $cassini->val('cassandane', 'suppress', ''); + + my %default; + my %suppressed; + @default{@test_roots} = (); + + # skip suppressions + foreach my $s (@tosuppress) { + if (exists $default{$s}) { + # if it's named explicitly in the default list, un-name it + delete $default{$s}; + } + else { + # otherwise, add a negation for it + $suppressed{"!$s"} = undef; + } + } + + die "no default tests" if not scalar keys %default; + return (sort(keys %default), sort(keys %suppressed)); +} + +sub schedule +{ + my ($self, @names) = @_; + + if (not scalar @names) { + # if no names provided, use default list + @names = $self->_default_test_list(); + } + elsif (not scalar grep { m/^[^!~]/ } @names) { + # if only negations provided, start with default list + @names = ($self->_default_test_list(), @names); + } + + foreach my $name (@names) + { + my ($neg, $type, $path, $test) = _parse_test_spec($name); + + # slow test explicitly requested by name, so turn off the filter + if (defined $test + and ! ref $test + and $test =~ m/_slow$/ + and ! $neg + and $self->{skip_slow}) + { + xlog "$name was explicitly requested. Enabling slow tests!"; + $self->{skip_slow} = 0; + } + + # non-slow test explicitly requested by name, so turn off slow-only + if (defined $test + and ! ref $test + and $test !~ m/_slow$/ + and ! $neg + and $self->{slow_only}) + { + xlog "$name was explicitly requested. Enabling regular tests!"; + $self->{slow_only} = 0; + } + + if ($type eq 'd') + { + opendir DIR, $path + or die "Cannot open directory $path for reading: $!"; + while ($_ = readdir DIR) + { + next unless m/\.pm$/; + $self->_schedule($neg, "$path/$_", undef); + } + closedir DIR; + } + else + { + $self->_schedule($neg, $path, $test); + } + } +} + +sub check_sanity +{ + my ($self) = @_; + + # collect tiny-tests directories that are used by test modules + my %used_tt_dirs; + find({ + no_chdir => 1, + wanted => sub { + my $fname = $File::Find::name; + + return if not -f $fname; + return if $fname !~ m/\.pm$/; + + open my $fh, '<', $fname or die "open $fname: $!"; + while (<$fh>) { + if (m{^\s*use\s+Cassandane::Tiny::Loader\s* + (['"]) + (.*?) + \1 + \s*;\s*$ + }x) + { + push @{$used_tt_dirs{$2}}, $fname; + } + } + close $fh; + }, + }, @test_roots); + + # collect tiny-tests directories that exist on disk + my %real_tt_dirs; + find({ + no_chdir => 1, + wanted => sub { + my $fname = $File::Find::name; + + my ($tt, $suite, $test) = split q{/}, $fname, 3; + return if not $suite; + + if (not $test) { + # explicit initialisation to detect directories with no files + $real_tt_dirs{"$tt/$suite"} //= 0; + return; + } + + $real_tt_dirs{"$tt/$suite"} ++; + }, + }, 'tiny-tests') if -d 'tiny-tests'; + + # whinge about bad test modules + while (my ($tt, $modules) = each %used_tt_dirs) { + # XXX this one might not be an error if we start doing this + # XXX intentionally, perhaps to run the same group of tests under + # XXX different setups or configurations + die "@{$modules} share tiny-tests directory $tt" + if scalar @{$modules} > 1; + + die "$modules->[0] uses nonexistent tiny-tests directory $tt" + if not exists $real_tt_dirs{$tt}; + } + + # whinge about orphaned directories + while (my ($tt, $ntests) = each %real_tt_dirs) { + die "$tt directory is not used by any tests" + if not $used_tt_dirs{$tt}; + + die "$tt directory contains no tests" + if not $ntests; + } + + # whinge about 'tiny-tests' directories in unexpected places + # start searching in the parent directory so that we're checking the + # whole cyrus-imapd repository + my @unexpected_tt_dirs = grep { + chomp; + $_ ne '../cassandane/tiny-tests'; + } qx{find .. -type d -name tiny-tests}; + die "unexpected extra tiny-tests directories: @unexpected_tt_dirs" + if @unexpected_tt_dirs; +} + +# +# Get the entire expanded schedule as specific {suite,testname} tuples, +# sorted in alphabetic order on suite name then testname. +# +sub _get_schedule +{ + my ($self) = @_; + + my @items = sort { $a->{suite} cmp $b->{suite} } values %{$self->{schedule}}; + my @res; + foreach my $item (@items) + { + my $loaded = $item->_get_loaded_suite(); + foreach my $name (sort @{$loaded->names()}) + { + $name =~ s/^test_//; + next unless $item->_is_allowed($name); + + my (@settings) = Cassandane::Unit::TestCase->make_parameter_settings($item->{suite}); + foreach my $setting (@settings) + { + push(@res, { + suite => $item->{suite}, + testname => $name, + result => 'unknown', + exception => undef, + logfile => undef, + parameter_setting => $setting, + }); + } + } + } + return @res; +} + +# Sort and return the schedule as a list of "suite.test" strings +# e.g. "Cassandane::Cyrus::Quota.using_storage". +sub list +{ + my ($self) = @_; + + my @res; + foreach my $witem ($self->_get_schedule()) + { + push(@res, "$witem->{suite}.$witem->{testname}"); + } + + return @res; +} + +sub _setup_logfile +{ + my ($self, $witem) = @_; + + # Flush the old stdout/stderr + ${\*STDOUT}->flush; + ${\*STDERR}->flush; + + # Save the old stdout/stderr - this is important + # for the single threaded case where inter-test + # messages go to the original stdout. + open my $oldout, '>&', \*STDOUT + or die "Cannot save STDOUT"; + open my $olderr, '>&', \*STDERR + or die "Cannot save STDERR"; + + my $logfh; + my $logfile; + srand; # XXX does this fix the tempfile() issue (#42)? + if (defined $self->{log_directory}) + { + # Log directory specified so create the log file + # there with a semi-obvious name + if (! -d $self->{log_directory}) + { + mkpath($self->{log_directory}) + or die "Cannot create directory $self->{log_directory}: $!"; + } + my $template = $witem->{suite} . '.' . $witem->{testname} . '.XXXXXX'; + $template =~ s/::/./g; + ($logfh, $logfile) = tempfile($template, + DIR => $self->{log_directory}, + SUFFIX => '.log', + UNLINK => 0); + chmod(0644, $logfile); + } + else + { + # Create a per-test temporary logfile + ($logfh, $logfile) = tempfile(UNLINK => 0); + } + + # Redirect both STDOUT and STDERR to the log file + open STDOUT, '>&', $logfh + or die "Cannot redirect STDOUT"; + open STDERR, '>&', $logfh + or die "Cannot redirect STDERR"; + close $logfh; + + $witem->{logfile} = $logfile; + $self->{oldout} = $oldout; + $self->{olderr} = $olderr; +} + +# Redirect STDOUT and STDERR back to their original fds +sub _restore_stdout +{ + my ($self) = @_; + + ${\*STDOUT}->flush; + open STDOUT, '>&', $self->{oldout} + or die "Cannot restore STDOUT"; + close $self->{oldout}; + $self->{oldout} = undef; + + ${\*STDERR}->flush; + open STDERR, '>&', $self->{olderr} + or die "Cannot restore STDERR"; + close $self->{olderr}; + $self->{olderr} = undef; +} + +sub _dump_logfile +{ + my ($logfile) = @_; + + open LOGFILE, '<', $logfile + or die "Cannot open $logfile for reading: $!"; + while () + { + print STDERR $_; + } + close LOGFILE; +} + +sub _get_suite_and_test +{ + my ($self, $witem) = @_; + my $suite = $self->_get_item($witem->{suite})->_get_loaded_suite(); + my ($test) = grep { $_->name() eq 'test_' . $witem->{testname}; } @{$suite->tests()}; + return ($suite, $test); +} + +sub _run_workitem +{ + my ($self, $witem, $result, $runner, $annotate_flag) = @_; + my ($suite, $test) = $self->_get_suite_and_test($witem); + Cassandane::Unit::TestCase->enable_test($witem->{testname}); + $self->_setup_logfile($witem); + Cassandane::Unit::TestCase->apply_parameter_setting($witem->{parameter_setting}); + $suite->run($result, $runner); + + if ($test->can('post_tear_down')) + { + eval + { + $test->post_tear_down($witem->{result}); + }; + my $ex = $@; + if ($ex) + { + $result->add_error($test, + Test::Unit::Error->make_new_from_error($ex)); + } + } + + $self->_restore_stdout(); + if ($annotate_flag) + { + $test->annotate_from_file($witem->{logfile}); + _dump_logfile($witem->{logfile}) if (get_verbose > 1); + unlink($witem->{logfile}) if (!defined $self->{log_directory}); + } +} + +sub _finish_workitem +{ + my ($self, $witem, $result, $runner) = @_; + my ($suite, $test) = $self->_get_suite_and_test($witem); + + # The test was actually started earlier by _run_workitem, but its + # start_test event wasn't sent. It might have got swallowed due to + # the output format listeners being removed in the workitem handling. + # Send the event again now, to make sure the formatters actually get + # it... + $result->start_test($test); + # But! If they're computing their own start time based on this event + # they'll get it wrong. We know the real start time, so tell the + # formatter to use that instead. + if ($runner->can('tell_formatters')) + { + $runner->tell_formatters('fake_start_time', + $test, + $witem->{start_time}); + } + + $test->annotate_from_file($witem->{logfile}); + _dump_logfile($witem->{logfile}) if (get_verbose > 1); + unlink($witem->{logfile}) if (!defined $self->{log_directory}); + + if ($witem->{result} eq 'pass') + { + $result->add_pass($test); + } + elsif ($witem->{result} eq 'fail') + { + $witem->{exception}->{'-object'} = $test; + $result->add_failure($test, $witem->{exception}); + } + elsif ($witem->{result} eq 'error') + { + $witem->{exception}->{'-object'} = $test; + $result->add_error($test, $witem->{exception}); + } + $result->end_test($test); +} + +sub _setup_worker_listeners +{ + my ($result, $wlistener) = @_; + + # Remove the output format listener + my @list; + my $found = 0; + foreach my $ll (@{$result->{_Listeners}}) + { + push(@list, $ll) + unless defined $ll->{remove_me_in_cassandane_child}; + $found ||= (ref($ll) eq ref($wlistener)); + } + push(@list, $wlistener) + unless $found; + $result->{_Listeners} = \@list; +} + +# The 'run' method makes this class look sufficiently like a +# Test::Unit::TestCase that Test::Unit::TestRunner will happily run it. +# This enables us to run all our scheduled tests with a single +# TestResult and a single summary of errors. +sub run +{ + my ($self, $result, $runner) = @_; + + my $maxworkers = $self->{maxworkers} || 1; + + # we expand the schedule before forking the + # workers so that we can just hand the reference + # to the worker + my @workitems = $self->_get_schedule(); + + # try to clean up after ourselves on interrupt + my $interrupted = 0; + $SIG{INT} = sub { + $interrupted ++; + # third ^C will terminate without cleanup + $SIG{INT} = 'DEFAULT' if $interrupted >= 2; + }; + + if ($maxworkers > 1) + { + # multi-threaded case: use worker pool + + # we want an error not a signal + $SIG{PIPE} = 'IGNORE'; + + # Just In Case any code samples this in a TestCase c'tor + $ENV{TEST_UNIT_WORKER_ID} = 'invalid'; + + my $wlistener = Cassandane::Unit::WorkerListener->new(); + + my $pool = Cassandane::Unit::WorkerPool->new( + maxworkers => $maxworkers, + handler => sub { + my ($witem) = @_; + $wlistener->{witem} = $witem; + _setup_worker_listeners($result, $wlistener); + $self->_run_workitem($witem, $result, $runner, 0); + }, + ); + my $witem; + $pool->start(); + # first ^C stops spawning new work items + while ($interrupted < 1 && ($witem = shift @workitems)) + { + $pool->assign($witem) + if ($self->{keep_going} || $result->was_successful()); + while ($witem = $pool->retrieve(0)) + { + $self->_finish_workitem($witem, $result, $runner); + } + } + # second ^C stops waiting for work items to finish + while ($interrupted < 2 && ($witem = $pool->retrieve(1))) + { + $self->_finish_workitem($witem, $result, $runner); + } + $pool->stop(); + } + else + { + # single threaded case: just run it all in-process + foreach my $witem (@workitems) + { + $self->_run_workitem($witem, $result, $runner, 1); + last if ($interrupted || !($self->{keep_going} || $result->was_successful())); + } + } + + return $result->was_successful(); +} + +1; diff --git a/cassandane/Cassandane/Util/DateTime.pm b/cassandane/Cassandane/Util/DateTime.pm new file mode 100644 index 0000000000..953e712020 --- /dev/null +++ b/cassandane/Cassandane/Util/DateTime.pm @@ -0,0 +1,261 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::DateTime; +use strict; +use warnings; +use DateTime; +use POSIX qw(strftime); + +use Exporter (); +our @ISA = qw(Exporter); +our @EXPORT = qw( + &from_iso8601 &to_iso8601 + &from_rfc822 &to_rfc822 + &from_rfc3501 &to_rfc3501 + ); + +# +# Construct and return a DateTime object using a string in the +# "combined basic" format defined in ISO8601, specifically +# +# T[Z]. +# +# Each field is fixed width zero-padded decimal numeric, +# 4 digits for year and 2 digits for all the others. +# The optional Z suffix indicates Zulu (UTC aka GMT) +# time, otherwise localtime is assumed. +# +sub from_iso8601($) +{ + my ($s) = @_; + my ($year, $mon, $day, $hour, $min, $sec, $zulu) = + ($s =~ m/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/); + return unless defined $sec; + return if ($year < 1970 || $year > 2037); + return if ($mon < 1 || $mon > 12); + return if ($day < 1 || $day > 31); + return if ($hour < 0 || $hour > 23); + return if ($min < 0 || $min > 59); + return if ($sec < 0 || $sec > 60); # allow for leap second + +# printf STDERR "%s -> year=%u mon=%u day=%u hour=%u min=%u sec=%u\n", +# $s, $year, $mon, $day, $hour, $min, $sec; + + my $tz = ($zulu ? 'GMT' : 'local'); + return DateTime->new( + year => $year, + month => $mon, + day => $day, + hour => $hour, + minute => $min, + second => $sec, + time_zone => $tz + ); +} + +# +# Given a DateTime, generate and return a string in ISO8601 +# combined basic format. +# +sub to_iso8601($) +{ + my ($dt) = @_; + return strftime("%Y%m%dT%H%M%SZ", gmtime($dt->epoch)); +} + +# Brief sanity test for parse_iso8601_datetime +# gnb@enki 566> date +%s -u -d '14 Oct 2010 16:19:52' +# 1287073192 +# die "Woops, from_iso8601 is broken" +# unless (from_iso8601('20101014T161952Z') == 1287073192); + +our %rfc822_months = ( + Jan => 1, + Feb => 2, + Mar => 3, + Apr => 4, + May => 5, + Jun => 6, + Jul => 7, + Aug => 8, + Sep => 9, + Oct => 10, + Nov => 11, + Dec => 12 + ); +our @rfc822_months = ( + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' + ); + +our @rfc822_days = ( + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun' + ); + +# +# Construct and return a DateTime object using a string in the +# format defined in RFC822 and its successors, which define +# the internet email message format. +# Example: Tue, 05 Oct 2010 11:19:52 +1100 +# +sub from_rfc822($) +{ + my ($s) = @_; + my ($wdayn, $day, $mon, $year, $hour, $min, $sec, $tzsign, $tzhour, $tzmin) = + ($s =~ m/^([A-Z][a-z][a-z]), (\d+) ([A-Z][a-z][a-z]) (\d{4}) (\d{2}):(\d{2}):(\d{2}) ([-+])(\d{2})(\d{2})$/); + return unless defined $tzmin; + return if ($year < 1970 || $year > 2037); + $mon = $rfc822_months{$mon}; + return unless defined $mon; + return if ($day < 1 || $day > 31); + return if ($hour < 0 || $hour > 23); + return if ($min < 0 || $min > 59); + return if ($sec < 0 || $sec > 60); # allow for leap second + return if ($tzhour < 0 || $tzhour > 23); + return if ($tzmin < 0 || $tzmin > 59); + +# printf STDERR "%s -> year=%u mon=%u day=%u hour=%u min=%u sec=%u tzsign=%s tzhour=%u tzmin=%u\n", +# $s, $year, $mon, $day, $hour, $min, $sec, $tzsign, $tzhour, $tzmin; + + return DateTime->new( + year => $year, + month => $mon, + day => $day, + hour => $hour, + minute => $min, + second => $sec, + time_zone => "$tzsign$tzhour$tzmin" + ); +} + +# +# Given a DateTime, generate and return a string in RFC822 format. +# +sub to_rfc822($) +{ + my ($dt) = @_; + + # We can't mix DateTime methods and strftime, because other parts of + # Cassandane foolishly construct DateTime using the 'from_epoch' but + # not the 'time_zone' parameters, resulting in a DT object in the + # UTC timezone instead of local. But conversely strftime() doesn't + # have a portable way to emit the fixed (non-local-specific) strings + # that the RFC expects. + my @lt = localtime($dt->epoch); + return strftime($rfc822_days[$lt[6]] . + ", %d " . + $rfc822_months[$lt[4]] . + " %Y %T %z", @lt); +} + + +# die "Woops, from_rfc822 is broken" +# unless (from_rfc822('Fri, 15 Oct 2010 03:19:52 +1100') == 1287073192); + +# +# Construct and return a DateTime object using a string in the +# format defined in RFC3501 which defines the IMAP protocol. +# Example: " 5-Oct-2010 09:19:52 +1100" (note leading space) +# +sub from_rfc3501($) +{ + my ($s) = @_; + my ($day, $mon, $year, $hour, $min, $sec, $tzsign, $tzhour, $tzmin) = + ($s =~ m/^\s*(\d+)-([A-Z][a-z][a-z])-(\d{4}) (\d{2}):(\d{2}):(\d{2}) ([-+])(\d{2})(\d{2})$/); + return unless defined $tzmin; + return if ($year < 1970 || $year > 2037); + $mon = $rfc822_months{$mon}; + return unless defined $mon; + return if ($day < 1 || $day > 31); + return if ($hour < 0 || $hour > 23); + return if ($min < 0 || $min > 59); + return if ($sec < 0 || $sec > 60); # allow for leap second + return if ($tzhour < 0 || $tzhour > 23); + return if ($tzmin < 0 || $tzmin > 59); + +# printf STDERR "%s -> year=%u mon=%u day=%u hour=%u min=%u sec=%u tzsign=%s tzhour=%u tzmin=%u\n", +# $s, $year, $mon, $day, $hour, $min, $sec, $tzsign, $tzhour, $tzmin; + + return DateTime->new( + year => $year, + month => $mon, + day => $day, + hour => $hour, + minute => $min, + second => $sec, + time_zone => "$tzsign$tzhour$tzmin" + ); +} + +# die "Woops, from_rfc3501 is broken" +# unless (from_rfc3501('15-Oct-2010 03:19:52 +1100') == 1287073192); + +# +# Given a DateTime, generate and return a string in RFC3501 format. +# +sub to_rfc3501($) +{ + my ($dt) = @_; + + my @lt = localtime($dt->epoch); + return strftime("%e-" + . $rfc822_months[$lt[4]] + . "-%Y %T %z", + @lt); +} + +1; diff --git a/cassandane/Cassandane/Util/Log.pm b/cassandane/Cassandane/Util/Log.pm new file mode 100644 index 0000000000..27208010e2 --- /dev/null +++ b/cassandane/Cassandane/Util/Log.pm @@ -0,0 +1,106 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::Log; +use strict; +use warnings; +use File::Basename; +use Scalar::Util qw(blessed); +use Sys::Syslog qw(:standard :macros); + +use Exporter (); +our @ISA = qw(Exporter); +our @EXPORT = qw( + &xlog &set_verbose &get_verbose + ); + +my $verbose = 0; + +openlog('cassandane', '', LOG_LOCAL6) + or die "Cannot openlog"; + +sub xlog +{ + my $id; + my $highlight = 0; + + # if the first argument is an object with an id() method, + # include the id it returns in the log message + if (ref $_[0] && blessed $_[0] && $_[0]->can('id')) { + my $obj = shift @_; + $id = $obj->id(); + } + + # if the first output argument starts with XXX, highlight the + # whole line when printing to stderr + if ($_[0] =~ m/^XXX/) { + $highlight = 1; + } + + # the current line number is in this frame + my (undef, undef, $line) = caller(); + # but the current subroutine name is in the parent frame, + # as the function-the-caller-called + my (undef, undef, undef, $sub) = caller(1); + $sub //= basename($0); + $sub =~ s/^Cassandane:://; + my $msg = "[$$] =====> $sub\[$line] "; + $msg .= "($id) " if $id; + $msg .= join(' ', @_); + if ($highlight) { + print STDERR "\033[33m" . $msg . "\033[0m\n"; + } + else { + print STDERR "$msg\n"; + } + syslog(LOG_ERR, "$msg"); +} + +sub set_verbose +{ + my ($v) = @_; + $verbose = 0 + $v; +} + +sub get_verbose +{ + return $verbose; +} + +1; diff --git a/cassandane/Cassandane/Util/Metronome.pm b/cassandane/Cassandane/Util/Metronome.pm new file mode 100644 index 0000000000..92d328c442 --- /dev/null +++ b/cassandane/Cassandane/Util/Metronome.pm @@ -0,0 +1,96 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::Metronome; +use strict; +use warnings; +use Time::HiRes qw(clock_gettime clock_nanosleep CLOCK_MONOTONIC); + +use lib '.'; +use Cassandane::Util::Log; + +sub new +{ + my ($class, %params) = @_; + + my $self = { + # These are floating point numbers in seconds + # with presumed nanosecond resolution + interval => 0.0, + error => 0.0, + last_tick => undef, + first_tick => undef, + nticks => 0, + }; + + $self->{interval} = $params{interval} + if defined $params{interval}; + $self->{interval} = 1.0/$params{rate} + if defined $params{rate}; + + return bless $self, $class; +} + +sub tick +{ + my ($self) = @_; + + my $now = clock_gettime(CLOCK_MONOTONIC); + $self->{first_tick} ||= $now; + $self->{nticks}++; + my $next_tick = ($self->{last_tick} || $now) + + $self->{interval}; + my $delay = ($next_tick + $self->{error} - $now); + clock_nanosleep(CLOCK_MONOTONIC, 1e9 * $delay) + if ($delay > 0.0); + $now = clock_gettime(CLOCK_MONOTONIC); + $self->{error} = $next_tick - $now; + $self->{last_tick} = $next_tick; +} + +sub actual_rate +{ + my ($self) = @_; + + return undef if !$self->{nticks}; + return $self->{nticks} / + (clock_gettime(CLOCK_MONOTONIC) - $self->{first_tick}); +} + +1; diff --git a/cassandane/Cassandane/Util/NetString.pm b/cassandane/Cassandane/Util/NetString.pm new file mode 100644 index 0000000000..39d883a5f7 --- /dev/null +++ b/cassandane/Cassandane/Util/NetString.pm @@ -0,0 +1,106 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +# code for reading and writing "netstrings", originally from FastMail's +# server utilities. +# +# A netstring is defined as: +# LENGTH COLON DATA COMMA +# where length is decimal [0-9]+ and is a count of BYTES +# +# Examples: +# 0:, +# 12:Hello world!, +# +# NOTE - there are no trailing endlines, just the comma. + +package Cassandane::Util::NetString; +use strict; +use warnings; +use vars qw(@ISA @EXPORT); + +@ISA = qw(Exporter); +@EXPORT = qw(print_netstring get_netstring); + +sub print_netstring { + my $fh = shift; + my $data = shift; + + die "Printing undefined network string" unless defined $data; + + my $size = length $data; + + print $fh "$size:$data," +} + +sub get_netstring { + my $fh = shift; + + my($r, $ns); + my $s = ""; + my $len = 0; + + # read the length + for (;;) { + defined($r = read($fh, $s, 1)) or return undef; + + return "" if !$r; + last if $s eq ":"; + return undef if $s !~ /^[0-9]$/; + + $len = 10 * $len + $s; + return undef if $len > 200000000; + } + + $s = ""; + + # read the string 'body' + defined($r = read($fh, $s, $len)) or return undef; + return "" if (!$r and $len != 0); # zero length is OK + $ns = $s; + + # read the trailing comma + defined($r = read($fh, $s, 1)) or return undef; + return "" if !$r; + return undef if $s ne ","; + + return $ns; +} + +1; diff --git a/cassandane/Cassandane/Util/SHA.pm b/cassandane/Cassandane/Util/SHA.pm new file mode 100644 index 0000000000..1b51ad20da --- /dev/null +++ b/cassandane/Cassandane/Util/SHA.pm @@ -0,0 +1,57 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +# The entire purpose of this module is to +# do the horrible dance needed to import either +# of Digest::SHA or the older Digest::SHA1 + +package Cassandane::Util::SHA; +use strict; +use warnings; +use vars qw(@ISA @EXPORT); + +@ISA = qw(Exporter); +@EXPORT = qw(sha1_hex sha1); + +BEGIN { + eval "use Digest::SHA qw(sha1_hex sha1); 1;" + || eval "use Digest::SHA1 qw(sha1_hex sha1);"; +} + +1; diff --git a/cassandane/Cassandane/Util/Sample.pm b/cassandane/Cassandane/Util/Sample.pm new file mode 100644 index 0000000000..dd96734c3e --- /dev/null +++ b/cassandane/Cassandane/Util/Sample.pm @@ -0,0 +1,132 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::Sample; +use strict; +use warnings; +use overload qw("") => \&as_string; + +sub new +{ + my ($class, @args) = @_; + + die "Unknown extra arguments" + if scalar(@args); + + my $self = + { + _total => 0.0, + _total2 => 0.0, + _n => 0, + _min => undef, + _max => undef, + }; + return bless($self, $class); +} + +sub add +{ + my ($self, $x) = @_; + + $self->{_total} += $x; + $self->{_total2} += $x * $x; + $self->{_n}++; + $self->{_min} = $x + if (!defined $self->{_min} || $x < $self->{_min}); + $self->{_max} = $x + if (!defined $self->{_max} || $x > $self->{_max}); +} + +sub nsamples +{ + my ($self) = @_; + return $self->{_n}; +} + +sub average +{ + my ($self) = @_; + die "No samples yet" if (!$self->{_n}); + return $self->{_total} / $self->{_n}; +} + +sub minimum +{ + my ($self) = @_; + die "No samples yet" if (!$self->{_n}); + return $self->{_min}; +} + +sub maximum +{ + my ($self) = @_; + die "No samples yet" if (!$self->{_n}); + return $self->{_max}; +} + +sub sample_deviation +{ + my ($self) = @_; + die "No samples yet" if ($self->{_n} < 2); + return sqrt( + ($self->{_n} * $self->{_total2} - $self->{_total} * $self->{_total}) + / + ($self->{_n} * ($self->{_n} - 1)) + ); +} + +sub as_string +{ + my ($self) = @_; + my $s = "no samples"; + if ($self->{_n} > 0) + { + $s = "count " . $self->nsamples() . + " minimum " . $self->minimum() . + " maximum " . $self->maximum() . + " average " . $self->average(); + if ($self->{_n} > 1) + { + $s .= " sample_deviation " . $self->sample_deviation(); + } + } + return $s; +} + +1; diff --git a/cassandane/Cassandane/Util/Setup.pm b/cassandane/Cassandane/Util/Setup.pm new file mode 100644 index 0000000000..20efd29916 --- /dev/null +++ b/cassandane/Cassandane/Util/Setup.pm @@ -0,0 +1,84 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::Setup; +use strict; +use warnings; +use base qw(Exporter); +use POSIX; +use User::pwent; +use Data::Dumper; + +use lib '.'; +use Cassandane::Util::Log; + +our @EXPORT = qw(&become_cyrus); + +my $me = $0; +my @saved_argv = @ARGV; + +sub become_cyrus +{ + my $cyrus = $ENV{CYRUS_USER}; + $cyrus //= 'cyrus'; + my $pw = getpwnam($cyrus); + die "No user named '$cyrus'" + unless defined $pw; + my $uid = getuid(); + if ($uid == $pw->uid) + { + xlog "already running as user $cyrus" if get_verbose; + } + elsif ($uid == 0) + { + xlog "setuid from root to $cyrus" if get_verbose; + setgid($pw->gid) + or die "Cannot setgid to group $pw->gid: $!"; + setuid($pw->uid) + or die "Cannot setuid to group $pw->uid: $!"; + } + else + { + xlog "using sudo to re-run as user $cyrus" if get_verbose; + my @cmd = ( qw(sudo -u), $cyrus, $me, @saved_argv ); + exec(@cmd); + } +} + +1; diff --git a/cassandane/Cassandane/Util/Slurp.pm b/cassandane/Cassandane/Util/Slurp.pm new file mode 100644 index 0000000000..6ed7e2b779 --- /dev/null +++ b/cassandane/Cassandane/Util/Slurp.pm @@ -0,0 +1,62 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2023 Fastmail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::Slurp; +use strict; +use warnings; +use base qw(Exporter); + +use lib '.'; + +our @EXPORT = qw(&slurp_file); + +sub slurp_file +{ + my ($filename) = @_; + + local $/; + open my $f, '<', $filename + or die "Cannot open $filename for reading: $!\n"; + my $str = <$f>; + close $f; + + return $str; +} + +1; diff --git a/cassandane/Cassandane/Util/Socket.pm b/cassandane/Cassandane/Util/Socket.pm new file mode 100644 index 0000000000..082d042eaf --- /dev/null +++ b/cassandane/Cassandane/Util/Socket.pm @@ -0,0 +1,91 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::Socket; +use strict; +use warnings; +use base qw(Exporter); +use IO::Socket::INET; +use IO::Socket::INET6; +use IO::Socket::UNIX; + +use lib '.'; +use Cassandane::Util::Log; + +our @EXPORT = qw(create_client_socket); + +sub create_client_socket +{ + my ($af, $host, $port) = @_; + my $sock; + + if ($af eq 'inet') + { + $host ||= '127.0.0.1'; + xlog "create_client_socket INET host=$host port=$port"; + return IO::Socket::INET->new( + Type => SOCK_STREAM, + PeerHost => $host, + PeerPort => $port); + } + elsif ($af eq 'inet6') + { + # IO::Socket::INET6 doesn't have an option to pass + # an address which is explicitly *without* the optional + # :port part, but it does allow us to use the same [] + # syntax for surrounding IPv6 address literals as Cyrus + $host ||= '::1'; + xlog "create_client_socket INET6 addr=[$host]:$port"; + return IO::Socket::INET6->new( + Domain => AF_INET6, + Type => SOCK_STREAM, + PeerAddr => "[$host]:$port"); + } + elsif ($af eq 'unix') + { + xlog "create_client_socket UNIX peer=$port"; + return IO::Socket::UNIX->new( + Type => SOCK_STREAM, + Peer => $port); + } + die "Cannot create sock for address family \"$af\""; +} + + +1; diff --git a/cassandane/Cassandane/Util/TestUrl.pm b/cassandane/Cassandane/Util/TestUrl.pm new file mode 100644 index 0000000000..d5ccd7ab4a --- /dev/null +++ b/cassandane/Cassandane/Util/TestUrl.pm @@ -0,0 +1,136 @@ +package Cassandane::Util::TestURL; + +use Plack::Loader; +use Plack::Request; +use Plack::Response; + +use Test::TCP; +use Carp qw(croak); + +use lib '.'; +use Cassandane::PortManager; + +sub new +{ + my ($pkg, $args) = @_; + + my %attrs; + + for my $required (qw(app)) { + $attrs{$required} = delete $args->{$required}; + unless ($attrs{$required}) { + croak("'$required' required for Cassandane::Test::URL->new"); + } + } + + my $self = bless {}, __PACKAGE__; + + $self->update($attrs{app}); + + return $self; +} + +sub url +{ + my ($self) = @_; + + if ($self->was_unregistered) { + croak("Cannot call ->url after ->unregister has been called!"); + } + + $self->{url}; +} + +sub _guard { shift->{_guard} } + +sub unregister +{ + my ($self) = @_; + + delete $self->{_guard}; + $self->{was_unregistered} = 1; +} + +sub was_unregistered { shift->{was_unregistered} } + +sub update +{ + my ($self, $content_or_app) = @_; + + unless (ref $content_or_app) { + my $content = $content_or_app; + # Plain ol' successful response + $content_or_app = sub { + return [ + 200, + [], + [ $content ], + ]; + }; + } + + { + package Shutdown::Guard; + + use POSIX qw(_exit); + + sub new { bless {}, __PACKAGE__ } + + sub DESTROY { + _exit(0); + } + } + + my $host = "127.0.0.1"; + + my $app = sub { + # No matter how we we leave this block, the $guard will go out + # of scope while in the run phase, which means it can exit Perl + # before any END block or object destructors are run. + my $guard = Shutdown::Guard->new; + + # Test::TCP uses Term to stop us. We could just let that + # terminate Perl cleanly, but in case something has put in a + # signal handle or something else, let's just call exit here. + # This will also cause our $guard above to go out of scope + # in a timely manner during the run phase. + local $SIG{TERM} = sub { exit; }; + + my $sock_or_port = shift; + my $server = Plack::Loader->auto( + host => $host, + port => $sock_or_port, + ); + + $server->run($content_or_app); + }; + + unless ($self->_guard) { + if ($self->was_unregistered) { + # We've already been unregistered. It's no longer safe to call + # update because our port may have been reused by someone else + # and our url is no longer useable + croak("Cannot call ->update after ->unregister has been called!"); + } + + my $guard = Test::TCP->new( + host => $host, + code => $app, + port => Cassandane::PortManager::alloc($host), + ); + + my $port = $guard->port; + + $self->{url} = "http://$host:$port/"; + $self->{_guard} = $guard; + } else { + $self->_guard->{code} = $app; + + $self->_guard->stop; + $self->_guard->start; + } + + return; +} + +1; diff --git a/cassandane/Cassandane/Util/Wait.pm b/cassandane/Cassandane/Util/Wait.pm new file mode 100644 index 0000000000..2028b389f2 --- /dev/null +++ b/cassandane/Cassandane/Util/Wait.pm @@ -0,0 +1,82 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::Wait; +use strict; +use warnings; +use base qw(Exporter); +use Time::HiRes qw(sleep gettimeofday tv_interval); + +use lib '.'; +use Cassandane::Util::Log; + +our @EXPORT = qw(&timed_wait); + +sub timed_wait +{ + my ($condition, %p) = @_; + $p{delay} = 0.010 # 10 millisec + unless defined $p{delay}; + $p{maxwait} = 20.0 + unless defined $p{maxwait}; + $p{description} = 'unknown condition' + unless defined $p{description}; + + my $start = [gettimeofday()]; + my $delayed = 0; + while ( ! $condition->() ) + { + die "Timed out waiting for " . $p{description} + if (tv_interval($start, [gettimeofday()]) > $p{maxwait}); + sleep($p{delay}); + $delayed = 1; + $p{delay} *= 1.5; # backoff + } + + if ($delayed) + { + my $t = tv_interval($start, [gettimeofday()]); + xlog "Waited $t sec for " . $p{description}; + return $t; + } + return 0.0; +} + + +1; diff --git a/cassandane/Cassandane/Util/Words.pm b/cassandane/Cassandane/Util/Words.pm new file mode 100644 index 0000000000..52ed1a1e8d --- /dev/null +++ b/cassandane/Cassandane/Util/Words.pm @@ -0,0 +1,87 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cassandane::Util::Words; +use strict; +use warnings; + +use Exporter (); +our @ISA = qw(Exporter); +our @EXPORT = qw( + &random_word + ); + +my @words; +my @remaining; + +use constant WORDFILE => '/usr/share/dict/words'; +use constant STRIDE => 7; +use constant MAX_WORDS => 2048; +use constant MIN_LENGTH => 2; +use constant MAX_LENGTH => 7; + +# Extract some well-formatted short words from the dictionary file +sub _read_words +{ + my $i = 0; + open DICT,'<',WORDFILE + or die "Cannot open " . WORDFILE . " for reading: $!"; + while () + { + chomp; + $_ = lc; + next unless m/^[a-z]+$/; + next if length $_ > MAX_LENGTH || length $_ < MIN_LENGTH; + next if $i++ < STRIDE; + $i = 0; + push(@words, $_); + last if scalar @words == MAX_WORDS; + } + close DICT; +} + +sub random_word +{ + _read_words() + if (!scalar @words); + @remaining = @words unless scalar @remaining; + return $remaining[int(rand(scalar @remaining))]; +} + +1; diff --git a/cassandane/Cyrus/CheckReplication.pm b/cassandane/Cyrus/CheckReplication.pm new file mode 100755 index 0000000000..038215f9f4 --- /dev/null +++ b/cassandane/Cyrus/CheckReplication.pm @@ -0,0 +1,641 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011-2017 FastMail Pty Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Fastmail Pty Ltd" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# FastMail Pty Ltd +# PO Box 234 +# Collins St West 8007 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Fastmail Pty. Ltd." +# +# FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +# EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE FOR ANY SPECIAL, INDIRECT +# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +# USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +package Cyrus::CheckReplication; + +use strict; +use warnings; +use Mail::IMAPTalk; +use Encode; +use Data::Dumper; +use Carp; +use JSON::XS; + +sub Dump { + local $Data::Dumper::Indent = 1; + local $Data::Dumper::Sortkeys = 1; + return Data::Dumper::Dumper(@_); +} + +sub deepeq { + return JSON::XS->new->utf8->canonical->encode([$_[0]]) eq JSON::XS->new->utf8->canonical->encode([$_[1]]); +} + +# Hello object orientation, friend of all "need state support in +# a multi-thread safe way" code. + +# Functions +sub new { + my $class = shift; + my %Opts = @_; + + die "NEED s1" unless $Opts{IMAPs1}; + die "NEED s2" unless $Opts{IMAPs2}; + die "NEED CyrusName" unless $Opts{CyrusName}; + + # sensible defaults + $Opts{NumRepeats} = 3 if not exists $Opts{NumRepeats}; + $Opts{SleepTime} = 2 if not exists $Opts{SleepTime}; + $Opts{Messages} = []; + + my $Self = bless \%Opts, ref($class) || $class; + + if ($Opts{TraceImap}) { + $Self->{IMAPs1}->set_tracing(sub { $Self->do_output("IMAP_MASTER: " . shift) }); + $Self->{IMAPs2}->set_tracing(sub { $Self->do_output("IMAP_REPLICA: " . shift) }); + } + + $Self->{IMAPs1}->{PreserveINBOX} = 1; + $Self->{IMAPs2}->{PreserveINBOX} = 1; + $Self->{IMAPs1}->uid(1); + $Self->{IMAPs2}->uid(1); + + return $Self; +} + +sub CheckUserReplication { + my ($Self, $Level) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + # Folder list from both servers + my $IMAPs1List = $IMAPs1->list("INBOX.*", '*') + || die "Could not list all folders: $@"; + my $IMAPs2List = $IMAPs2->list("INBOX.*", '*') + || die "Could not list all folders: $@"; + + $IMAPs1List = [] if !ref $IMAPs1List; + $IMAPs2List = [] if !ref $IMAPs2List; + + my @s1List = ("INBOX", map { $_->[2] } @$IMAPs1List); + my @s2List = ("INBOX", map { $_->[2] } @$IMAPs2List); + + my %s1Hash = map { $_ => 1 } @s1List; + my %s2Hash = map { $_ => 1 } @s2List; + + my @Missings1 = grep { !$s1Hash{$_} } @s2List; + my @Missings2 = grep { !$s2Hash{$_} } @s1List; + + if (@Missings1 || @Missings2) { + $Self->do_repeat($Repeat, $CyrusName, "Folders mismatch", join(', ', @Missings1), join(', ', @Missings2)) + || goto RepeatCheck; + } + + # Check subscriptions + $Self->CheckUserSubs(); + + # Check quota + $Self->CheckUserQuota(); + + # Compare each folder + foreach my $Folder (@s1List) { + $Self->debug("$CyrusName checking folder $Folder"); + my $MsgsExist = $Self->CheckFolderBasic($Folder); + next if $Level == 0 || !$MsgsExist; + + $IMAPs1->examine($Folder); + $IMAPs2->examine($Folder); + $Self->CheckFolderFlags($Folder); + if ($Self->{CheckMetadata} && + $IMAPs1->capability()->{'metadata'} && + $IMAPs2->capability()->{'metadata'}) { + $Self->debug("$CyrusName checking metadata $Folder"); + $Self->CheckFolderMetadata($Folder); + } + # yes, they really did + if ($Self->{CheckAnnotations} && + $IMAPs1->capability()->{'annotate-experiment-1'} && + $IMAPs2->capability()->{'annotate-experiment-1'}) { + $Self->debug("$CyrusName checking annotations $Folder"); + $Self->CheckFolderAnnots($Folder); + } + if ($Self->{CheckConversations} && + $IMAPs1->capability()->{'xconversations'} && + $IMAPs2->capability()->{'xconversations'}) { + $Self->debug("$CyrusName checking conversations $Folder"); + $Self->CheckFolderConversations($Folder); + } + $Self->CheckFolderModseq($Folder); + next if $Level == 1; + + $Self->CheckFolderSizes($Folder); + next if $Level == 2; + + $Self->CheckFolderEnvelopes($Folder); + next if $Level == 3; + + # Force recheck of all sha1's on disk. Need level = 99 + next if $Level < 99; + $Self->debug("$CyrusName full sha1 check for folder $Folder"); + $Self->CheckFullSHA1($Folder); + } + + return $Self->{has_error}; +} + +sub CheckUserQuota { + my ($Self) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + $Self->debug("$CyrusName checking quota"); + + my $Repeat = 0; + RepeatCheck: + + my $s1Quota = $IMAPs1->getquotaroot('INBOX'); + my $s1QuotaRoot = $s1Quota->{quotaroot}->[1] || ''; + my (undef, $s1MBUsed, $s1MBTotal) = @{$s1Quota->{$s1QuotaRoot} || []}; + $s1MBUsed ||= 0; + $s1MBTotal ||= 0; + + my $s2Quota = $IMAPs2->getquotaroot('INBOX'); + my $s2QuotaRoot = $s2Quota->{quotaroot}->[1] || ''; + my (undef, $s2MBUsed, $s2MBTotal) = @{$s2Quota->{$s2QuotaRoot} || []}; + $s2MBUsed ||= 0; + $s2MBTotal ||= 0; + + if ($s1MBUsed != $s2MBUsed || $s1MBTotal != $s2MBTotal) { + $Self->do_repeat($Repeat, $CyrusName, "Quota mismatch: $s1MBUsed/$s1MBTotal vs $s2MBUsed/$s2MBTotal") + || goto RepeatCheck; + } +} + +sub CheckUserSubs { + my ($Self) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + $Self->debug("$CyrusName checking subscriptions"); + + my $Repeat = 0; + RepeatCheck: + + my $s1Subs = $IMAPs1->lsub('*', '*'); + if (!$s1Subs) { + $Self->error("$CyrusName Couldn't subs on master: $@"); + return; + } + $s1Subs = [] unless ref($s1Subs) eq 'ARRAY'; + @$s1Subs = map { $_->[2] } @$s1Subs; + @$s1Subs = grep { !/^user\./ } @$s1Subs if $Self->{IgnoreSharedSubs}; + my %s1data = map { $_ => 1 } @$s1Subs; + + my $s2Subs = $IMAPs2->lsub('*', '*'); + if (!$s2Subs) { + $Self->error("$CyrusName Couldn't subs on replica: $@"); + return; + } + $s2Subs = [] unless ref($s2Subs) eq 'ARRAY'; + @$s2Subs = map { $_->[2] } @$s2Subs; + @$s2Subs = grep { !/^user\./ } @$s2Subs if $Self->{IgnoreSharedSubs}; + my %s2data = map { $_ => 1 } @$s2Subs; + + my %ids = (%s1data, %s2data); + + foreach my $id (keys %ids) { + if (!$s1data{$id} || !$s2data{$id}) { + my $On = !$s1data{$id} ? "master" : "replica"; + $Self->do_repeat($Repeat, $CyrusName, "Missing subscription to $id on $On") + || goto RepeatCheck; + } + } +} + +sub CheckFolderBasic { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1Status = $IMAPs1->status($Folder, '(messages uidnext unseen recent uidvalidity highestmodseq)'); + if (!$s1Status) { + $Self->error("$CyrusName Couldn't get status of '$Folder' on master: $@"); + return; + } + my $s2Status = $IMAPs2->status($Folder, '(messages uidnext unseen recent uidvalidity highestmodseq)'); + if (!$s2Status) { + $Self->error("$CyrusName Couldn't get status of '$Folder' on replica: $@"); + return; + } + + for (qw(messages uidnext unseen recent uidvalidity highestmodseq)) { + unless (defined $s1Status->{$_} and defined $s2Status->{$_}) { + $Self->error("$CyrusName status on $Folder undefined for $_"); + next; + } + if ($s1Status->{$_} != $s2Status->{$_}) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched $Folder/$_", "master=$s1Status->{$_}, replica=$s2Status->{$_}") + || goto RepeatCheck; + } + } + + return ($s1Status->{messages} || $s2Status->{messages}); +} + +sub CheckFolderConversations { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1CIDs = $Self->do_fetch($IMAPs1, $CyrusName, 'cid') || return; + my $s2CIDs = $Self->do_fetch($IMAPs2, $CyrusName, 'cid') || return; + + my %ids = (%$s1CIDs, %$s2CIDs); + + for (sort {$a <=> $b } keys %ids) { + my $s1c = eval { join(' ', sort map { lc $_ } @{$s1CIDs->{$_}{cid}}) } || ''; + my $s2c = eval { join(' ', sort map { lc $_ } @{$s2CIDs->{$_}{cid}}) } || ''; + if ($s1c ne $s2c) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched cid for $Folder/$_", "master=$s1c, replica=$s2c") + || goto RepeatCheck; + } + } + + my $s1Stat = $IMAPs1->status($Folder, "(xconvmodseq xconvexists xconvunseen)"); + my $s2Stat = $IMAPs2->status($Folder, "(xconvmodseq xconvexists xconvunseen)"); + + foreach my $key (qw(xconvmodseq xconvexists xconvunseen)) { + if ($s1Stat->{$key} != $s2Stat->{$key}) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched $key for $Folder", "master=$s1Stat->{$key}, replica=$s2Stat->{$key}") + || goto RepeatCheck; + } + } +} + +sub CheckFolderFlags { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1Flags = $Self->do_fetch($IMAPs1, $CyrusName, 'flags') || return; + my $s2Flags = $Self->do_fetch($IMAPs2, $CyrusName, 'flags') || return; + + my %ids = (%$s1Flags, %$s2Flags); + + my %SkipFlags = (); #map { $_ => 1 } qw(\Recent \Seen); + + for (sort {$a <=> $b } keys %ids) { + my $s1f = eval { join(' ', sort map { lc $_ } grep { !$SkipFlags{$_} } @{$s1Flags->{$_}{flags}}) } || ''; + my $s2f = eval { join(' ', sort map { lc $_ } grep { !$SkipFlags{$_} } @{$s2Flags->{$_}{flags}}) } || ''; + if ($s1f ne $s2f) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched flags for $Folder/$_", "master=$s1f, replica=$s2f") + || goto RepeatCheck; + } + } +} + +sub CheckFolderAnnots { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1Annot = $Self->do_fetch($IMAPs1, $CyrusName, '(annotation (* value))') || return; + my $s2Annot = $Self->do_fetch($IMAPs2, $CyrusName, '(annotation (* value))') || return; + + my %ids = (%$s1Annot, %$s2Annot); + + for (sort {$a <=> $b } keys %ids) { + unless (deepeq($s1Annot->{$_}, $s2Annot->{$_})) { + my $s1v = Dump($s1Annot->{$_}); + my $s2v = Dump($s2Annot->{$_}); + $Self->do_repeat($Repeat, $CyrusName, "mistmatched annots for $Folder/$_", "master=$s1v, replica=$s2v") + || goto RepeatCheck; + } + } +} + +sub CheckFolderMetadata { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1data = $IMAPs1->getmetadata($Folder, {DEPTH => 'infinity'}, '/private', '/shared'); + my $s2data = $IMAPs2->getmetadata($Folder, {DEPTH => 'infinity'}, '/private', '/shared'); + + delete $s1data->{$Folder}{'/shared/vendor/cmu/cyrus-imapd/lastupdate'}; + delete $s2data->{$Folder}{'/shared/vendor/cmu/cyrus-imapd/lastupdate'}; + + unless (deepeq($s1data, $s2data)) { + # this field is not replicated and not consistent... + my $s1v = Dump($s1data); + my $s2v = Dump($s2data); + $Self->do_repeat($Repeat, $CyrusName, "mistmatched metadata for $Folder", "master=$s1v, replica=$s2v") + || goto RepeatCheck; + } +} + +sub CheckFolderEnvelopes { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1Res = $Self->do_fetch($IMAPs1, $CyrusName, 'envelope') || return; + my $s2Res = $Self->do_fetch($IMAPs2, $CyrusName, 'envelope') || return; + + my %ids = (%$s1Res, %$s2Res); + + for (sort { $a <=> $b } keys %ids) { + my $s1h = eval { $s1Res->{$_}{'envelope'} } || {}; + my $s2h = eval { $s2Res->{$_}{'envelope'} } || {}; + my $s1e = join(' ', map { "($_: " . ($s1h->{$_}||'') . ")" } sort keys %$s1h); + my $s2e = join(' ', map { "($_: " . ($s2h->{$_}||'') . ")" } sort keys %$s2h); + if ($s1e and not $s2e) { + $Self->error("$CyrusName for '$Folder', '$_', exists only on replica"); + } + elsif ($s2e and not $s1e) { + $Self->do_repeat($Repeat, $CyrusName, "only exists on master $Folder/$_", "master=$s1e") + || goto RepeatCheck; + } + elsif ($s1e ne $s2e) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched envelopes for $Folder/$_", "master=$s1e, replica=$s2e") + || goto RepeatCheck; + } + } +} + +sub CheckFolderSizes { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1Res = $Self->do_fetch($IMAPs1, $CyrusName, ['rfc822.size', 'digest.sha1']) || return; + my $s2Res = $Self->do_fetch($IMAPs2, $CyrusName, ['rfc822.size', 'digest.sha1']) || return; + + my %ids = (%$s1Res, %$s2Res); + + for (sort { $a <=> $b } keys %ids) { + my $s1f = eval { $s1Res->{$_}{'rfc822.size'} } || ''; + my $s2f = eval { $s2Res->{$_}{'rfc822.size'} } || ''; + my $s1g = eval { $s1Res->{$_}{'digest.sha1'} } || ''; + my $s2g = eval { $s2Res->{$_}{'digest.sha1'} } || ''; + if ($s1f and not $s2f) { + $Self->error("$CyrusName for '$Folder', '$_', exists only on replica"); + } + elsif ($s2f and not $s1f) { + $Self->do_repeat($Repeat, $CyrusName, "only exists on master $Folder/$_", "master=$s1f, $s1g") + || goto RepeatCheck; + } + elsif ($s1f ne $s2f) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched sizes for $Folder/$_", "master=$s1f, replica=$s2f") + || goto RepeatCheck; + } + elsif ($s1g ne $s2g) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched guids for $Folder/$_", "master=$s1g, replica=$s2g") + || goto RepeatCheck; + } + # every 1000th message + elsif ($s1f and $s1f < 70000 and rand(1000) >= 999) { # 70k seems a resonable limit + $Self->debug("Doing sha1 check on $CyrusName/$Folder/$_"); + my $s1message = $IMAPs1->fetch($_, 'rfc822.sha1'); + my $s2message = $IMAPs2->fetch($_, 'rfc822.sha1'); + next unless ($s1message->{$_}{'rfc822.sha1'} and $s2message->{$_}{'rfc822.sha1'}); # deleted? + unless ($s1message->{$_}{'rfc822.sha1'} eq $s2message->{$_}{'rfc822.sha1'}) { + $Self->error("$CyrusName for '$Folder', '$_', messages do not match"); + } + } + } +} + +sub CheckFolderModseq { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1ms = $Self->do_fetch($IMAPs1, $CyrusName, 'modseq') || return; + my $s2ms = $Self->do_fetch($IMAPs2, $CyrusName, 'modseq') || return; + + my %ids = (%$s1ms, %$s2ms); + + my %SkipFlags = (); #map { $_ => 1 } qw(\Recent \Seen); + + for (sort {$a <=> $b } keys %ids) { + my $s1m = $s1ms->{$_}{modseq}[0] || 0; + my $s2m = $s2ms->{$_}{modseq}[0] || 0; + if ($s1m ne $s2m) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched modseq for $Folder/$_", "master=$s1m, replica=$s2m") + || goto RepeatCheck; + } + } +} + +sub CheckFullSHA1 { + my ($Self, $Folder) = @_; + my $IMAPs1 = $Self->{IMAPs1}; + my $IMAPs2 = $Self->{IMAPs2}; + my $CyrusName = $Self->{CyrusName}; + + my $Repeat = 0; + RepeatCheck: + + my $s1Res = $Self->do_fetch($IMAPs1, $CyrusName, 'rfc822.sha1') || return; + my $s2Res = $Self->do_fetch($IMAPs2, $CyrusName, 'rfc822.sha1') || return; + + my %ids = (%$s1Res, %$s2Res); + + for (sort { $a <=> $b } keys %ids) { + my $s1s = eval { $s1Res->{$_}{'rfc822.sha1'} } || ''; + my $s2s = eval { $s2Res->{$_}{'rfc822.sha1'} } || ''; + if ($s1s and not $s2s) { + $Self->error("$CyrusName for '$Folder', sha1 of '$_', exists only on replica"); + } + elsif ($s2s and not $s1s) { + $Self->do_repeat($Repeat, $CyrusName, "only sha1 exists on master $Folder/$_", "master=$s1s") + || goto RepeatCheck; + } + elsif ($s1s ne $s2s) { + $Self->do_repeat($Repeat, $CyrusName, "mistmatched sha1 for $Folder/$_", "master=$s1s, replica=$s2s") + || goto RepeatCheck; + } + } +} + +sub do_fetch { + my $Self = shift; + my ($IMAP, $CyrusName, @Items) = @_; + + # $IMAP->fetch(...) currently returns undef if no messages because: + # . fetch 1:* flags + # . NO No matching messages (0.000 sec) + # This sub returns {} for a fetch on an empty folder + + my $Uids = $IMAP->search('1:*'); + if (!$Uids) { + $Self->error("$CyrusName Couldn't search '$IMAP->{CurrentFolder}' on $IMAP->{SType}: $@"); + return undef; + } + + my $Res = $Items[0] eq 'flags' ? $IMAP->fetch_flags('1:*') : $IMAP->fetch('1:*', @Items); + $Res = {} if !$Res && ref($Uids) && !@$Uids; + if (!$Res) { + $Self->error("$CyrusName Couldn't fetch $Items[0] in '$IMAP->{CurrentFolder}' on $IMAP->{SType}: $@"); + return undef; + } + + return $Res; +} + +sub do_repeat { + my $Self = shift; + $_[0]++; + my ($Repeat, $UserName, $Msg, @Data) = @_; + if ($Repeat <= $Self->{NumRepeats}) { + $Self->debug("$UserName, $Msg @Data, try $Repeat"); + sleep($Self->{SleepTime}) if $Self->{SleepTime}; + return 0; + } + + my $Error = join ", ", map { ref($_) ? Dump($_) : $_ } @Data; + $Self->error("$UserName, $Msg: $Error"); + + # Reset repeat count + return 1; +} + +sub get_type { + my $Msg = shift; + return 'QUOTA' if $Msg =~ m/Quota mismatch/; + return 'CONV' if $Msg =~ m/xconv/i; + return 'RECONSTRUCT'; +} + +# Logging + +sub notice { + my $Self = shift; + my $Message = shift; + unless ($Self->{Quiet}) { + $Self->do_output("NOTICE: $Message"); + } +} + +sub debug { + my $Self = shift; + my $Message = shift; + if ($Self->{Debug}) { + $Self->do_output("DEBUG: $Message"); + } +} + +sub error { + my $Self = shift; + my $Message = shift; + $Self->{HasError} = 1; + $Self->do_output("ERROR: $Message"); +} + +sub do_output { + my $Self = shift; + my $Message = shift; + chomp($Message); + unless ($Self->{Silent}) { + if ($Self->{LogFile}) { + $Self->{LogFile}->print("$Message\n"); + } + else { + print "$Message\n"; + } + } + push @{$Self->{Messages}}, $Message; +} + +sub GetMessages { + my $Self = shift; + $Self->{Messages} ||= []; + return wantarray ? @{$Self->{Messages}} : $Self->{Messages}; +} + +sub HasError { + my $Self = shift; + return $Self->{HasError}; +} + +sub _fname { + my $CyrusName = shift; + my $Folder = shift; + + my $Domain; + if ($CyrusName =~ s{\@(.*)}{}) { + $Domain = $1; + } + + $Folder =~ s{^INBOX}{user.$CyrusName}; + $Folder .= '@' . $Domain if $Domain; + + return $Folder; +} + +1; diff --git a/cassandane/Makefile b/cassandane/Makefile new file mode 100644 index 0000000000..6310b6fadf --- /dev/null +++ b/cassandane/Makefile @@ -0,0 +1,80 @@ +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +SUBDIRS := utils +PERL := perl + +all clean install:: + @for dir in $(SUBDIRS) ; do cd $$dir ; $(MAKE) $@ || exit 1 ; done + +all:: syntax + +# utils/annotator.pl depends on modules installed with Cyrus, which it +# will only be able to find when invoked by Cyrus::Instance (which sets +# up $PERL5LIB appropriately) or when the system coincidentally also has +# a real Cyrus installation on it. So we can't rely on it to pass a +# simple 'perl -c' check unless Cyrus is available. +SCRIPTS := $(shell find . -type f -name '*.pl' \ + | grep -v 'utils\/annotator.pl' | sort) + +MODULES := $(shell find . -type f -name '*.pm' | sort) + +# define NOCYRUS to skip utils/annotator.pl, allowing the rest of the +# checks to proceed when no Cyrus install exists +ifndef NOCYRUS + SCRIPTS += $(shell find . -type f -name 'annotator.pl') + CYRUS_PERL_PATHS := $(shell $(PERL) utils/cyrus-perl-paths.pl) +endif + +SYNTAX_rules = + +define SYNTAX_template + $(1)_syntax: $(1) + @$(PERL) $(CYRUS_PERL_PATHS) -c $(1) + SYNTAX_rules += $(1)_syntax +endef + +$(foreach s,$(SCRIPTS),$(eval $(call SYNTAX_template,$(s)))) + +$(foreach m,$(MODULES),$(eval $(call SYNTAX_template,$(m)))) + +syntax: $(SYNTAX_rules) + +.PHONY: all syntax $(SYNTAX_rules) diff --git a/cassandane/Net/README b/cassandane/Net/README new file mode 100644 index 0000000000..fc085e713e --- /dev/null +++ b/cassandane/Net/README @@ -0,0 +1,8 @@ +Basic perl SMTP/LMTP client/server components + +COPYRIGHT AND LICENCE + +Copyright (C) 2003-2017 by FastMail Pty Ltd + +This library is free software; you can redistribute it and/or modify +it under the same terms as Perl itself. diff --git a/cassandane/Net/XmtpServer.pm b/cassandane/Net/XmtpServer.pm new file mode 100644 index 0000000000..0cf827e950 --- /dev/null +++ b/cassandane/Net/XmtpServer.pm @@ -0,0 +1,712 @@ +package Net::XmtpServer; + +=head1 NAME + +Net::XmtpServer - Implement SMTP/LMTP server skeleton + +=head1 SYNOPSIS + + package MyServer; + + use Net::XmtpServer; + use Net::Server::PreForkSimple; + use base qw(Net::XmtpServer Net::Server::PreForkSimple); + + MyServer->run( + max_servers => 5, + ... + ); + + # Callbacks for each server event + sub helo { } + sub rcpt { } + + +=head1 DESCRIPTION + +This module implements a SMTP/LMTP server skeleton. Basically +you derive from it, as well as a Net::Server::* personality, +and it handles all process_request() calls, interpreting the +SMTP/LTMP stream, and making callbacks as appropriate for +your code to deal with + +=head1 ADDITIONAL OPTIONS + +When executing the run() method of the Net::Server object, +you can pass extra options + +=over 4 + +=item xmtp_personality + +Either 'lmtp', 'smtp' or 'both'. Determines whether helo/ehlo or lhlo +works + +=item store_msg + +If true, all messages in the DATA part of a transaction +are stored into a file which can be used when the DATA +section is complete + +=item xmtp_tmp_dir + +Directory for holding temporary message spool files. +Default to /tmp + +=item handle_mime + +If true, tries to handle MIME structure of message as +passed and does extra callbacks + +=item xmtp_timeout + +Timeout value for handling entire transactions +Default 120 seconds + +=item cmd_timeout + +Timeout value for each command (not including data) +Default 30 seconds + +=item data_timeout + +Timeout value for DATA command +Default 60 seconds + +=item max_messages + +Maximum number of messages to process before existing a child + +=back + +=cut + +# Use modules/constants {{{ +use IO::File; +use File::Temp qw(tempfile); + +# Avoid UTF-8 regexp issues. Treat everything as pure +# binary data +no utf8; +use bytes; + +# Standard use items +use Data::Dumper; +use strict; +use warnings; +# }}} + +=head1 METHODS + +=over 4 +=cut + +=item I + +Pass to $Self->log($Level, "%s", $Msg) + +=cut +sub xmtplog { + # $_[0]->log($_[1], '%s', $_[2]); + $_[0]->log($_[1] * 2, $_[2]); +} + +=item I + +Catch configure options + +=cut +sub post_configure_hook { + my ($Self, $Xmtp, $Srv) = ($_[0], $_[0]->{xmtp} ||= {}, $_[0]->{server}); + + # In old versions of Net::Server, parameters passed to "run" could + # be accessed with $Self->{server}->{configure_args}. + # In new versions they are available directly in $Self->{server} + my $Config = $Srv->{configure_args}; + my %Options = $Config ? @{$Config} : %{$Srv}; + + # Get config options + @$Xmtp{qw(StoreMsg HandleMime Personality)} + = @Options{qw(store_msg handle_mime xmtp_personality)}; + + # Set timeout for each transaction + $Xmtp->{XmtpTimeout} = $Options{xmtp_timeout} || 300; + $Xmtp->{CmdTimeout} = $Options{cmd_timeout} || 30; + $Xmtp->{DataTimeout} = $Options{data_timeout} || 60; + + $Xmtp->{MaxMessages} = $Options{max_messages} || 0; + + $Xmtp->{TmpDir} = $Options{xmtp_tmp_dir} || '/tmp'; + + # Set personality regexp match + my $Personality = $Xmtp->{Personality}; + my $PersonalityRE = qr/helo|ehlo/i; + if ($Personality) { + $PersonalityRE = qr/lhlo/i if $Personality eq 'lmtp'; + $PersonalityRE = qr/helo|ehlo|lhlo/i if $Personality eq 'both'; + } + $Xmtp->{PersonalityRE} = $PersonalityRE; + +} + +=item I + +Called after ownership chage. Create dir to hold spool files. + +=cut +sub pre_loop_hook { + my ($Self, $Xmtp, $Srv) = ($_[0], $_[0]->{xmtp}, $_[0]->{server}); + + # Get temporary dir, and clean + if ($Xmtp->{StoreMsg}) { + my $TmpDir = $Xmtp->{TmpDir}; + -d $TmpDir || mkdir $TmpDir; + unlink glob("$TmpDir/*.xmtp"); + } + +} + +=item I + +Called when a new child is forked. Create temp spool file + +=cut +sub child_init_hook { + my ($Self, $Xmtp, $Srv) = ($_[0], $_[0]->{xmtp}, $_[0]->{server}); + + # Return if already inited. Possible because post_accept_hook() + # calls us. This is so non-forking debug versions work as well + return 1 if $Xmtp->{ChildInit}; + + srand($$ + time()); + + # Don't inherit parent child signal handling + # Otherwise system("...") or `cmd` calls fail... + $SIG{CHLD} = 'IGNORE'; + $SIG{PIPE} = 'IGNORE'; + + # Create temporary spool file for this child + if ($Xmtp->{StoreMsg}) { + my $TmpDir = $Xmtp->{TmpDir}; + my ($Fh, $Filename) = tempfile(DIR => $TmpDir, UNLINK => 1, SUFFIX => '.xmtp'); + bless $Fh, "IO::File"; + + # Same in server properties + @$Xmtp{qw(Fh Filename)} = ($Fh, $Filename); + } + + $Xmtp->{ChildInit} = 1; + return 0; +} + +=item I + +Check for init requirements. + +If running in debug non-forking mode, then child_init_hook() +won't be called, so we try calling it now + +=cut +sub post_accept_hook { + $_[0]->child_init_hook(); +} + +=item I + +Process a new accepted connection from a client + +=cut +sub process_request { + my ($Self, $Xmtp, $Srv) = ($_[0], $_[0]->{xmtp}, $_[0]->{server}); + + eval { + + $Self->start_request(); + + $Self->ClearAlarm(); + + # Reset any existing state + $Self->reset_state(); + + # Notify of new client connection + $Self->new_connection(); + $Self->xmtplog(2, "New connection"); + + # Setup timeout handler (after new_connection, which might + # change $SIG{ALRM} itself) + $SIG{ALRM} = sub { + my ($Package, $Filename, $Line, $Sub) = caller(0); + my $LastCmd = $Xmtp->{LastCmd} || ''; + die "Timeout: State=$LastCmd; In=${Sub}; Line=$Line"; + }; + + # Do all the connection work + $Self->HandleConnection(); + + alarm(0); + }; + + if (my $Err = $@) { + if ($Err =~ /^Timeout/) { + $Self->timeout($Err); + } else { + $Self->error($Err); + $Self->xmtplog(1, "Processing error: $Err"); + } + } + + # Stop timeout alarm + alarm(0); + + $Self->close_connection(); + + $Self->end_request(); +} + +sub HandleConnection { + my $Self = shift; + + # Simple three states + # 0 = done/quit + # 1 = command mode + # 2 = data mode + my $Mode = 1; + while ($Mode) { + if ($Mode == 1) { + $Mode = $Self->HandleModeCommand(); + } elsif ($Mode == 2) { + $Mode = $Self->HandleModeData(); + } else { + die "Unknown mode: $Mode"; + } + } +} + +sub HandleModeCommand { + my ($Self, $Xmtp, $Srv) = ($_[0], $_[0]->{xmtp}, $_[0]->{server}); + + # Schedule command timeout + $Self->ScheduleAlarm($Xmtp->{CmdTimeout}); + + # Loop over commands and dispatch each + while (defined($_ = )) { + # Remove EOL chars for processing below + s/[\r\n]*$//; + + $Xmtp->{LastCmd} = $_; + $Self->xmtplog(2, "Received command: $_"); + + if (my ($To, $ToExtra) = /^RCPT\s+TO:\s*(.*?)\s*$/i) { + ($To, $ToExtra) = ($To =~ /^@]*\@?[^>\s]*)>?\s*(.*?)$/); + $Self->rcpt_to($To || '', split /\s+/, ($ToExtra || '')); + + } elsif (my ($From, $FromExtra) = /^MAIL\s+FROM:\s*(.*?)\s*$/i) { + $From =~ s/^<([^>]*)>\s*|^([^<]\S*)\s*//; + ($From, $FromExtra) = (defined($1) ? $1 : $2, $From); + $Self->mail_from($From || '', split /\s+/, ($FromExtra || '')); + + } elsif (my ($Helo) = /^$Xmtp->{PersonalityRE}\s+(.*?)\s*$/i) { + $Self->helo($Helo); + + } elsif (my ($RsetExtra) = /^RSET\s*(.*?)\s*$/i) { + # If rset returns true, means we're done with this connection. + # Switch to mode 0, which exits connection + my $Done = $Self->rset(split /\s+/, ($RsetExtra || '')); + $Self->reset_state(); + return 0 if $Done; + + } elsif (/^DATA\s*$/i) { + # Note start of data section + # Returns false if failure... + next if !$Self->begin_data(); + + # Switch to data mode + return 2; + + } elsif (/^QUIT\s*$/i) { + $Self->quit(); + return 0; + + } elsif (/^NOOP\s*$/i) { + $Self->noop(); + + } else { + $Self->unknown($_); + } + + # Reschedule alarm if still in cmd mode + $Self->ScheduleAlarm($Xmtp->{CmdTimeout}); + } + + # EOF on input, done/exit mode + return 0; +} + +sub HandleModeData { + my ($Self, $Xmtp, $Srv) = ($_[0], $_[0]->{xmtp}, $_[0]->{server}); + + # Schedule correct alarm + $Self->ScheduleAlarm($Xmtp->{DataTimeout}); + + # MIME body buffering details + my ($HeadBuffer, $DoBodyBuffer, $BodyBuffer) = ('', 0, ''); + + $Self->begin_headers(); + + # MIME message boundary regexps + my ($InHeader, $MessageHdrs, @Boundaries, $UUEnc, $BinHex) = (1, 1); + + # Processing options + my ($Fh, $HandleMime) = @$Xmtp{qw(Fh HandleMime)}; + + # Main processing loop + while (defined($_ = )) { + # Remove all null chars + tr/\000//d; + # Normalise to \n line endings + s/\r+\n$/\n/; + + # Lone . is always EOD + if ($_ eq ".\n") { + + if ($Xmtp->{HandleMime}) { + if ($InHeader) { + $Self->ProcessHeaders(\$HeadBuffer, \@Boundaries, $MessageHdrs); + $Self->end_headers(\$HeadBuffer); + $Self->output_headers($Fh, $HeadBuffer) if $Fh; + } else { + $Self->end_body(\$BodyBuffer); + $Self->output_body($Fh, $BodyBuffer) if $Fh && $DoBodyBuffer; + } + } + + return $Self->HandleEndOfData() ? 0 : 1; + + # Otherwise handle header/mime/data line + } else { + + # Un-dot-stuff + s/^\.//; + + # If not handling MIME, just add straight to spool file + if (!$HandleMime) { + $Self->output_body($Fh, $_) if $Fh; + + # Handle MIME phases ... {{{ + } else { + + if ($InHeader) { + # Strip bare \r's from headers + s/\r//g; + + $HeadBuffer .= $_; + + # End of headers + if ($_ eq "\n") { + $MessageHdrs = $Self->ProcessHeaders(\$HeadBuffer, \@Boundaries, $MessageHdrs); + $Self->end_headers(\$HeadBuffer); + + $Self->output_headers($Fh, $HeadBuffer) if $Fh; + $HeadBuffer = ''; + + # If message/rfc822 attachment, then we're immediately into headers again + if (!$MessageHdrs) { + $InHeader = 0; + $DoBodyBuffer = $Self->begin_body(); + } + } + + # In 'body' type section + } else { + + # Found boundary string? + if (@Boundaries && /$Boundaries[-1]->[1]/) { + $Self->end_body(\$BodyBuffer); + $Self->output_body($Fh, $BodyBuffer) if $Fh && $DoBodyBuffer; + $BodyBuffer = ''; + $DoBodyBuffer = 0; + + # Use previous boundary match + pop @Boundaries if /--\s*$/; + + if (@Boundaries) { + $InHeader = 1; + $Self->begin_headers(); + } + } + + # Always send body to spool file/buffer + if ($DoBodyBuffer) { + $BodyBuffer .= $_; + } else { + $Self->output_body($Fh, $_) if $Fh; + } + + # UUENCODE begin type section + if (/^begin(?:-base64)? \d{1,4}/) { + $Self->uuenc_begin($_); + $UUEnc = 1; + } elsif ($UUEnc && /^(?:end|====)/) { + $Self->uuenc_end($_); + $UUEnc = 0; + } + + # BINHEX type section + if (/^\(This file must be converted with BinHex 4\.0\)/) { + $Self->binhex_begin($_); + $BinHex = 1; + } elsif ($BinHex && /:$/) { + $Self->binhex_end($_); + $BinHex = 0; + } + + } + } + + # }}} + + } + + # Main while loop + } + + # EOF on input, done/exit mode + return 0 +} + +sub ProcessHeaders { + my ($Self, $HeadBuffer, $Boundaries, $MsgHeaders) = @_; + + # Loop through and list all headers (minus \n) + my @Headers; + while ($$HeadBuffer =~ /\G([^\s:]+)(:[ \t]*(?:\n[ \t]+)*)([^\n]*(?:\n[ \t]+[^\n]*)*)\n/gc) { + push @Headers, [ $1, $2, $3 ] + } + my ($Remainder) = $$HeadBuffer =~ /\G(.*)$/s; + + # Build map (prefer earlier headers). Save refs + my %Headers = map { lc($_->[0]) => $_ } reverse @Headers; + @$Self{qw(HeaderList HeaderMap)} = (\@Headers, \%Headers); + + # Callback for each header (use counter because add_header() might be called) + for (my $i = 0; $i < @Headers; $i++) { + $Self->HandleHeader(@{$Headers[$i]}, $Boundaries, $MsgHeaders); + } + + # Callback with all headers + $Self->all_headers(\%Headers, \@Headers, $Boundaries, $MsgHeaders); + + # Don't need these refs any more + delete @$Self{qw(HeaderList HeaderMap)}; + + # Build headers again + $$HeadBuffer = join "", map { !defined $_->[2] ? "" : join("", @$_, "\n") } @Headers; + $$HeadBuffer .= $Remainder; + + # Extract new MIME boundary details in content-type headers + if (my $ContentType = $Headers{'content-type'}) { + $Self->HandleContentTypeHeader($Boundaries, $ContentType->[2]); + + # Return true if message/rfc822 attachment + if ($ContentType->[2] =~ m{^message/rfc822}i) { + # We're inside a message now + $Boundaries->[-1]->[2]++ if @$Boundaries; + return 1; + } + } + + return 0; +} + +sub HandleHeader { + my ($Self, $HeaderName, $HeaderSep, $HeaderValue, $Boundaries, $MsgHeaders) = @_; + + # Process existing header + if ($HeaderName) { + + # Callback to inspect (and possibly modify) header "name: value" pair + my $OldValue = $HeaderValue; + $Self->header($HeaderName, $HeaderValue, scalar(@$Boundaries), $MsgHeaders); + + # If old header was empty value, add space into separator if not present + $HeaderSep .= " " if !$OldValue && $HeaderValue && $HeaderSep eq ':'; + + # Save any changes back + ($_[1], $_[2], $_[3]) = ($HeaderName, $HeaderSep, $HeaderValue); + } + + return; +} + +sub HandleContentTypeHeader { + my ($Self, $Boundaries, $HeaderValue) = @_; + + # Put current mime type string into boundary details + my ($MimeType) = $HeaderValue =~ /^([^;\s]+)/; + $Boundaries->[-1]->[4] = $MimeType if @$Boundaries; + + # Get boundary string + my ($Boundary) = $HeaderValue =~ /boundary="([^"]+)"/i; + ($Boundary) = $HeaderValue =~ /boundary=([^\s;]+)/i if !$Boundary; + return if !$Boundary; + + my $BoundaryRE = qr/^--\Q$Boundary\E(?:--)?\s*$/; + + # Track how deep we are in attached messages + my $MessageDepth = @$Boundaries ? $Boundaries->[-1]->[2] : 0; + + # Create match regexp + push @$Boundaries, [ $Boundary, $BoundaryRE, $MessageDepth, $MimeType, '' ]; +} + +sub HandleEndOfData { + my ($Self, $Xmtp) = ($_[0], $_[0]->{xmtp}); + + $Self->ClearAlarm(); + + $Xmtp->{LastCmd} = "EOD ."; + + # Flush data to file, call end of data callback and reset state + $Xmtp->{Fh}->flush() if $Xmtp->{Fh}; + my $Done = $Self->end_data(); + $Self->reset_state(); + + return $Done; +} + +sub ClearAlarm { + my ($Self, $Xmtp) = ($_[0], $_[0]->{xmtp}); shift; + alarm(0); + $Xmtp->{TotalTime} = $Xmtp->{XmtpTimeout}; + $Xmtp->{PrevTimeout} = undef; +} + +sub ScheduleAlarm { + my ($Self, $Xmtp) = ($_[0], $_[0]->{xmtp}); shift; + my $Timeout = shift; + + # Total time left for transaction + my $TotalTime = $Xmtp->{TotalTime}; + + # Find if there was a previous alarm() set + my $PrevTimeout = $Xmtp->{PrevTimeout}; + + # Find remaining time on alarm + my $RemTime = alarm(0); + + # A previous timeout value supplied to alarm() + if ($PrevTimeout) { + my $Used = $PrevTimeout - $RemTime; + $TotalTime -= $Used; + $TotalTime = 1 if $TotalTime < 1; + + # No previous timeout value, but there is now + } else { + $Xmtp->{PrevTimeout} = $Timeout; + } + + $Xmtp->{TotalTime} = $TotalTime; + + # Set new alarm. Use less that timeout if + # global time left is < timeout specified + my $NewAlarm = $TotalTime < $Timeout ? $TotalTime : $Timeout; + alarm($NewAlarm); +} + +sub GetSpoolFile { + my ($Self, $Xmtp) = ($_[0], $_[0]->{xmtp}); + return @$Xmtp{qw(Fh Filename)}; +} + +sub add_header { + my ($Self, $Header, $Value) = @_; + + my $Data = [ $Header, ": ", $Value ]; + push @{$Self->{HeaderList}}, $Data; + $Self->{HeaderMap}->{lc $Header} = $Data; +} + +# Callback prototypes {{{ + +sub reset_state { + my ($Self, $Xmtp) = ($_[0], $_[0]->{xmtp}); + + # Reset spool file + if (my $Fh = $Xmtp->{Fh}) { + $Fh->seek(0, 0); + $Fh->truncate(0); + } + + $Xmtp->{LastCmd} = "EOD Done"; +} + +sub start_request { undef; } +sub end_request { undef; } + +sub new_connection { undef; } +sub helo { undef; } +sub noop { $_[0]->send_client_resp(250, "250 2.0.0 ok"); } +sub mail_from { undef; } +sub rcpt_to { undef; } +sub rset { undef; } +sub unknown { undef; } +sub quit { undef; } +sub close_connection { undef; } + +sub begin_data { undef; } +sub end_data { undef; } +sub header { undef; } +sub data_line { undef; } + +sub begin_headers { undef; } +sub end_headers { undef; } +sub all_headers { undef; } +sub begin_body { undef; } +sub end_body { undef; } + +sub uuenc_begin { undef; } +sub uuenc_end { undef; } +sub binhex_begin { undef; } +sub binhex_end { undef; } + +sub output_headers { print {$_[1]} $_[2]; } +sub output_body { print {$_[1]} $_[2]; } + +sub timeout { undef; } +sub error { undef; } +# }}} + +=item I + +Send back to the connected client the given code and message + +=cut +sub send_client_resp { + my ($Self, $Code, @MsgLines) = @_; + while (@MsgLines > 1) { + my $Msg = shift @MsgLines; + print STDOUT "$Code-$Msg\r\n"; + } + my $Msg = shift @MsgLines; + print STDOUT "$Code $Msg\r\n"; +} + +=back +=cut + +=head1 AUTHOR + +Rob Mueller Ecpan@robm.fastmail.fmE + +=cut + +=head1 COPYRIGHT AND LICENSE + +Copyright (C) 2003-2017 by FastMail Pty Ltd + +This library is free software; you can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +1; diff --git a/cassandane/README b/cassandane/README new file mode 100644 index 0000000000..c8e7b803ec --- /dev/null +++ b/cassandane/README @@ -0,0 +1,5 @@ +Cassandane is a Perl-based integration test suite for Cyrus. + +Why "Cassandane" ? Wikipedia indicates that Cassandane was the name of +the consort of King Cyrus the Great of Persia, founder of the Achaemenid +Persian Empire. So that's kinda cool. diff --git a/cassandane/README.imaptest b/cassandane/README.imaptest new file mode 100644 index 0000000000..9b0a695528 --- /dev/null +++ b/cassandane/README.imaptest @@ -0,0 +1,23 @@ +http://www.imapwiki.org/ImapTest + +Cassandane has support for the ImapTest engine. First you need to build +imaptest, which isn't too hard - download and build dovecot (it can just +sit in the build target, it doesn't need to be installed) and then +download and build imaptest. I used mercurial and downloaded the latest +of both. + +Then add this block to your cassandane.ini: + +--- + +[imaptest] +basedir=/home/brong/src/imaptest/imaptest +suppress=append-binary fetch-binary-mime fetch-binary-mime-base64 fetch-binary-mime-qp urlauth-binary + +--- + +I'm hoping to fix these! There are also some tests that don't run because +we don't advertise the capabilities, and I'd like to fix those as well. + +Finally, we should contribute tests back to ImapTest if they are generic +tests that we think would be valuable for other servers. diff --git a/cassandane/cassandane.ini.dockertests b/cassandane/cassandane.ini.dockertests new file mode 100644 index 0000000000..bdb42ba5a8 --- /dev/null +++ b/cassandane/cassandane.ini.dockertests @@ -0,0 +1,78 @@ +# +# Config file for running automated tests +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +[cassandane] +rootdir = /tmp/cass +cleanup = no +# We skip some tests that are either long-time known failures, or +# are brittle and fail sporadically, to keep the CI integration +# useful. +# +# This list last reviewed on 2020-08-07 +# +# The final three (Sieve imip and Caldav implicit allday) fail on our current +# Docker image, and we'll remove them from here when we upgrade the Docker +# image. +suppress = Rename.rename_inbox JMAPBackup Sieve.snooze_tzid MboxEvent.tls_login_event Sieve.imip_reply_override Sieve.imip_reply_override_google Caldav.invite_switch_implicit_allday_to_dtend + +[valgrind] + +[cyrus default] + +[gdb] + +[config] +zoneinfo_dir = /usr/local/cyruslibs/share/cyrus-timezones/zoneinfo + +[imaptest] +basedir = /srv/imaptest.git +# suppress list last reviewed 2023-05-03 +suppress = urlauth2 + + +[caldavtester] +# still buggy, skip for now +#basedir = /srv/caldavtester.git + +[jmaptestsuite] +basedir = /srv/JMAP-TestSuite.git +suppress = diff --git a/cassandane/cassandane.ini.example b/cassandane/cassandane.ini.example new file mode 100644 index 0000000000..6a11c76f39 --- /dev/null +++ b/cassandane/cassandane.ini.example @@ -0,0 +1,219 @@ +# +# Example cassandane.ini file +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +# You can set (or override) any of the settings in this file by setting +# an environment variable thus: CASSINI_SECTION_NAME=value +# If an environment variable so named is defined, its value will be used +# instead of the value from the cassandane.ini file +# +# e.g. +# [cassandane].rootdir -> CASSINI_CASSANDANE_ROOTDIR +# [cyrus default].prefix -> CASSINI_CYRUS_DEFAULT_PREFIX +# +# See Cassandane::Cassini::val() for specific details of how +# the environment variable name to look up is constructed + +# This section describes configurable properties of the +# Cassandane infrastructure +[cassandane] +# Directory under which all the instance directories will be created. +# It's wise to keep this short: UNIX domain sockets will be created +# under here, and most systems impose a limit of 100 or so characters +# on paths used for these. +# Tests will run quite a bit faster if this is on a tmpfs filesystem +# (though note that you will lose your old instance directories across +# reboots if you do this). +##rootdir = /var/tmp/cass +# Which SASL password checking method to use: +# alwaystrue +# is the default and it makes libsasl conveniently accept +# any old password, but it can be configured out at libsasl +# build time e.g. on older RedHat builds. +# sasldb +# can be used for those RedHat systems, as it's always available +# in libsasl. Cassandane will build a sasldb2 file containing +# usernames and passwords, but it requires installing the package +# containing the saslpasswd2 binary. +##pwcheck = alwaystrue +# Whether to clean up instance directories after their tests have +# run (also, will remove and old instance directories from earlier +# runs). See also the --cleanup option to testrunner.pl. +##cleanup = no +# How many worker processes to run. Overridden by -j argument to +# testrunner.pl. +##maxworkers = 1 +# Base port number to use. All Cyrus instances run by Cassandane +# will listen at ports starting from this number. +##base_port = 9100 +# A list of tests or suites which will be suppressed. These tests +# will still run if requested on the command line, but will not be +# run by default. +##suppress = +# Whether Cassandane should allow itself to start up without a +# cassandane.ini file. This doesn't make much sense to specify in +# a cassandane.ini file, but if you enable it via the environment +# variable CASSINI_CASSANDANE_ALLOW_NOINIFILE, it will permit you +# to run Cassandane using only configuration from environment +# variables (or defaults) +##allow_noinifile = no +# Perl regular expression to match core file names on your system. +# The default should match typical default setups, but if you have +# configured something unusual in sysctl kernel.core_pattern, you +# should configure this to a regex that will match it. The first +# capture group (if present) will be used to identify the pid the +# core file came from. +##core_pattern = ^core.*?(?:\.(\d+))?$ + +# This section describes configurable properties of Valgrind. +[valgrind] +# Whether to run Cyrus binaries under Valgrind (see also the +# --valgrind option to testrunner.pl) +##enabled = no +# Where to find the Valgrind binary +##binary = /usr/bin/valgrind +# File containing Valgrind suppression rules +##suppressions = vg.supp +# Other arguments passed to Valgrind +##arguments = -q --tool=memcheck --leak-check=full --run-libc-freeres=no + +# This section describes the default Cyrus installation. +[cyrus default] +# Prefix of the installation; should be the value which was supplied as +# --prefix to the Cyrus configure script. Cassandane will look for Cyrus +# binaries in {prefix}/bin, {prefix}/sbin, {prefix}/libexec, {prefix}/lib, +# and {prefix}/cyrus/bin. +##prefix = /usr/cyrus +# A non-standard or temporary place where the installation has been made, +# e.g. by make DESTDIR=/tmp/my-cyrus-inst install +# If set, binaries will be in {destdir}{prefix}/bin (etc) +##destdir = +# If your Cyrus build has renamed binaries, you can tell Cassandane how +# to find them like this: +##quota = cyr_quota +# Maximum core file size in megabytes. Set to 0 for unlimited (subject +# to system limitations) +##coresizelimit = 100 + +# This optional section describes the Cyrus installation used for the +# replica side of replication tests. You can use this to test +# replication to a different Cyrus version from your main instance. +# If this section does not exist, or the prefix it names does not +# exist, then the replica instance will use the "cyrus default". +##[cyrus replica] +##prefix = /usr/cyrus +##destdir = + +# This optional section describes the Cyrus installation used for the +# murder frontend in murder tests. You can use this to test a murder +# with a different Cyrus version from your main instance. +# If this section does not exist, or the prefix it names does not +# exist, then the murder tests will use the "cyrus default". +##[cyrus murder] +##prefix = /usr/cyrus +##destdir = + +# This section enables GDB debugging of services run from Cyrus master +# on a per-service basis. To debug, enable one of these, run +# Cassandane, and look in syslog for helpful instructions from gdbtramp. +[gdb] +##imapd = yes +##sync_server = yes +##lmtpd = yes +##timsieved = yes +##backupd = yes + +# This section describes the common configuration parameters to set +# for each test. This overrides the bare hardcoded configuration, and is +# overridden by the test case configuration. +# Some variables of the form @varname@ are available: +# name: instance name +# basedir: instance directory +# cyrus_prefix: cyrus path +# prefix: working directory +[config] +##sasl_mech_list = PLAIN LOGIN +##debug_command = @prefix@/utils/gdbtramp %s %d + +# This section describes how Cassandane interacts with the Net::CalDAVTalk +# module. If the basedir is set, we can test the built in API tests against +# cyrus's CalDAV support for event+json +[caldavtalk] +# The base directory of a clone of Net::CalDAVTalk from git (i.e. the +# directory to find the 'testdata' directory in). If empty, the built-in +# API tests won't be run +##basedir = + +# This section describes how Cassandane interacts with the ImapTest +# testsuite, which is a test suite for IMAP servers written by the +# Dovecot team and downloadable from http://www.imapwiki.org/ImapTest/ +[imaptest] +# The base directory of a built but not installed ImapTest. If empty, +# no ImapTests will be found or run. +##basedir = +# A list of tests which will be suppressed, i.e. not reported and not +# run. The default value is the list of ImapTest tests which are known +# to trigger unfixed bugs in Cyrus at the moment, hopefully that will +# shrink to nil in the future. +##suppress = listext subscribe + +# This section describes how Cassandane interacts with the CalDAVTester +# testsuite, which is a test suite for CalDAV and CardDAV servers written +# by Apple and downloadable from http://calendarserver.org/wiki/CalDAVTester +[caldavtester] +# The base directory of a svn checkout of CalDAVTester. If empty, no +# CalDAV or CardDAV tests will be found or run +## basedir = +# A list of tests which will be suppressed for each category. The default +# is a list of CalDAVTester tests which are known to trigger unfixed bugs +# in Cyrus or which are very Apple specific +## suppress-caldav = +## suppress-carddav = + +# This section describes how Cassandane interacts with the JMAP::TestSuite +# test suite, which is a test suite for JMAP services available from +# https://github.com/fastmail/JMAP-TestSuite +[jmaptestsuite] +# The base directory of a git checkout of JMAP::TestSuite. If empty, no +# JMAP tests will be found or run +## basedir = +# A list of tests which will be suppressed +## suppress = diff --git a/cassandane/data/FM_BIMI.svg b/cassandane/data/FM_BIMI.svg new file mode 100644 index 0000000000..c0313cf6fd --- /dev/null +++ b/cassandane/data/FM_BIMI.svg @@ -0,0 +1 @@ +FM-Icon-RGB diff --git a/cassandane/data/caldavtester-serverinfo-template.xml b/cassandane/data/caldavtester-serverinfo-template.xml new file mode 100644 index 0000000000..419faeb23a --- /dev/null +++ b/cassandane/data/caldavtester-serverinfo-template.xml @@ -0,0 +1,844 @@ + + + + + + + + SERVICE_HOST + SERVICE_PORT + basic + + 10 + 0.25 + + + + COPY Method + MOVE Method + Extended MKCOL + + + + + + + + + add-member + + brief + + ctag + current-user-principal + + + + + + prefer + prefer-minimal + prefer-representation + prefer-noroot + quota + + + sync-report + sync-report-limit + + well-known + + + + + json-data + + + caldav + + + + + + + + implicit-scheduling + + + + no-duplicate-uids + + + + + + + + + + shared-calendars + + + + + + + + + timezone-std-service + + vpoll + webcal + rscale + + + carddav + default-addressbook + + + + + + + + + $multistatus-response-prefix: + /{DAV:}multistatus/{DAV:}response + + + $multistatus-href-prefix: + /{DAV:}multistatus/{DAV:}response/{DAV:}href + + + $verify-response-prefix: + {DAV:}response/{DAV:}propstat/{DAV:}prop + + + $verify-property-prefix: + /{DAV:}multistatus/{DAV:}response/{DAV:}propstat/{DAV:}prop + + + $verify-bad-response: + /{DAV:}multistatus/{DAV:}response/{DAV:}status + + + $verify-error-response: + /{DAV:}multistatus/{DAV:}response/{DAV:}error + + + $CALDAV: + urn:ietf:params:xml:ns:caldav + + + $CARDDAV: + urn:ietf:params:xml:ns:carddav + + + $CS: + http://calendarserver.org/ns/ + + + + + + + + $root: + /dav/ + + + + + $principalcollection: + $root:principals/ + + + + + $uidstype: + user + + + $userstype: + user + + + $groupstype: + groups + + + $locationstype: + locations + + + $resourcestype: + resources + + + + + $principals_uids: + $principalcollection:$uidstype:/ + + + $principals_users: + $principalcollection:$userstype:/ + + + $principals_groups: + $principalcollection:$groupstype:/ + + + $principals_resources: + $principalcollection:$resourcestype:/ + + + $principals_locations: + $principalcollection:$locationstype:/ + + + + + $calendars: + $root:calendars/ + + + + + $calendars_uids: + $calendars:$uidstype:/ + + + $calendars_users: + $calendars:$userstype:/ + + + $calendars_groups: + $calendars:$groupstype:/ + + + $calendars_resources: + $calendars:$resourcestype:/ + + + $calendars_locations: + $calendars:$locationstype:/ + + + + + $calendar: + Default + + + + + $tasks: + Default + + + + + $polls: + Default + + + + + $inbox: + Inbox + + + + + $outbox: + Outbox + + + + + $dropbox: + dropbox + + + + + $attachments: + dropbox + + + + + $notification: + notification + + + + + $freebusy: + freebusy + + + + + $servertoserver: + $root:inbox + + + + + $timezoneservice: + $root:timezones + + + + + $timezonestdservice: + /timezone + + + + + $addressbooks: + $root:addressbooks/ + + + + + $addressbooks_uids: + $addressbooks:$uidstype:/ + + + $addressbooks_users: + $addressbooks:$userstype:/ + + + $addressbooks_groups: + $addressbooks:$groupstype:/ + + + + + $addressbook: + Default + + + + + $directory: + $root:directory/ + + + + + $add-member: + ?action=add-member + + + + + $useradmin: + admin + + + + $useradminguid: + admin + + + + $pswdadmin: + admin + + + + + $principal_admin: + $principals_users:$useradmin:/ + + + $principaluri_admin: + $principals_uids:$useradminguid:/ + + + + + $userapprentice: + apprentice + + + + $userapprenticeguid: + apprentice + + + + $pswdapprentice: + apprentice + + + + + $principal_apprentice: + $principals_users:$userapprentice:/ + + + $principaluri_apprentice: + $principals_uids:$userapprenticeguid:/ + + + + + $userproxy: + superuser + + + + $pswdproxy: + superuser + + + + + + + $userid%d: + user%02d + + + + $userguid%d: + user%02d + + + + $username%d: + User %02d + + + + $username-encoded%d: + User%%20%02d + + + + $firstname%d: + User + + + + $lastname%d: + %02d + + + + $pswd%d: + user%02d + + + + $principal%d: + $principals_users:$userid%d:/ + + + $principaluri%d: + $principals_uids:$userguid%d:/ + + + $principal%dnoslash: + $principals_users:$userid%d: + + + + + $calendarhome%d: + $calendars_uids:$userguid%d: + + + + $calendarhomealt%d: + $calendars_users:$userid%d: + + + + $calendarpath%d: + $calendarhome%d:/$calendar: + + + + $calendarpathalt%d: + $calendarhomealt%d:/$calendar: + + + + $taskspath%d: + $calendarhome%d:/$tasks: + + + + $pollspath%d: + $calendarhome%d:/$polls: + + + + $inboxpath%d: + $calendarhome%d:/$inbox: + + + + $outboxpath%d: + $calendarhome%d:/$outbox: + + + + $dropboxpath%d: + $calendarhome%d:/$dropbox: + + + + $notificationpath%d: + $calendarhome%d:/$notification: + + + + $freebusypath%d: + $calendarhome%d:/$freebusy: + + + $email%d: + $userid%d:@example.com + + + + $cuaddr%d: + mailto:$email%d: + + + $cuaddralt%d: + $principaluri%d: + + + $cuaddraltnoslash%d: + $principals_uids:$userguid%d: + + + $cuaddrurn%d: + mailto:$email%d: + + + + + + $addressbookhome%d: + $addressbooks_uids:$userguid%d: + + + + $addressbookpath%d: + $addressbookhome%d:/$addressbook: + + + + + + + + $publicuserid%d: + public%02d + + + + $publicuserguid%d: + public%02d + + + + $publicusername%d: + Public %02d + + + + $publicpswd%d: + public%02d + + + + $publicprincipal%d: + $principals_users:$publicuserid%d:/ + + + $publicprincipaluri%d: + $principals_uids:$publicuserguid%d:/ + + + + $publiccalendarhome%d: + $calendars_uids:$publicuserguid%d: + + + + $publiccalendarpath%d: + $calendars_uids:$publicuserguid%d:/$calendar: + + + $publicemail%d: + $publicuserid%d:@example.com + + + + $publiccuaddr%d: + mailto:$publicemail%d: + + + $publiccuaddralt%d: + $publicprincipaluri%d: + + + $publiccuaddrurn%d: + urn:uuid:$publicuserguid%d: + + + + + + + $resourceid%d: + resource%02d + + + + $resourceguid%d: + resource%02d + + + + $resourcename%d: + Resource %02d + + + + $rcalendarhome%d: + $calendars_uids:$resourceguid%d: + + + + $rcalendarpath%d: + $calendars_uids:$resourceguid%d:/$calendar: + + + + $rinboxpath%d: + $calendars_uids:$resourceguid%d:/$inbox: + + + + $routboxpath%d: + $calendars_uids:$resourceguid%d:/$outbox: + + + + $rprincipal%d: + $principals_resources:$resourceid%d:/ + + + $rprincipaluri%d: + $principals_uids:$resourceguid%d:/ + + + $rcuaddralt%d: + $rprincipaluri%d: + + + $rcuaddrurn%d: + urn:uuid:$resourceguid%d: + + + + + + + $locationid%d: + location%02d + + + + $locationguid%d: + location%02d + + + + $locationname%d: + Location %02d + + + + $lcalendarhome%d: + $calendars_uids:$locationguid%d: + + + + $lcalendarpath%d: + $calendars_uids:$locationguid%d:/$calendar: + + + + $linboxpath%d: + $calendars_uids:$locationguid%d:/$inbox: + + + + $loutboxpath%d: + $calendars_uids:$locationguid%d:/$outbox: + + + + $lprincipal%d: + $principals_resources:$locationid%d:/ + + + $lprincipaluri%d: + $principals_uids:$locationguid%d:/ + + + $lcuaddralt%d: + $lprincipaluri%d: + + + $lcuaddrurn%d: + urn:uuid:$locationguid%d: + + + + + + + + $groupid%d: + group%02d + + + + $groupguid%d: + group%02d + + + + $groupname%d: + Group %02d + + + + $gprincipal%d: + $principals_resources:$groupid%d:/ + + + $gprincipaluri%d: + $principals_uids:$groupguid%d:/ + + + $gcuaddralt%d: + $gprincipaluri%d: + + + $gcuaddrurn%d: + urn:uuid:$groupguid%d: + + + + + + $i18nid: + i18nuser + + + + $i18nguid: + i18nuser + + + + $i18nname: + まだ + + + + $i18npswd: + i18nuser + + + + $i18ncalendarpath: + $calendars_uids:$i18nguid:/$calendar: + + + $i18nemail: + $i18nid:@example.com + + + + $i18ncuaddr: + mailto:$i18nemail: + + + $i18ncuaddrurn: + urn:uuid:$i18nguid: + + + + + $principaldisabled: + $principals_groups:disabledgroup/ + + + $principaluridisabled: + $principals_uids:disabledgroup/ + + + + $cuaddrdisabled: + $principals_uids:disabledgroup/ + + + + + + + $cuaddr2: + mailto:$email2: + + + + + ATTENDEE:X-CALENDARSERVER-DTSTAMP + ATTENDEE:X-CALENDARSERVER-AUTO + ATTENDEE:X-CALENDARSERVER-RESET-PARTSTAT + CALSCALE + PRODID + DTSTAMP + CREATED + LAST-MODIFIED + X-WR-CALNAME + CLASS=PUBLIC + PRIORITY=0 + TRANSP=OPAQUE + SEQUENCE=0 + + PRODID + REV + + diff --git a/cassandane/data/certs/cacert.pem b/cassandane/data/certs/cacert.pem new file mode 100644 index 0000000000..3583045b0b --- /dev/null +++ b/cassandane/data/certs/cacert.pem @@ -0,0 +1,120 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + d8:8f:9f:11:01:4d:34:da + Signature Algorithm: sha256WithRSAEncryption + Issuer: O = Cyrus, CN = Cunit Test CA, emailAddress = ellie@fastmail.com + Validity + Not Before: May 6 00:39:56 2020 GMT + Not After : May 4 00:39:56 2030 GMT + Subject: O = Cyrus, CN = Cunit Test CA, emailAddress = ellie@fastmail.com + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (4096 bit) + Modulus: + 00:ad:93:cc:8d:90:4b:d7:7d:2e:e4:8e:2a:d4:6e: + 0c:31:cc:f3:0a:f0:01:be:6d:24:c8:c4:c7:9a:8a: + c5:0e:05:6a:86:62:14:b9:94:43:28:2d:43:ba:e2: + 9e:ab:e5:81:be:b5:93:fc:0b:c8:eb:f0:43:0a:74: + 9a:4d:67:69:86:0a:71:50:ac:fa:d4:6c:0a:fb:76: + 0a:28:bd:51:50:0b:8b:a6:38:6e:b5:a6:c3:78:33: + 89:32:cb:9a:0a:6b:03:82:5e:a3:1f:ad:0a:18:77: + 3e:8b:2a:88:32:d6:03:fc:96:d8:82:cc:f4:65:89: + ea:d8:ea:a6:65:21:e8:26:7b:46:05:2a:a3:d9:1d: + 68:e9:18:ee:e5:77:92:20:74:da:e7:42:24:35:e5: + 6b:63:b6:80:fa:dc:9e:42:80:ae:2d:3f:71:03:64: + 6a:b8:2a:1d:bf:f9:0e:33:f1:88:8a:a1:51:fe:62: + 0a:9b:5c:0c:9d:2a:c4:75:98:fe:40:32:d2:19:bf: + 3f:27:ec:15:06:87:62:e0:de:dd:85:5b:46:1d:b0: + b1:1f:90:4e:e7:38:5d:b9:00:7d:95:bb:da:fb:2a: + 03:ef:4e:2f:b0:44:8a:92:eb:09:82:38:52:8c:8a: + b7:70:14:f8:61:36:2c:da:81:08:ba:37:ea:bc:ba: + 99:4f:51:3e:6d:d3:01:a4:c4:7e:6c:47:8f:f3:47: + 9c:eb:16:a1:c3:f7:23:b8:35:98:a4:69:a2:02:c4: + 35:ad:8a:3a:8c:55:01:74:a4:45:20:99:db:de:dc: + d2:6a:42:bb:16:5e:c4:47:e7:4f:95:ab:49:4a:64: + 91:3b:97:d2:6e:92:92:ad:14:00:78:4c:e5:3e:bc: + 3d:36:c3:0c:2a:e9:dc:bd:83:27:d3:83:47:33:95: + 85:dc:34:2f:b9:de:e9:b0:46:c0:b5:26:5c:52:87: + 7d:cd:57:7c:04:dd:ce:01:20:a5:3d:9b:77:65:31: + 44:bb:c4:81:78:1e:63:59:14:9f:1c:3f:70:18:18: + 87:94:79:b2:a3:e7:da:96:ee:38:88:55:0c:ae:ef: + a0:75:c9:e7:4f:89:c8:09:a9:8f:eb:9a:00:c9:ae: + ba:dd:2e:c3:e6:3a:bc:13:f0:d7:8a:2f:43:e4:d5: + ed:70:6a:b3:2c:70:13:e4:1b:02:e8:e5:cf:a3:3d: + 96:a7:f3:3b:86:5e:c4:dc:dc:e3:f5:90:ca:c9:0e: + ee:08:cf:ac:4f:81:f1:5e:46:94:d7:b2:3c:de:3e: + 0b:e5:e4:c5:28:d5:1e:04:e1:8d:c5:4b:d0:62:c4: + 3d:46:1d:6d:27:5a:4f:f4:8f:9b:1c:bc:cd:e3:2b: + 8d:bb:21 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Subject Key Identifier: + 39:6F:8D:DF:ED:88:34:6D:F3:C3:9A:AC:4A:B5:49:43:AB:74:AB:0A + X509v3 Authority Key Identifier: + keyid:39:6F:8D:DF:ED:88:34:6D:F3:C3:9A:AC:4A:B5:49:43:AB:74:AB:0A + + X509v3 Basic Constraints: critical + CA:TRUE + Signature Algorithm: sha256WithRSAEncryption + 0f:25:56:f2:34:9a:3c:bc:37:6c:79:36:70:f5:6b:9b:d9:b6: + 58:eb:1e:ba:f9:08:d7:15:59:db:3c:aa:85:c4:54:6b:81:2a: + 15:fe:24:91:48:66:b4:23:bf:b9:ee:12:ac:19:f0:84:35:d4: + f4:99:b6:90:0a:67:54:22:40:ea:91:e7:97:75:96:b9:40:4f: + d0:b1:6a:07:24:b0:23:66:07:0c:4b:70:24:38:6c:bd:64:3c: + e2:a7:2a:5c:00:e6:cc:51:95:2c:54:c3:d1:8a:82:96:8e:82: + 75:80:52:cb:2b:e0:b5:bc:a3:d2:55:3c:9b:f8:c6:17:0c:a2: + d5:e7:a9:32:ba:e7:5e:ab:00:a2:4b:85:52:3e:15:95:3c:84: + a2:d9:8e:02:96:7e:c9:45:00:da:e0:b0:d9:c2:9a:9a:1c:18: + aa:4f:b6:29:02:d9:39:44:19:a6:f5:51:c9:15:88:c2:6d:87: + 42:7d:3c:1e:0d:05:a3:96:96:e9:7c:1e:47:84:90:f6:fe:89: + 47:59:ae:c7:84:86:ae:85:e7:d2:12:61:ed:72:18:27:68:c8: + f4:86:90:cb:63:f7:4b:5c:d9:98:0e:9b:c7:bc:be:82:aa:d7: + d8:a2:a8:48:36:8e:c2:7e:a2:19:2b:3b:2b:4b:08:3b:cf:b7: + 34:6e:4a:10:8e:4a:54:f5:bb:93:2d:a5:00:0f:b3:92:df:74: + 14:d0:8c:5f:3f:5b:78:94:33:bd:bd:69:8d:06:71:54:d8:1b: + 64:fc:11:44:08:95:c1:f0:24:55:7d:93:a7:0e:e0:cc:0a:7a: + d9:70:9f:48:f6:b1:38:e4:2d:9d:b7:3d:c1:52:7b:6a:89:cd: + 7d:1e:9d:3d:62:73:72:b0:39:11:04:3a:4a:95:37:97:71:5e: + 24:c5:4d:83:ba:9b:08:e0:99:ae:d0:76:dd:8f:c4:ee:66:1b: + c0:4c:57:da:1b:14:83:d8:78:74:27:00:b5:4d:58:19:1e:73: + ce:75:1f:a7:44:ce:98:31:89:10:5a:92:cb:78:93:9e:bc:28: + 2e:25:a7:d1:76:cf:11:8b:4d:be:54:11:92:4f:a2:19:59:a3: + f1:c1:65:16:d2:dc:ef:41:00:ed:f8:6e:3b:f1:37:b7:b8:4b: + 6f:53:e5:6e:d9:88:1b:c9:0b:ca:58:32:bc:6c:30:ea:42:12: + e7:16:03:7a:2c:24:d8:f9:d0:ff:35:f2:87:92:2c:6d:d3:38: + 58:77:ec:61:a5:42:e7:aa:c3:7c:3d:c3:d2:fb:f3:7f:03:35: + 45:08:76:18:8b:16:1f:6c:e6:86:97:39:56:f5:09:a2:58:82: + bb:79:05:67:1d:5b:4d:c8 +-----BEGIN CERTIFICATE----- +MIIFbDCCA1SgAwIBAgIJANiPnxEBTTTaMA0GCSqGSIb3DQEBCwUAMEsxDjAMBgNV +BAoMBUN5cnVzMRYwFAYDVQQDDA1DdW5pdCBUZXN0IENBMSEwHwYJKoZIhvcNAQkB +FhJlbGxpZUBmYXN0bWFpbC5jb20wHhcNMjAwNTA2MDAzOTU2WhcNMzAwNTA0MDAz +OTU2WjBLMQ4wDAYDVQQKDAVDeXJ1czEWMBQGA1UEAwwNQ3VuaXQgVGVzdCBDQTEh +MB8GCSqGSIb3DQEJARYSZWxsaWVAZmFzdG1haWwuY29tMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEArZPMjZBL130u5I4q1G4MMczzCvABvm0kyMTHmorF +DgVqhmIUuZRDKC1DuuKeq+WBvrWT/AvI6/BDCnSaTWdphgpxUKz61GwK+3YKKL1R +UAuLpjhutabDeDOJMsuaCmsDgl6jH60KGHc+iyqIMtYD/JbYgsz0ZYnq2OqmZSHo +JntGBSqj2R1o6Rju5XeSIHTa50IkNeVrY7aA+tyeQoCuLT9xA2RquCodv/kOM/GI +iqFR/mIKm1wMnSrEdZj+QDLSGb8/J+wVBodi4N7dhVtGHbCxH5BO5zhduQB9lbva ++yoD704vsESKkusJgjhSjIq3cBT4YTYs2oEIujfqvLqZT1E+bdMBpMR+bEeP80ec +6xahw/cjuDWYpGmiAsQ1rYo6jFUBdKRFIJnb3tzSakK7Fl7ER+dPlatJSmSRO5fS +bpKSrRQAeEzlPrw9NsMMKuncvYMn04NHM5WF3DQvud7psEbAtSZcUod9zVd8BN3O +ASClPZt3ZTFEu8SBeB5jWRSfHD9wGBiHlHmyo+falu44iFUMru+gdcnnT4nICamP +65oAya663S7D5jq8E/DXii9D5NXtcGqzLHAT5BsC6OXPoz2Wp/M7hl7E3Nzj9ZDK +yQ7uCM+sT4HxXkaU17I83j4L5eTFKNUeBOGNxUvQYsQ9Rh1tJ1pP9I+bHLzN4yuN +uyECAwEAAaNTMFEwHQYDVR0OBBYEFDlvjd/tiDRt88OarEq1SUOrdKsKMB8GA1Ud +IwQYMBaAFDlvjd/tiDRt88OarEq1SUOrdKsKMA8GA1UdEwEB/wQFMAMBAf8wDQYJ +KoZIhvcNAQELBQADggIBAA8lVvI0mjy8N2x5NnD1a5vZtljrHrr5CNcVWds8qoXE +VGuBKhX+JJFIZrQjv7nuEqwZ8IQ11PSZtpAKZ1QiQOqR55d1lrlAT9CxagcksCNm +BwxLcCQ4bL1kPOKnKlwA5sxRlSxUw9GKgpaOgnWAUssr4LW8o9JVPJv4xhcMotXn +qTK6516rAKJLhVI+FZU8hKLZjgKWfslFANrgsNnCmpocGKpPtikC2TlEGab1UckV +iMJth0J9PB4NBaOWlul8HkeEkPb+iUdZrseEhq6F59ISYe1yGCdoyPSGkMtj90tc +2ZgOm8e8voKq19iiqEg2jsJ+ohkrOytLCDvPtzRuShCOSlT1u5MtpQAPs5LfdBTQ +jF8/W3iUM729aY0GcVTYG2T8EUQIlcHwJFV9k6cO4MwKetlwn0j2sTjkLZ23PcFS +e2qJzX0enT1ic3KwOREEOkqVN5dxXiTFTYO6mwjgma7Qdt2PxO5mG8BMV9obFIPY +eHQnALVNWBkec851H6dEzpgxiRBakst4k568KC4lp9F2zxGLTb5UEZJPohlZo/HB +ZRbS3O9BAO34bjvxN7e4S29T5W7ZiBvJC8pYMrxsMOpCEucWA3osJNj50P818oeS +LG3TOFh37GGlQueqw3w9w9L7838DNUUIdhiLFh9s5oaXOVb1CaJYgrt5BWcdW03I +-----END CERTIFICATE----- diff --git a/cassandane/data/certs/cert.pem b/cassandane/data/certs/cert.pem new file mode 100644 index 0000000000..95856758d7 --- /dev/null +++ b/cassandane/data/certs/cert.pem @@ -0,0 +1,111 @@ +Certificate: + Data: + Version: 1 (0x0) + Serial Number: + b1:9a:bb:97:c3:6c:2f:03 + Signature Algorithm: sha256WithRSAEncryption + Issuer: O = Cyrus, CN = Cunit Test CA, emailAddress = ellie@fastmail.com + Validity + Not Before: May 6 00:51:14 2020 GMT + Not After : May 4 00:51:14 2030 GMT + Subject: O = Cyrus, CN = Cunit Test Certificate, emailAddress = ellie@fastmail.com + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (4096 bit) + Modulus: + 00:c5:7b:27:ab:e9:ec:a0:cd:3a:9a:ee:bf:d6:e8: + 40:da:5d:ff:23:75:7e:b7:c7:94:77:5f:65:a2:a6: + 58:18:4c:d8:b6:57:b5:ed:46:e6:2c:45:cd:09:ff: + bb:24:1b:75:14:54:d1:95:a3:d2:9b:13:5d:dc:4c: + e5:20:eb:07:d0:86:2b:1e:53:1a:fa:5e:c6:02:9c: + 74:82:8c:71:66:4d:7b:e5:e6:92:9b:68:6c:52:a1: + d1:cd:a6:12:b8:44:04:9e:55:d2:91:05:fc:83:86: + 47:6a:e3:d6:b9:b2:a6:01:3c:1e:a6:c7:95:90:81: + 36:f1:79:72:e1:07:97:1c:aa:41:3d:3a:60:dd:3b: + 2c:77:6e:ba:6d:cb:27:89:09:a9:db:c9:fe:ff:95: + a8:a5:ef:c1:7f:30:bb:a4:d9:d3:af:44:16:d6:45: + 1c:fa:49:e3:26:10:55:fa:b5:a1:91:99:bc:79:fe: + 8e:4b:92:a4:30:ca:f4:20:21:ac:0d:fe:c6:4a:69: + 8c:a5:80:3e:67:e9:fd:d2:02:91:8c:4a:cf:2c:ce: + 54:7c:cb:76:fa:e7:c0:a0:de:d0:fc:dc:e9:28:21: + cd:e4:26:3a:53:fd:bd:3e:ac:51:ae:a9:31:a4:3d: + 6d:c3:a6:b5:05:af:3e:c2:02:34:08:40:96:ee:d3: + 11:97:d3:0a:af:51:0e:a9:0f:dd:01:28:1b:51:56: + 44:91:7b:75:13:71:c3:71:3f:86:a6:c5:f4:18:69: + 2d:53:9f:c0:84:42:8f:9e:55:5f:5d:6f:c9:e8:a9: + 40:db:0c:30:f4:20:94:e6:d8:3c:b6:7f:ea:5f:b3: + a7:fe:4b:03:21:8f:f5:31:ce:cf:c1:77:b5:3d:6e: + 46:60:dc:c4:71:4c:18:69:6e:62:b5:ad:ef:da:f8: + 1d:49:fc:3f:00:6e:d1:ae:1e:01:97:0c:73:81:89: + 45:61:47:37:7f:22:88:59:bf:87:59:39:e1:c6:42: + b6:04:a6:ad:55:6a:53:41:91:a0:60:d0:c7:90:77: + 57:3d:97:7a:26:92:a6:ec:1a:39:b2:5e:97:a4:08: + 5a:f3:b3:a6:9a:b7:84:f7:33:98:aa:15:14:d6:f9: + b9:be:0a:98:85:f8:e2:ee:e5:c9:dc:b5:0f:30:1b: + 8b:fa:ef:94:3a:59:8d:03:cb:47:05:07:77:47:7c: + 57:2f:b3:19:0f:82:59:b9:05:92:ca:6f:a1:0e:29: + 66:52:99:77:8d:3f:07:61:14:af:63:e4:ae:93:6d: + 1b:2f:03:ad:a3:f6:e4:89:34:25:c1:c7:bc:ef:37: + e3:88:ff:92:67:91:9c:a2:91:6a:f7:9b:b7:e0:67: + c4:d1:db + Exponent: 65537 (0x10001) + Signature Algorithm: sha256WithRSAEncryption + 01:e9:6b:c7:a2:f7:20:0b:a1:ae:ef:7e:0f:73:8d:9f:4d:c0: + 9e:ce:0c:be:88:a0:d9:07:2e:3b:af:73:79:90:79:6d:67:e5: + 45:8d:cb:96:4f:db:f2:49:f6:5c:22:94:60:a9:0c:05:22:9f: + e3:4b:7c:b7:5c:e9:25:bf:25:63:f4:b9:f6:bc:dc:8d:ae:2e: + 34:b2:de:68:50:99:00:dd:b4:3f:ee:cf:f5:94:25:51:57:95: + 83:5b:d0:2f:98:80:d8:75:8f:b7:73:e1:18:37:85:70:6c:20: + 96:f7:3a:d7:79:e6:e1:cb:30:40:42:5c:74:34:8a:47:2c:d2: + 8f:a4:ba:54:4c:8c:00:9e:52:d7:af:88:63:a6:d0:35:c8:9a: + f1:04:87:65:7c:44:f6:9d:7e:83:ee:3e:62:23:21:05:b2:4b: + da:fa:dc:55:9b:bd:d7:58:08:6a:a1:85:6c:f6:2a:28:09:bc: + 07:ed:32:1a:95:e1:a2:3c:23:26:5b:b4:01:49:0f:87:e3:c3: + 16:75:f5:28:64:b8:b8:a4:68:b8:9e:8c:4b:80:7a:20:60:74: + bc:72:aa:96:7e:28:77:ed:00:7a:ac:51:13:34:c4:6e:6b:f7: + ae:9e:83:cb:0e:41:fc:51:f3:61:ff:fd:14:a2:15:da:2f:6a: + 18:2f:5f:01:0a:e9:ae:be:d6:44:37:70:d8:4c:e1:6b:b0:4f: + 34:3d:7b:f8:1f:f4:97:ea:c4:1c:af:c2:7f:50:8a:d1:55:b5: + 1c:b2:c0:9f:e4:1e:45:42:49:ef:05:8d:c2:fe:27:d8:e5:ec: + e9:d3:65:73:2d:7e:ad:34:05:93:e2:9c:bc:6a:f8:9c:75:09: + 1d:5b:60:e8:b6:15:a4:35:6a:55:38:3e:4e:dc:07:13:82:6f: + 0a:95:7d:fc:44:29:8f:d5:4b:f8:64:dd:54:5c:02:e7:be:84: + de:46:ad:65:5b:31:b4:7f:f0:de:03:a3:7c:e6:53:12:21:ed: + df:18:98:ef:7f:aa:59:ee:78:cc:1f:3b:b1:9b:67:75:1e:a5: + 8e:ad:ac:21:c9:b5:55:08:76:7a:24:d5:7a:87:ba:64:11:c3: + a7:89:35:8f:55:90:aa:e5:ed:7e:ee:c5:94:33:59:ad:ef:62: + 98:88:ae:d1:38:7d:25:56:ee:d0:9b:9d:cc:9a:fa:27:9f:83: + 59:7f:39:a7:06:b1:1e:f6:6e:5d:42:4d:48:02:ce:a8:6e:0f: + 78:f4:f0:b3:c7:0d:c3:26:a2:ff:ac:ea:6a:0d:6b:75:c2:72: + 49:c5:a7:36:47:90:23:da:f9:84:9c:c7:a6:6b:49:02:4d:a6: + dd:8e:e9:27:d2:4c:51:1b +-----BEGIN CERTIFICATE----- +MIIFGzCCAwMCCQCxmruXw2wvAzANBgkqhkiG9w0BAQsFADBLMQ4wDAYDVQQKDAVD +eXJ1czEWMBQGA1UEAwwNQ3VuaXQgVGVzdCBDQTEhMB8GCSqGSIb3DQEJARYSZWxs +aWVAZmFzdG1haWwuY29tMB4XDTIwMDUwNjAwNTExNFoXDTMwMDUwNDAwNTExNFow +VDEOMAwGA1UECgwFQ3lydXMxHzAdBgNVBAMMFkN1bml0IFRlc3QgQ2VydGlmaWNh +dGUxITAfBgkqhkiG9w0BCQEWEmVsbGllQGZhc3RtYWlsLmNvbTCCAiIwDQYJKoZI +hvcNAQEBBQADggIPADCCAgoCggIBAMV7J6vp7KDNOpruv9boQNpd/yN1frfHlHdf +ZaKmWBhM2LZXte1G5ixFzQn/uyQbdRRU0ZWj0psTXdxM5SDrB9CGKx5TGvpexgKc +dIKMcWZNe+XmkptobFKh0c2mErhEBJ5V0pEF/IOGR2rj1rmypgE8HqbHlZCBNvF5 +cuEHlxyqQT06YN07LHduum3LJ4kJqdvJ/v+VqKXvwX8wu6TZ069EFtZFHPpJ4yYQ +Vfq1oZGZvHn+jkuSpDDK9CAhrA3+xkppjKWAPmfp/dICkYxKzyzOVHzLdvrnwKDe +0Pzc6SghzeQmOlP9vT6sUa6pMaQ9bcOmtQWvPsICNAhAlu7TEZfTCq9RDqkP3QEo +G1FWRJF7dRNxw3E/hqbF9BhpLVOfwIRCj55VX11vyeipQNsMMPQglObYPLZ/6l+z +p/5LAyGP9THOz8F3tT1uRmDcxHFMGGluYrWt79r4HUn8PwBu0a4eAZcMc4GJRWFH +N38iiFm/h1k54cZCtgSmrVVqU0GRoGDQx5B3Vz2XeiaSpuwaObJel6QIWvOzppq3 +hPczmKoVFNb5ub4KmIX44u7lydy1DzAbi/rvlDpZjQPLRwUHd0d8Vy+zGQ+CWbkF +kspvoQ4pZlKZd40/B2EUr2PkrpNtGy8DraP25Ik0JcHHvO8344j/kmeRnKKRaveb +t+BnxNHbAgMBAAEwDQYJKoZIhvcNAQELBQADggIBAAHpa8ei9yALoa7vfg9zjZ9N +wJ7ODL6IoNkHLjuvc3mQeW1n5UWNy5ZP2/JJ9lwilGCpDAUin+NLfLdc6SW/JWP0 +ufa83I2uLjSy3mhQmQDdtD/uz/WUJVFXlYNb0C+YgNh1j7dz4Rg3hXBsIJb3Otd5 +5uHLMEBCXHQ0ikcs0o+kulRMjACeUteviGOm0DXImvEEh2V8RPadfoPuPmIjIQWy +S9r63FWbvddYCGqhhWz2KigJvAftMhqV4aI8IyZbtAFJD4fjwxZ19ShkuLikaLie +jEuAeiBgdLxyqpZ+KHftAHqsURM0xG5r966eg8sOQfxR82H//RSiFdovahgvXwEK +6a6+1kQ3cNhM4WuwTzQ9e/gf9JfqxByvwn9QitFVtRyywJ/kHkVCSe8FjcL+J9jl +7OnTZXMtfq00BZPinLxq+Jx1CR1bYOi2FaQ1alU4Pk7cBxOCbwqVffxEKY/VS/hk +3VRcAue+hN5GrWVbMbR/8N4Do3zmUxIh7d8YmO9/qlnueMwfO7GbZ3UepY6trCHJ +tVUIdnok1XqHumQRw6eJNY9VkKrl7X7uxZQzWa3vYpiIrtE4fSVW7tCbncya+ief +g1l/OacGsR72bl1CTUgCzqhuD3j08LPHDcMmov+s6moNa3XCcknFpzZHkCPa+YSc +x6ZrSQJNpt2O6SfSTFEb +-----END CERTIFICATE----- diff --git a/cassandane/data/certs/http_jwt/jwt.pem b/cassandane/data/certs/http_jwt/jwt.pem new file mode 100644 index 0000000000..73c0bd0dd1 --- /dev/null +++ b/cassandane/data/certs/http_jwt/jwt.pem @@ -0,0 +1,12 @@ +This is a Cassandane test certificate for HTTP JSON Web Token +authentication. + +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo +4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u ++qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh +kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ +0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg +cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc +mwIDAQAB +-----END PUBLIC KEY----- diff --git a/cassandane/data/certs/key.pem b/cassandane/data/certs/key.pem new file mode 100644 index 0000000000..2ad85a4427 --- /dev/null +++ b/cassandane/data/certs/key.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKQIBAAKCAgEAxXsnq+nsoM06mu6/1uhA2l3/I3V+t8eUd19loqZYGEzYtle1 +7UbmLEXNCf+7JBt1FFTRlaPSmxNd3EzlIOsH0IYrHlMa+l7GApx0goxxZk175eaS +m2hsUqHRzaYSuEQEnlXSkQX8g4ZHauPWubKmATwepseVkIE28Xly4QeXHKpBPTpg +3Tssd266bcsniQmp28n+/5Wope/BfzC7pNnTr0QW1kUc+knjJhBV+rWhkZm8ef6O +S5KkMMr0ICGsDf7GSmmMpYA+Z+n90gKRjErPLM5UfMt2+ufAoN7Q/NzpKCHN5CY6 +U/29PqxRrqkxpD1tw6a1Ba8+wgI0CECW7tMRl9MKr1EOqQ/dASgbUVZEkXt1E3HD +cT+GpsX0GGktU5/AhEKPnlVfXW/J6KlA2www9CCU5tg8tn/qX7On/ksDIY/1Mc7P +wXe1PW5GYNzEcUwYaW5ita3v2vgdSfw/AG7Rrh4BlwxzgYlFYUc3fyKIWb+HWTnh +xkK2BKatVWpTQZGgYNDHkHdXPZd6JpKm7Bo5sl6XpAha87OmmreE9zOYqhUU1vm5 +vgqYhfji7uXJ3LUPMBuL+u+UOlmNA8tHBQd3R3xXL7MZD4JZuQWSym+hDilmUpl3 +jT8HYRSvY+Suk20bLwOto/bkiTQlwce87zfjiP+SZ5GcopFq95u34GfE0dsCAwEA +AQKCAgAA3J65s1WjBgJBdtVDfNP7n/ljEDozVx2gv7vTz+IGiR9Q/GUA2hRbERrp +9kG80JncMtqPSp26q4T3VyaQ1DW+hTde9IHjodI/ZKtlfnNoPOJTiIQPRY9jdO1T +dmwSfcl/X2SB2YLWmBlrr/7Z5Juw2bBQjgJrFQVGXH9R2BSivWN3fu+5R27UPpl1 +rTNI98/T87e3KdIIl1lC0tWezIyN8UAgQ0DzHqttGRkm9O/1kLQv3BqG3eb1h401 +LrBvhzMaVAeXGU4saer/pZ84+4KX8XaQ7NpiEezXRuGmmNgzoqIhYsFSaIMQ6POa +TYa37sSx2+JiWfduJVBQ0OdXt3gWLY1kOHfWFcrRNqxlfGVvjky2/ByZjbd0SV+O +z01MV8/CW5UnuUnAPiVOX2zy8GIv6sU5sN8HZBgU1Edx4ZnrYiwbkyt+FRindBBP +iUytpaLZa9yieo6xG/rYhjIYXCtGbXilrtbSFr09hVDWV3pDihemh4r5gWvs6jFY +mNl+MVloblDhHZceq+RQgcFfCnHVO4chETeXwQyq00Y/YgeHrzgwpTwzEc0kgwjM +iU63+OU+UVfWzmu8+kJ59cFoQGJLZRyPSfmjzWu7Bnfq7XsTbnt8qldPm6pKoAek +GCmS+uTADFjxBIykVjWrCsixp3R9GRQQGjCIpXixLTskVp+G8QKCAQEA/lHCwAGN +nyyP4N5t3A/7/cv+krAzfwy6P7TJPVXgt5+GFudpd7jUT9uQlvKWX0H/U3fkPBmi +/LJuHOEtnGidFYGbTUe2mzZ229ZQ1NP1wMDBEsPxEjNi0+F3DM9Kn4pK6U5Uy9GY +nfu1iZJlS3DuXS09S3I2TpXshLo/pQMz5sFdGFI/VME/dMy6WmsfdCDRSV/NiTfz +jm6bKhGXpEw0oneo5W7ujvftjeW369xJgcMvUkIzwZWPJKqqlFC/riyEFiRQ9xjQ +5wuwyapgfhKiPij206hofO5Ar+MUAxpAIt4pl8QOJhE+Dp61edyMM7XuZ7nStTzm +bh95XjToH/wxZwKCAQEAxsk9QHDdHYgoVeibKNIqUKm41LSo4ZaQe6Uv99QvrPXg +U3pIKU88PPppUZH4S0EJbTWpA1MtXE5j1vqWdX7IbJXUBQ0v7bzw5P+jLVd3rrPN +UNQrX5yLO4vupXu+hjTzi3VbxnKIpEel31ajZ450BDIq+Z71pBNC/eltUN5WT+2I +cXedsZeMclq3WJEET2KRGHmK/3Dmge62NMUicdaPoUWUosCxDHHFA8YaPNUoIhjK +jhIl0/81Df0BSW41bH7gkv6Yb/sSsTSR5uyI6mRuGsDOgAMa7vF7aCPrzi8NDZzZ +eVklfP13I7uiRvOv3Kpsb2wcsNbuQlIS5YGQqJNPbQKCAQEAzOiLhbC6rvl0o7YT +xi+K1Z67au1VUJSsrA+55RWAjfKWU3X44GGnjwBVq4mh5vaCBnqfBl2RmREa72Hv +IgqYJm/a9ZVGaCCl+9LeJdzyMXAdIEWHwyZsBlOvXD7Y3VrLqNdYMzCZSxE337R4 +sSQ4qhJ9RICtiPv7KaX3Cble5BoALEx4go2B11XtAFU3bpXSitAKBvlx39z2YBr0 +l4hfEFhhWRrcU40ndiEU45EGGOtvAVQd52fdgamQ7xdwmaF8e2qfYbg4+S/OLW59 +eJcC6hqPZVJXffFpZU4NHcLU0kM2N/XbgIh7+8OcbKdqv29iu2hZgXWkJC5v15vB +O6QzGQKCAQEAxAXZ4tvpD6AetmiD6MMmexiCbS4hgyMoIuWH4clZoiNsLKVe122N +J0x/4rIguITPuOO7YM364xViGrJNAFwfZARzaO/SHYu9uPPlg2bHXH1tr5EpnEUQ +f43DrWfTPyCkMRdvgseauvT0OsKCrDGrch/OhQ0dich8vUocRCybzIGdlNaxqFib +ZIDUX//Q0j+OeSYRzUcV53bwMiVbjApa5Ftq8Ps3G+BsuQX3BZnk04rC40o+B0mY +lcyyIikNgYm0BwAMbhCWJCyE28TQVuLmOHd8qntlac6zNMSHWXDIXG4ZfjJMZ27C +t3fl1DWla+Kav11LBY9MsBWjELKtZa6uGQKCAQBMoZNz23ZjHMaZ7nyBG8huDTPF +6ll/3+7WvdL6o+YYmtcM8rKp2HAqr8xrh+QtKk1q3L55s8XyQbht82hdmXoXb08e +eF3QNGoC/urQ1usMz+lKeET/LoAG7z3lNKBBYZPEUl4T644ZqgbiLShb4KDsL+Xi +pIJAUut1YvOrcgiGP8fsjO43AcMev/dzfmfHL8YQ2JqVIMAZkVfdNnBP5lQ7mQ9y +QnNK1BPKd+apevGp7Cf0SQHL1j5MZW5A3Zwt5c41ZoyiDnDVFJheoMCcNDOiQ/VL +PyYckEI8JGkXj9TQiQaJCUNMx+cItxKTZWwsvA7XGl1eaFjjlpbVxhCjEQfh +-----END RSA PRIVATE KEY----- diff --git a/cassandane/data/custom-notification-template b/cassandane/data/custom-notification-template new file mode 100644 index 0000000000..fa04f0efb4 --- /dev/null +++ b/cassandane/data/custom-notification-template @@ -0,0 +1,8 @@ +custom notification! with ½ as much 8bit as regular milk +Mailbox: %MAILBOX% +Virus: %VIRUS% +Message-ID: %MSG_ID% +Date: %MSG_DATE% +From: %MSG_FROM% +Subject: %MSG_SUBJECT% +IMAP UID: %MSG_UID% diff --git a/cassandane/data/cyrus/legacy_sieve.tar.gz b/cassandane/data/cyrus/legacy_sieve.tar.gz new file mode 100644 index 0000000000..f79e1549b2 Binary files /dev/null and b/cassandane/data/cyrus/legacy_sieve.tar.gz differ diff --git a/cassandane/data/cyrus/quota_upgrade_v2_4.quota.tar.gz b/cassandane/data/cyrus/quota_upgrade_v2_4.quota.tar.gz new file mode 100644 index 0000000000..4ebc911a08 Binary files /dev/null and b/cassandane/data/cyrus/quota_upgrade_v2_4.quota.tar.gz differ diff --git a/cassandane/data/cyrus/quota_upgrade_v2_4.user.tar.gz b/cassandane/data/cyrus/quota_upgrade_v2_4.user.tar.gz new file mode 100644 index 0000000000..fdf4a62ca6 Binary files /dev/null and b/cassandane/data/cyrus/quota_upgrade_v2_4.user.tar.gz differ diff --git a/cassandane/data/directory.ldif b/cassandane/data/directory.ldif new file mode 100644 index 0000000000..832be9f7df --- /dev/null +++ b/cassandane/data/directory.ldif @@ -0,0 +1,56 @@ +dn: cn=internal,ou=domains,o=cyrus +objectclass: domainrelatedobject +associateddomain: internal. + +dn: uid=admin,dc=internal +objectclass: user +uid: admin +dc: internal + +dn: uid=cassandane,dc=internal +objectclass: user +uid: cassandane +dc: internal +memberof: cn=group co,ou=groups,o=cyrus +memberof: cn=group c,ou=groups,o=cyrus + +dn: uid=otheruser,dc=internal +objectclass: user +uid: otheruser +dc: internal +memberof: cn=group co,ou=groups,o=cyrus +memberof: cn=group o,ou=groups,o=cyrus + +dn: cn=foo,ou=groups,o=cyrus +objectclass: group +cn: foo +ou: groups +o: cyrus + +dn: cn=this group name has spaces,ou=groups,o=cyrus +objectclass: group +cn: this group name has spaces +ou: groups +o: cyrus + +dn: cn=group co,ou=groups,o=cyrus +objectclass: group +cn: group co +ou: groups +o: cyrus +member: uid=cassandane,dc=internal +member: uid=otheruser,dc=internal + +dn: cn=group c,ou=groups,o=cyrus +objectclass: group +cn: group c +ou: groups +o: cyrus +member: uid=cassandane,dc=internal + +dn: cn=group o,ou=groups,o=cyrus +objectclass: group +cn: group o +ou: groups +o: cyrus +member: uid=otheruser,dc=internal diff --git a/cassandane/data/dogcatbat.pdf.b64 b/cassandane/data/dogcatbat.pdf.b64 new file mode 100644 index 0000000000..7827f51fdb --- /dev/null +++ b/cassandane/data/dogcatbat.pdf.b64 @@ -0,0 +1,130 @@ +JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURl +Y29kZT4+CnN0cmVhbQp4nDPQM1Qo5ypUMABCU0tTPSMFCxNDPQuFolSucC2FPKiMgUJROpdTCJep +GVDK3NxYz1IhJEVB381QwdBIISQt2sbA0MDIwNjAxMDUztDGwAxEmNvpmtoARSxg3NgQLy7XEK5A +rkAFAMfcGRUKZW5kc3RyZWFtCmVuZG9iagoKMyAwIG9iagoxMDgKZW5kb2JqCgo1IDAgb2JqCjw8 +L0xlbmd0aCA2IDAgUi9GaWx0ZXIvRmxhdGVEZWNvZGUvTGVuZ3RoMSA5MTA0Pj4Kc3RyZWFtCnic +5ThpcBvnde/bBQjwEAFIIAkJkrDQiqQYHqBIXdRFCCRAUqRI8HIAyhKxBEACNnEIAKnIRwzHsqxS +Uc3Yqe+x1TTjelLPeGnZKe06MZ2jbaZ17bROYidWwjTJZDKRatlxktaxwL7v2yVFyrI97fRfP+Db +ffd733vv+3aBTGoiDEWQBR6cwZiUXLuqUA8A/wxAVgcnM8K+npI9CM8DcP86mhyLPfK3N74PoHkO +QPfc2PiJ0en8iymAogjiXZGwFDpnOVIDsAb5sCOChJ7cCR3iFxHfHIllPvcI+aIAYDZSfDwRlJ7W +tyLfvA1xc0z6XLJJ08wh2I64EJdiYVszj7AZ7RdOJhPpTAg2LwDY5ik/mQonux4Z+S6CiPLTeCH4 +oaMIwTyKc7xGm6fTw//XoT0LJdCu3QcGSLLrisE/DWvhYYAFWp9l11zXwgf/l1Go+X8InoTn4Cy8 +BUdUhge8EIUJpCwfr8D3kUqHF4bgazD1MWafhlnkK3IBuJeu5LrDCw/CefiHFV68EINbMZbn4S2y +Fb6HrZKA94ge7oTvotX3kHboeqa4YryMMnB0GfUn8Ch3Bg5yv0TkYcrhHJwRvgOPkaNoOYPrPLu0 +4r0fMXoP3I7XfojAJMJsaPd9+GPIX/gdrup2OAhfgAMwvkzjJfIEX4D1G4AnMKevMJpjkalr52/i +vs5xV+5H5EswhlMiuHbuLH/gYzL0Px78IKwiVXw55F+Py20DQ+4DrmHhfX4zFMDgwuVF2kLnwu94 +KRfXDGvWa/dp/umTfOR9SRNDbVj4Ve7WXEjbrX0Sq/UUgLPt8JDfNzjQ39fr7ek+1NV5sKO9zeNu +bXEdcDbv37d3z+6mXTt3bN9a76irrdlSWVG+Wdxkt1nMJqOheFVhQb5el6fV8ByBGkEmAbfMlwsm +jyS6Ram9tkZwWyKttTVu0ROQBUmQ8aapENvbGUmUZCEgyBV4k5aRA7ITJUevkXQqks4lSWIU9sJe +6kIU5FdbRWGWDPX6ED7bKvoF+RKDDzFYU8GQVYjY7ajBoqLRCm7ZMxmZcgcwRjJTWNAitoQLamtg +pqAQwUKE5C1icoZs2U8YwG1x757hQL+KusWVuqWQ7O31uVutdru/tqZDLhZbGQtamEk5r0XWMZNC +lIYOZ4SZmrmpL84aYSRQXRQSQ9KNPpmXUHeKd09N3SObquUqsVWuuuWXFlx5WK4RW91yNbXa2bfk +p/OqSyJry42iMPV7wOWIly6upEgqJa/c+HugoMy1yKTPZ6fD6sFcT015RMEzFZiSZheyI6JgFKdm +ioqmkm5MN3h9aGJ24cUzVtnzRb9sDETIbr+6dE9fp7ym97BP5so9QkRCCn6bRfsuq920JOP9ODZg +WjA5mGG7nabhzKwTRhCRs70+BRdgxPosOB3VfpkLUM7cIqdkkHKyi5wl9YCIte3s903JmvKOkOjG +jJ+R5OwIdtdNtDCiUS7+g9UuTq02CU0OP5MVMKqOUFSQtRWYJNRaroB9Q1WmjAwp/oNyu2RFBxWm +1UKTiGaoHbfoDqjfyYgFDQiY6PZqpREGfLKzFQGnpFbMPVPvQA0pgAWLtrJiyg4xKZtF11J1aVju +aL+PqahqsrlFhkBQ1ZIdbravBPdUoFUJgdoSe30vQOPC/Mw2wXq+EbaBv5UKl7Zgl1W4p3yhUdkW +sIZw340KPqtddvqxwn7RF/bTtsMMVc1bWXP4Wa8M+Dr7xc7eId8uNRCFQc1pyt3XmBF9VsUMNqCs +L9cLPs7K+1HQiATBg4Do2otXWVeux2nEhDMqbVzXXsFHrLAojWHIVYI73KrKUXyFUS1tp5b2RWt5 +FEU7Le1Wu9+ujNoaDtmC6hg19DSp7YssPKaQocf+bGlnJJpLC216wSeGRb8YEWSn10fXRtPDsqwm +g+VcrdXACmxZsjBNYEf2IkKTKXuqrcuTK7cxfAltv4bdscgWpvRiZ/8UNS6qBgEj75CBtrBzl8nK +zgK6oUU8ewUjbmm2oadmnE66mSO7qRGxIzQl9vv2Mmk8T2633kJ9rYZO0jngqq3Bo801I5LTvTNO +crp/yPeCEV8GTw/4nuUI1xJw+Wc2I8/3goAPDUblKJUSKSJQhFrqQ0TP5K0vOAGyjKthBIYHZwkw +mn6RRiA4yyk0o+KogjlyAoccjcJxLkprkKZXaFlGY2MGaMqcBVqn3pnvLOJWcdYZQknPIuVFfI/N +J3C+iKwi1hnU6mPkWZKdyXdaFYksSjiVCE8PXnU9OOQ7X4RPZyu7oiMXHdgulggWGx8rbiFEG+U2 +f2Qq4KebDUqxNPglMhH3Y5nE/RhIXpFcIIZdcqHoovRmSm9W6HmUrsMWJaUE1bNYe69MaAcc9tlx +SwrrvmedMl6ilfLjoTJl/FUtBndw4SL/a3wfXQMbIOvsMWsKYe1ao8a40bbG6PWvKTEUef0G0K33 ++nXGtfi+wJX1+rlSsJE2r404baTeRgQbQXzORrKMogABRj+C49ixYyk6jh6B5moTNJoaLc3VoE7T +atLUZEIafrfWl+eJgmnb6saGsop9pHK73VRWYifm0saGnaYKUeD+7diDuTt+/MZ4Iu9x0prJ/WfO +lj15bMifyn3oGSI//yMhZfa737fUfvDC2lry6jf/rpL7tYm+9kA3rvFZ/rv41lIKLzrvNGkLQQtl +Fn2x1683cmYvLkiwELCQeQvxWki9hRgt5DJDX7eQOQuRLeSchUxbSNZCkhYSsBCnhSgqe55gJC8j +1TOqkTGW659jmooaXmlK2Eip4+iR5UPhwDUJ2lpPjPZNFdu37WhsKNVtqxA35ZXQ1Ozgn821v/Hm +m2//8MfPff7UXRPH7zyZJT/JmXLv/seHf/zdm996cf4X3/gOezkEX87Dv6WxQiUe6E7yuHOhxuEo +M+et279hF2xZtQoqRK11wzpz/gEXv93rL6uuLtBaK0QNX8AXCCZhj9cvGE0NXr9p/XMucs5Fvuwi +WRfJuEjIRQZcpNVFtrnIZhcxu4jGReZd5A0XmXMRFP4qEz65UliRBBd530V+yYS/s1I49BGbTctF +v7ootNy35iMCSy6dTEZwEc7I3F52OTfRKF9nUcpsSdNsSUkXCbhIPRNeWZjhY1dHatlYWcAjx1aM +5YJLVYWmJgtmn5WWFlmBlI9SbXOeuKlSt5E02htKS8zFRGTF31lHtu9Uu6BsZ5mulMdWsBcTpRdI +w47ti63Bj37r630e/KW+g5Q+dP/Ev//l3PfaA03djz/+7RfLM7YL4pmWKk9b7suf2X5b9q+fz52P +HT4aiY4EuLu+8qThLtPGk5noY4OTse1j7jU3bn/24FuPPGUoSFRPd3443uTcnKi/ofM2buL2O+4+ +ljp58nP0Fz7dY924x0oh4NyLO6xUW4o7zOD1F+mNpWbe3OvnS3FX7F++Sy6z/aFsDqQ/YyHDuDmO +LCVt8cSwNDcu3wflmAs8JjADZSaxEg8GEzsi+O6tTw/ldv7mrXvO7azuz+Te/6u/uW+8aXMVefe3 +V2y5D5505CJvPG+n50E7xnqMfwWsUA4xZ7NJX16uEYqK1mp4/FmyqWBTr99SYjLhkWcw2UxcEW8y +gb6gVKfBM7AESrx+MGYryXAlcVYSBI5c3aw4G1c3OYaPHmEVZqfd1dLSomIp6aFWibU1bdtPmgkr +l4GI23cQHa0iFnEn+f4jX5rI5dakZt7tOPfQ2baDof5Nu75C4K5Tw/e2Bhv4Vz7/hSt3r609miKW +o7ce4DX3Szc6Jl4Vcxs12qNx2Wah9TAvXORqNXdiPdqclQXFxbo1PF9m0RQV4nmerys0mAFMvX4o +fYKdbs0W4qCpP5JaynijGn3T6qaGBpp2LfafSdzeTBpLGktEJeclxYR0B4ZvvT3c/KMf7anf3S+e +NKfGuPtrK3/wg4ErdxxwGQ9YbOwMBnoG43OmkD1nelfrdBugbEPZRtu6fK9/XWne6tVmM9/rNxuV +B46TPUTwsdI0bSNGG5lnzxXZRqYXny7Xf9IorXNUWQjbaeoDZ6l/2N5ie0RnYluGPmfMOrqfSswc +brJKLpc9tSezbmBi6rYrZ/6MOPJCD829+vMf3PBaN7k8+1xJ0ZUy45uaOkttTt4x3f2bi1dy/1VB +10jUdR6qPDVs2Pt7zqb8v/KPra//y9Vfz3gC06ft0p8vip7OnnPDZ5dTVoz8vCbA1xU4iLMbp4+j +x/lZ6MbZzjWBWbMomYI/cJvxcwt3kd/GP8ks5cMBPPvZHwBgBAfciMC3+b9HGuVuJPElfzcs+SYo +eYMKc6CDURXmcc/EVFiDMqdVWAvF8JAK54EBnlRhHdwCz6uwHszEocL5UExaVLiAxEmvChfCeu7l +pX8L67ifqPAq2M7nq3AxrOP30+g19F+Op3mfChMQNBoV5qBYs1mFedihaVBhDcpEVFgL6zWnVTgP +Nmq+qsI6eF/zLRXWwxbt11U4H9Zrf6rCBdzb2g9UuBB26X+owkVwY36xCq+Cm/JvUuFi2Jb/Rmt0 +LJqJ3hIOCSEpIwnBRPJEKjoWyQhbglVCQ/3WeqEtkRgbDwstiVQykZIy0US8rqDlWrEGoQ9NtEuZ +GqEjHqzrio6EFVmhP5yKjvaFxybGpdSBdDAcD4VTQq1wrcS1+A3hVJoiDXX19XUNV7nXCkfTgiRk +UlIoHJNSNwuJ0ZWBCKnwWDSdCaeQGI0Lg3X9dYJXyoTjGUGKh4SBJcWe0dFoMMyIwXAqI6FwIhPB +UG+aSEXToWiQekvXLa1gWTr6M+HJsHBIymTC6UTcJaXRF0Y2EI0n0jXC8Ug0GBGOS2khFE5Hx+LI +HDkhrNQRkCvhWuLxxCSanAzXYNyjqXA6Eo2PCWm6ZFVbyESkDF10LJxJRYPS+PgJrFksiVojWKTj +0UwEHcfCaaE7fFzoS8Sk+NfqlFAwN6OYVCEaS6YSkyzG2nQwFQ7H0ZkUkkai49EMWotIKSmIGcO0 +RYNplhFMhJCU4rXuiVQiGcZIP9vWdVUQA1SymU6MT6JnKh0Ph0PUI4Y9GR5HJXQ8nkjcTNczmkhh +oKFMpHZZ5KOJeAZVE4IUCuHCMVuJ4ESM1gnTnFkMTgqmEshLjksZtBJL10UymeRuh+P48eN1klqa +IFamDi07PomXOZEMq/VIUSux8S4sf5yWboLVly6iv6NL6ElifjwYnKAK1AiLrbm1bqvqAtMYTWbS +denoeF0iNebo8XRBK0RhDGcG5y0QhhAIOCXEJYSCkIAknMCzkUpFkCrAFqRW4b0B6mErTgHaUCqB +/HHUF6AF4RRq0avE7CYgDnX4C6LlU601INSnRtHOtGsQ6kD9IFroQr0R5C63K0A/o0TxnKWaYzCB +cUhIOQBp1AqjTIhJCFCL89NsfBr/BgallzgNGFc9fuoQup7up1mOoi2B5TrDODTWGIv/ZqQlUO+T +MiKgXJjVL42cMMNCzCq1PYgS/UzKyzRpLjLMW5xJDVzHYw96HEX9IKvlomSQ2aY9oVhOIBxRs3oT +ZjzFIggxvcW1pdHzR2tw/e7oZ9FNMp+HGJ3iacZzIZ5W16XkbIBFkUAqzcVxjIT6jTBYYvkMMW3a +ZXFVcwT7TvhEP4KqK6l1iTMfk2qUVKdGzfcou6aZ3zj6EFh8SpVX+hZYniSWdaXSMeRmmGwQ6eP4 +OaHusxhmRfE1ou6k42xfRtQVx5hdAbrxfpx1RYLVLW7fxGp8NStK34yqnSow3STCCbaKxTzWstrQ +lYRZpBSS2N4fQY1x5luJLcK6Q2K1Dau1zrAVLOYrpK6URp1klFpws76gOz6s5vSzeFJ0XdeiksHl +vUlrMs7iTS+zHWfRhpbWqGSbSo2rnpQVj7MT6eal+oyyflMyGmLWaj8m56MsNxnVa4JFFMKPUnGl +txKoO8HqoewnpZszH8mcxPKbUPWS7FzKqLHE2P6IsA5Mwm58t3RgdPRTx/pw+a4JqnumTo3Z8b/W +o3ElWQaX74/UUiwxjLFL3f3xpV03sWz/LlaiH8+gLnZeJNX+8aiZE66xQHfNtafmVvS39ZpVKN0Y +RTzD4kmzXNaxNYwhvwc9dLH3aDYW7BjTdcZMvvfACAkDIREyBmvwZ1AAuskwDJIDsI848e5Engvv +LYjTex3ZB1mU24f0/YjvRfoePDxteG3G2YPzXpwanIpEPUo48O5Q8VrEa1DjNbwSNim1Gan0fhDx +dry3qXcP0t14d6t4B+J4hwDR4Yt4M7u+TDTO82T+CnntChGukDv+RLx/Itn3pt/j3r1cZXvm8suX +uZ53ht955h2+/h1ieIfo4ZLxkvdS4FLy0rlLeQWGi6QIfktMv5jfZfvZvguDP9339iBcwJVdqL/g +vZC9IF/QXiD84Nt8qc04J8zVzyXnsnOvz83PXZ7TZ785/U3uGy85bIaXbC9xtvM95+84zweeIoan +bE9x3kcDj3LTjxHDY7bHHI/xjzxcZ3u4baPtwQcqbfMPXH6Am12YO//AKpPnJdJDumAf5rD7PL9g +e+ZACTmEyzLg1YbTgbMHZwLnvTjxdw+K23A6SJdzFz/8F6TwPut91ffdet+Z+7TJU9lT06f47N3T +d3PPTL48yaW9VbZEvNoWb/uMbW2jZVDXyA/moRv07uwYKd/iCQw7bcModHio3jbUVmVb07h6UIsL +1qCggbfxzXwPn+Dv5V/mdfo+70ZbL85572Uv5/TmF3kMPbYeRw8/uzDvDHfa0drB5MHsQb7DU2Vr +b9tlM7TZ2hxtr7X9rO2dtrzhNvIEfj3PeF728E5PlcPj9Gy0e9a3WwdLG0sGTcQwaGw0DHIEC90I +gw7DgoEzGIYNdxh4AzQDly0lWjJLpmcG+qurO2d1C32dst57WCan5fJ+enX2Dsl5p2UYHDrsmyHk +z/13nz0Lrg2dckO/Tw5s8HfKIQScFMgiYNwwUwoufzqdqWaDVFcjPIFXqJ6oRuLRtEKFJT5Up0ka +z6g0UyLVVEDBCV6rKQ8JVI+g9tE00AtlVitKVDutmmPKyoUBlqP/DRXTF5cKZW5kc3RyZWFtCmVu +ZG9iagoKNiAwIG9iago1MjMzCmVuZG9iagoKNyAwIG9iago8PC9UeXBlL0ZvbnREZXNjcmlwdG9y +L0ZvbnROYW1lL0JBQUFBQStMaWJlcmF0aW9uU2VyaWYKL0ZsYWdzIDQKL0ZvbnRCQm94Wy01NDMg +LTMwMyAxMjc3IDk4MV0vSXRhbGljQW5nbGUgMAovQXNjZW50IDg5MQovRGVzY2VudCAtMjE2Ci9D +YXBIZWlnaHQgOTgxCi9TdGVtViA4MAovRm9udEZpbGUyIDUgMCBSCj4+CmVuZG9iagoKOCAwIG9i +ago8PC9MZW5ndGggMjU4L0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQp4nF2Qu27DIBSGd56C +MR0iMEnjDBZSlciSh7RV3T4AhmMXqQaE8eC3L5e0lTqAvnP5z41cumtndCCv3soeAh61UR4Wu3oJ +eIBJG1QxrLQMdyv/chYOkajttyXA3JnRNg0ibzG2BL/h3ZOyAzwg8uIVeG0mvPu49NHuV+e+YAYT +MEWcYwVjrHMT7lnMQLJq36kY1mHbR8lfwvvmALNsV2UUaRUsTkjwwkyAGko5btqWIzDqX+xcFMMo +P4WPmVXMpPR05JFZ4TbxoXCd+JiZ0cSPxX9IfCpcJa4z17nOufhZ7n/vlCZJp/rZEMvV+7hdvmde +Ky2kDfye3FmXVPl9AzpvfacKZW5kc3RyZWFtCmVuZG9iagoKOSAwIG9iago8PC9UeXBlL0ZvbnQv +U3VidHlwZS9UcnVlVHlwZS9CYXNlRm9udC9CQUFBQUErTGliZXJhdGlvblNlcmlmCi9GaXJzdENo +YXIgMAovTGFzdENoYXIgOAovV2lkdGhzWzc3NyA1MDAgNTAwIDUwMCAyNTAgNDQzIDQ0MyAyNzcg +NTAwIF0KL0ZvbnREZXNjcmlwdG9yIDcgMCBSCi9Ub1VuaWNvZGUgOCAwIFIKPj4KZW5kb2JqCgox +MCAwIG9iago8PC9GMSA5IDAgUgo+PgplbmRvYmoKCjExIDAgb2JqCjw8L0ZvbnQgMTAgMCBSCi9Q +cm9jU2V0Wy9QREYvVGV4dF0KPj4KZW5kb2JqCgoxIDAgb2JqCjw8L1R5cGUvUGFnZS9QYXJlbnQg +NCAwIFIvUmVzb3VyY2VzIDExIDAgUi9NZWRpYUJveFswIDAgNTk1LjI3NTU5MDU1MTE4MSA4NDEu +ODYxNDE3MzIyODM1XS9Hcm91cDw8L1MvVHJhbnNwYXJlbmN5L0NTL0RldmljZVJHQi9JIHRydWU+ +Pi9Db250ZW50cyAyIDAgUj4+CmVuZG9iagoKNCAwIG9iago8PC9UeXBlL1BhZ2VzCi9SZXNvdXJj +ZXMgMTEgMCBSCi9NZWRpYUJveFsgMCAwIDU5NSA4NDEgXQovS2lkc1sgMSAwIFIgXQovQ291bnQg +MT4+CmVuZG9iagoKMTIgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PcGVuQWN0 +aW9uWzEgMCBSIC9YWVogbnVsbCBudWxsIDBdCi9MYW5nKGVuLVVTKQo+PgplbmRvYmoKCjEzIDAg +b2JqCjw8L0NyZWF0b3I8RkVGRjAwNTcwMDcyMDA2OTAwNzQwMDY1MDA3Mj4KL1Byb2R1Y2VyPEZF +RkYwMDRDMDA2OTAwNjIwMDcyMDA2NTAwNEYwMDY2MDA2NjAwNjkwMDYzMDA2NTAwMjAwMDM2MDAy +RTAwMzA+Ci9DcmVhdGlvbkRhdGUoRDoyMDE5MDQwNTA5MDYxNyswMicwMCcpPj4KZW5kb2JqCgp4 +cmVmCjAgMTQKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDA2MzU2IDAwMDAwIG4gCjAwMDAwMDAw +MTkgMDAwMDAgbiAKMDAwMDAwMDE5OCAwMDAwMCBuIAowMDAwMDA2NTI1IDAwMDAwIG4gCjAwMDAw +MDAyMTggMDAwMDAgbiAKMDAwMDAwNTUzNSAwMDAwMCBuIAowMDAwMDA1NTU2IDAwMDAwIG4gCjAw +MDAwMDU3NTEgMDAwMDAgbiAKMDAwMDAwNjA3OCAwMDAwMCBuIAowMDAwMDA2MjY5IDAwMDAwIG4g +CjAwMDAwMDYzMDEgMDAwMDAgbiAKMDAwMDAwNjYyNCAwMDAwMCBuIAowMDAwMDA2NzIxIDAwMDAw +IG4gCnRyYWlsZXIKPDwvU2l6ZSAxNC9Sb290IDEyIDAgUgovSW5mbyAxMyAwIFIKL0lEIFsgPDM1 +REYxREJEMTVCMTE2OTk2Qjk4NzY1QTAyRTdDMEVBPgo8MzVERjFEQkQxNUIxMTY5OTZCOTg3NjVB +MDJFN0MwRUE+IF0KL0RvY0NoZWNrc3VtIC84QjhFQzk3Qjk0MDlDQ0EzMTc5QkEyRUJCMjYzMzc5 +Ngo+PgpzdGFydHhyZWYKNjg5NgolJUVPRgo= diff --git a/cassandane/data/icalendar/alerts.ics b/cassandane/data/icalendar/alerts.ics new file mode 100644 index 0000000000..d0d94cdb27 --- /dev/null +++ b/cassandane/data/icalendar/alerts.ics @@ -0,0 +1,76 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/Vienna +X-LIC-LOCATION:Europe/Vienna +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:72a66940-3694-48a8-b332-0de9fb73016d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:Yep +DESCRIPTION: +LAST-MODIFIED:20150928T132434Z +BEGIN:VALARM +X-WR-ALARMUID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-1 +UID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-1 +TRIGGER:-PT5M +ACTION:EMAIL +ATTENDEE:mailto:foo@example.com +SUMMARY:Event alert: 'Yep' starts soon. +DESCRIPTION:Your event 'Yep' starts soon. +END:VALARM +BEGIN:VALARM +UID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-2 +ACTION:DISPLAY +DESCRIPTION:Your event 'Yep' starts soon. +TRIGGER;VALUE=DATE-TIME:20160928T135500Z +ACKNOWLEDGED:20160928T140005Z +END:VALARM +BEGIN:VALARM +UID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3 +ACTION:DISPLAY +DESCRIPTION:Your event 'Yep' already started. +TRIGGER:PT10M +END:VALARM +BEGIN:VALARM +UID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3-snoozed1 +ACTION:DISPLAY +DESCRIPTION:I'm a snoozed alert! +RELATED-TO;RELTYPE=PARENT:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3 +TRIGGER;VALUE=DATE-TIME:20160928T150005Z +END:VALARM +BEGIN:VALARM +UID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3-snoozed2 +ACTION:DISPLAY +DESCRIPTION:I'm a snoozed alert! +RELATED-TO:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3 +TRIGGER;VALUE=DATE-TIME:20160928T150005Z +END:VALARM +BEGIN:VALARM +UID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-4 +ACTION:NONE +DESCRIPTION:This alert should be ignored +TRIGGER;VALUE=DATE-TIME:19760401T005545Z +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/attendee_noorganizer.ics b/cassandane/data/icalendar/attendee_noorganizer.ics new file mode 100644 index 0000000000..73a6508b23 --- /dev/null +++ b/cassandane/data/icalendar/attendee_noorganizer.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:foo +LAST-MODIFIED:20150928T132434Z +ATTENDEE:mailto:attendee@local +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/attendeedir.ics b/cassandane/data/icalendar/attendeedir.ics new file mode 100644 index 0000000000..08a640d38c --- /dev/null +++ b/cassandane/data/icalendar/attendeedir.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:foo +LAST-MODIFIED:20150928T132434Z +ATTENDEE;X-JMAP-ID=attendee;DIR="https://local/attendee/dir":mailto:attendee@local +ORGANIZER:mailto:organizer@local +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/color-categories.ics b/cassandane/data/icalendar/color-categories.ics new file mode 100644 index 0000000000..623919f574 --- /dev/null +++ b/cassandane/data/icalendar/color-categories.ics @@ -0,0 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +RELATED-TO:58ADE31-broken-UID +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:double yo +CATEGORIES:red +CATEGORIES:foo,bar +CATEGORIES:baz +SEQUENCE:9 +SUMMARY;LANGUAGE=en:yo +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/color.ics b/cassandane/data/icalendar/color.ics new file mode 100644 index 0000000000..b5216abc1d --- /dev/null +++ b/cassandane/data/icalendar/color.ics @@ -0,0 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +RELATED-TO:58ADE31-broken-UID +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:double yo +COLOR:red +CATEGORIES:foo,bar +CATEGORIES:baz +SEQUENCE:9 +SUMMARY;LANGUAGE=en:yo +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/description.ics b/cassandane/data/icalendar/description.ics new file mode 100644 index 0000000000..6f6c0e40a8 --- /dev/null +++ b/cassandane/data/icalendar/description.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +RELATED-TO:58ADE31-broken-UID +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:Hello, world! +SEQUENCE:9 +SUMMARY;LANGUAGE=en:yo +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/endtimezone.ics b/cassandane/data/icalendar/endtimezone.ics new file mode 100644 index 0000000000..b05a1378d2 --- /dev/null +++ b/cassandane/data/icalendar/endtimezone.ics @@ -0,0 +1,15 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/London:20160928T130000 +DTEND;TZID=Europe/Vienna:20160928T150000 +SUMMARY:Foo +UID:3edfa67a-9e92-4ed7-b0cf-c07ad63ab8f8 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/floatingtzid.ics b/cassandane/data/icalendar/floatingtzid.ics new file mode 100644 index 0000000000..95fc363e76 --- /dev/null +++ b/cassandane/data/icalendar/floatingtzid.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20190310T111500 +DTEND;TZID=Europe/Amsterdam:20190310T130000 +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SEQUENCE:9 +SUMMARY:floatingtzid +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/keywords.ics b/cassandane/data/icalendar/keywords.ics new file mode 100644 index 0000000000..4fb2ac86ad --- /dev/null +++ b/cassandane/data/icalendar/keywords.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +RELATED-TO:58ADE31-broken-UID +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:double yo +CATEGORIES:foo,bar +CATEGORIES:baz +SEQUENCE:9 +SUMMARY;LANGUAGE=en:yo +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/links.ics b/cassandane/data/icalendar/links.ics new file mode 100644 index 0000000000..cb1ade06c4 --- /dev/null +++ b/cassandane/data/icalendar/links.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:40d6fe3c-6a51-489e-823e-3ea22f427a3e +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:yo +ATTACH;X-TITLE="the spec";X-JMAP-CID=123456789asd;FMTTYPE=text/html;SIZE=4480;X-JMAP-REL=enclosure:http://jmap.io/spec.html#calendar-events +ATTACH:http://example.com/some.url +ATTACH;X-JMAP-ID=describedby-attach;X-JMAP-REL=describedby:http://describedby/attach +URL;X-JMAP-ID=describedby-url:http://describedby/url +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/location-newline.ics b/cassandane/data/icalendar/location-newline.ics new file mode 100644 index 0000000000..eaf924af50 --- /dev/null +++ b/cassandane/data/icalendar/location-newline.ics @@ -0,0 +1,36 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.15.6//EN +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:America/New_York +BEGIN:DAYLIGHT +TZOFFSETFROM:-0500 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +DTSTART:20070311T020000 +TZNAME:EDT +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0400 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +DTSTART:20071104T020000 +TZNAME:EST +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +TRANSP:OPAQUE +DTEND;TZID=America/New_York:20200915T100000 +X-APPLE-STRUCTURED-LOCATION;VALUE=URI; + X-TITLE="xyz\nxyz":geo:39.946146,-75.164682 +UID:3FB312C0-48A8-4589-8EB8-44165EEB79B7 +DTSTAMP:20200915T135306Z +LOCATION:xyz\nxyz +SEQUENCE:1 +SUMMARY:test +LAST-MODIFIED:20200915T135301Z +CREATED:20200915T135254Z +DTSTART;TZID=America/New_York:20200915T090000 +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/locations-apple.ics b/cassandane/data/icalendar/locations-apple.ics new file mode 100644 index 0000000000..d3f3585c3f --- /dev/null +++ b/cassandane/data/icalendar/locations-apple.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:47cd939c-90c2-4efe-a8dd-b704435667a2 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:Remember the yep. +LOCATION:a place in Vienna +X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-APPLE-RADIUS=14140.1607181516;X-TITLE="a place in Vienna":geo:48.208304,16.371602 +SEQUENCE:9 +SUMMARY;LANGUAGE=en:Yep +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/locations-conference.ics b/cassandane/data/icalendar/locations-conference.ics new file mode 100644 index 0000000000..8f6c81b1f4 --- /dev/null +++ b/cassandane/data/icalendar/locations-conference.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:2e531c37-0862-491b-ae16-111de073740c +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:Remember the yep. +CONFERENCE;X-JMAP-ID=loc1;VALUE=URI;FEATURE=PHONE,MODERATOR;LABEL=Moderator dial-in:tel:+123451 +CONFERENCE;X-JMAP-ID=loc2;VALUE=URI;FEATURE=CHAT;LABEL=Chat room:xmpp:chat123@conference.example.com +SEQUENCE:9 +SUMMARY;LANGUAGE=en:Yep +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/locations-geo.ics b/cassandane/data/icalendar/locations-geo.ics new file mode 100644 index 0000000000..7063000f74 --- /dev/null +++ b/cassandane/data/icalendar/locations-geo.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:1c42cba5-4b46-42df-8241-8d3619a1e3bf +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:Remember the yep. +GEO:37.38601;-122.08290 +SEQUENCE:9 +SUMMARY;LANGUAGE=en:Yep +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/locations-uri.ics b/cassandane/data/icalendar/locations-uri.ics new file mode 100644 index 0000000000..75e50f1edc --- /dev/null +++ b/cassandane/data/icalendar/locations-uri.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:79c6ded1-630f-44b6-9367-98650fe81277 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:Remember the yep. +LOCATION;ALTREP="skype:foo":On planet Earth +SEQUENCE:9 +SUMMARY;LANGUAGE=en:Yep +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/locations.ics b/cassandane/data/icalendar/locations.ics new file mode 100644 index 0000000000..3661728f3b --- /dev/null +++ b/cassandane/data/icalendar/locations.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:2e531c37-0862-491b-ae16-111de073740c +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:Remember the yep. +LOCATION:A location with a comma\,\nand a newline. +SEQUENCE:9 +SUMMARY;LANGUAGE=en:Yep +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/ms_timezone.ics b/cassandane/data/icalendar/ms_timezone.ics new file mode 100644 index 0000000000..348714c31c --- /dev/null +++ b/cassandane/data/icalendar/ms_timezone.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Eastern Standard Time:20160928T130000 +DTEND;TZID=Eastern Standard Time:20160928T150000 +SUMMARY:Foo +UID:3edfa67a-9e92-4ed7-b0cf-c07ad63ab8f8 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/organizer.ics b/cassandane/data/icalendar/organizer.ics new file mode 100644 index 0000000000..afd3b99518 --- /dev/null +++ b/cassandane/data/icalendar/organizer.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:foo +LAST-MODIFIED:20150928T132434Z +ATTENDEE:mailto:attendee@local +ORGANIZER;CN="Organizer":mailto:organizer@local +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/organizer_bogusuri.ics b/cassandane/data/icalendar/organizer_bogusuri.ics new file mode 100644 index 0000000000..9a41f59693 --- /dev/null +++ b/cassandane/data/icalendar/organizer_bogusuri.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:foo +LAST-MODIFIED:20150928T132434Z +ATTENDEE:mailto:attendee@local +ATTENDEE;CN="Organizer":/foo-bar/principal/ +ORGANIZER:/foo-bar/principal/ +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/organizer_noattendees.ics b/cassandane/data/icalendar/organizer_noattendees.ics new file mode 100644 index 0000000000..80d8a0c9d9 --- /dev/null +++ b/cassandane/data/icalendar/organizer_noattendees.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:foo +LAST-MODIFIED:20150928T132434Z +ORGANIZER;CN="Organizer":mailto:organizer@local +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/organizermailto.ics b/cassandane/data/icalendar/organizermailto.ics new file mode 100644 index 0000000000..f1ffddeb7a --- /dev/null +++ b/cassandane/data/icalendar/organizermailto.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:foo +LAST-MODIFIED:20150928T132434Z +ATTENDEE;CN="Attendee":mailto:attendee@local +ATTENDEE;CN="Organizer":mailto:organizer@local +ORGANIZER:MAILTO:organizer@local +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/participants.ics b/cassandane/data/icalendar/participants.ics new file mode 100644 index 0000000000..36df7e4bee --- /dev/null +++ b/cassandane/data/icalendar/participants.ics @@ -0,0 +1,24 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:Yep +LAST-MODIFIED:20150928T132434Z +ATTENDEE;ROLE=OPT-PARTICIPANT;CN=Homer Simpson;PARTSTAT=ACCEPTED;X-JMAP-LOCATIONID="loc1":mailto:homer@example.com +ATTENDEE;X-JMAP-ID="carl";X-SEQUENCE=3;X-DTSTAMP=20170102T030405Z;PARTSTAT=TENTATIVE;DELEGATED-FROM="mailto:lenny@example.com";CN=Carl Carlson:mailto:carl@example.com +ATTENDEE;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:carl@example.com";CN=Lenny Leonard:mailto:lenny@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=DECLINED;X-DTSTAMP=20150929T144423Z;CN=Larry Burns;MEMBER="mailto:projectA@example.com":mailto:larry@example.com +ORGANIZER;CN="Monty Burns":mailto:smithers@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Monty Burns:mailto:smithers@example.com +STATUS:TENTATIVE +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/privacy.ics b/cassandane/data/icalendar/privacy.ics new file mode 100644 index 0000000000..505bfbaa1a --- /dev/null +++ b/cassandane/data/icalendar/privacy.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +CLASS:CONFIDENTIAL +X-JMAP-PRIVACY:PRIVATE +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +RELATED-TO:58ADE31-broken-UID +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:double yo +SEQUENCE:9 +SUMMARY;LANGUAGE=en:yo +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/rdate_period.ics b/cassandane/data/icalendar/rdate_period.ics new file mode 100644 index 0000000000..3f01f6cca9 --- /dev/null +++ b/cassandane/data/icalendar/rdate_period.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160101T130000Z +DURATION:PT1H +RRULE:RSCALE=GREGORIAN;SKIP=OMIT;FREQ=MONTHLY +RDATE;VALUE=PERIOD:20160304T150000Z/20160304T160000Z +UID:89eee195-600b-423b-b3a6-52b3a420e556 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:description +SUMMARY:foo +X-JMAP-TRANSLATION;LANGUAGE=de;X-JMAP-PROP=title:Titel +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/recurrence.ics b/cassandane/data/icalendar/recurrence.ics new file mode 100644 index 0000000000..25669713e2 --- /dev/null +++ b/cassandane/data/icalendar/recurrence.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +RRULE:RSCALE=GREGORIAN;SKIP=OMIT;FREQ=MONTHLY;BYDAY=+2MO,TU,-3SU,+1MO,-2TH,-1SA +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:8d9b581d-6f64-4f9f-89ce-8f9c09d54911 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY: +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/recurrenceid_utc.ics b/cassandane/data/icalendar/recurrenceid_utc.ics new file mode 100644 index 0000000000..93bf868866 --- /dev/null +++ b/cassandane/data/icalendar/recurrenceid_utc.ics @@ -0,0 +1,50 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//foo//NONSGML bar 1.0//EN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +TZURL:http://tzurl.org/zoneinfo-outlook/Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CLASS:PUBLIC +DESCRIPTION: +DTEND;TZID=Europe/Berlin:20190212T103000 +DTSTAMP:20190219T125922Z +DTSTART;TZID=Europe/Berlin:20190212T100000 +PRIORITY:5 +RRULE:FREQ=WEEKLY;COUNT=10 +SEQUENCE:0 +SUMMARY:main +TRANSP:OPAQUE +UID:f6f3db8e-1d6b-4fbf-9a31-23a0ccfc9e13 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +END:VEVENT +BEGIN:VEVENT +CLASS:PUBLIC +DESCRIPTION: +DTEND;TZID=Europe/Berlin:20190219T103000 +DTSTAMP:20190219T125922Z +DTSTART;TZID=Europe/Berlin:20190219T100000 +PRIORITY:5 +RECURRENCE-ID:20190219T090000Z +SUMMARY:override +TRANSP:OPAQUE +UID:f6f3db8e-1d6b-4fbf-9a31-23a0ccfc9e13 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/recurrenceoverrides-mixed-datetypes.ics b/cassandane/data/icalendar/recurrenceoverrides-mixed-datetypes.ics new file mode 100644 index 0000000000..9e25e8d97b --- /dev/null +++ b/cassandane/data/icalendar/recurrenceoverrides-mixed-datetypes.ics @@ -0,0 +1,25 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;VALUE=DATE:20160101 +DURATION:P1D +RRULE:RSCALE=GREGORIAN;SKIP=OMIT;FREQ=MONTHLY +UID:89eee195-600b-423b-b3a6-52b3a420e556 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:event +LAST-MODIFIED:20150928T132434Z +END:VEVENT +BEGIN:VEVENT +UID:89eee195-600b-423b-b3a6-52b3a420e556 +SEQUENCE:0 +RECURRENCE-ID:20180501 +SUMMARY:event +DTSTART;TZID=Europe/Vienna:20180502T170000 +DURATION:PT1H +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/recurrenceoverrides.ics b/cassandane/data/icalendar/recurrenceoverrides.ics new file mode 100644 index 0000000000..f632721342 --- /dev/null +++ b/cassandane/data/icalendar/recurrenceoverrides.ics @@ -0,0 +1,56 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160101T130000 +DURATION:PT1H +RRULE:RSCALE=GREGORIAN;SKIP=OMIT;FREQ=MONTHLY +RDATE;TZID=Europe/Vienna:20161224T200000 +RDATE;TZID=Europe/Vienna;VALUE=PERIOD:20160304T160000/20160304T170000 +EXDATE;TZID=Europe/Vienna:20160201T130000 +UID:89eee195-600b-423b-b3a6-52b3a420e556 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:description +SUMMARY:foo +LAST-MODIFIED:20150928T132434Z +END:VEVENT +BEGIN:VEVENT +TRANSP:OPAQUE +UID:89eee195-600b-423b-b3a6-52b3a420e556 +SEQUENCE:0 +RECURRENCE-ID;TZID=Europe/Vienna:20160501T130000 +SUMMARY:foobarbazbla +DESCRIPTION:description +DTSTART;TZID=Europe/Vienna:20160501T170000 +DURATION:PT2H +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +BEGIN:VALARM +UID:89eee195-600b-423b-b3a6-52b3a420e556-alarmuid +ACTION:DISPLAY +DESCRIPTION:yo +TRIGGER:-PT5M +END:VALARM +END:VEVENT +BEGIN:VEVENT +TRANSP:OPAQUE +UID:89eee195-600b-423b-b3a6-52b3a420e556 +SEQUENCE:0 +RECURRENCE-ID;TZID=Europe/Vienna:20160901T130000 +SUMMARY:foobarbazblabam +DESCRIPTION:description +DTSTART;TZID=Europe/Vienna:20160901T130000 +DURATION:PT1H +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +BEGIN:VALARM +UID:89eee195-600b-423b-b3a6-52b3a420e556-alarmuid +ACTION:DISPLAY +DESCRIPTION:yo +TRIGGER:-PT5M +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/cassandane/data/icalendar/relatedto.ics b/cassandane/data/icalendar/relatedto.ics new file mode 100644 index 0000000000..f1e2127fc7 --- /dev/null +++ b/cassandane/data/icalendar/relatedto.ics @@ -0,0 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +RELATED-TO;RELTYPE=FIRST:58ADE31-001 +RELATED-TO;RELTYPE=NEXT:58ADE31-003 +RELATED-TO;RELTYPE=X-Unknown1;RELTYPE=X-Unknown2:foo +RELATED-TO:bar +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:double yo +SEQUENCE:9 +SUMMARY;LANGUAGE=en:yo +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/rscale.ics b/cassandane/data/icalendar/rscale.ics new file mode 100644 index 0000000000..304a90f5d0 --- /dev/null +++ b/cassandane/data/icalendar/rscale.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:20140208 +DURATION:P1D +RRULE:RSCALE=HEBREW;FREQ=YEARLY;BYMONTH=5L;BYMONTHDAY=8;SKIP=FORWARD +SUMMARY:Some day in Adar I +UID:8fd7993e-a38e-401e-a8af-540d53fd0078 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/rsvpsequence.ics b/cassandane/data/icalendar/rsvpsequence.ics new file mode 100644 index 0000000000..22b3104d0c --- /dev/null +++ b/cassandane/data/icalendar/rsvpsequence.ics @@ -0,0 +1,42 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=3 +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:test +SEQUENCE:1 +LAST-MODIFIED:20150928T132434Z +ATTENDEE;X-JMAP-ID=me;RSVP=yes:mailto:cassandane@example.com +ORGANIZER:mailto:organizer@local +BEGIN:VALARM +UID:alert1 +TRIGGER:-PT5M +ACTION:DISPLAY +SUMMARY:alert +END:VALARM +END:VEVENT +BEGIN:VEVENT +RECURRENCE-ID;TZID=Europe/Vienna:20160929T160000 +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160929T180000 +DURATION:PT1H +UID:cbbd08c0-8e14-44e7-a6f2-b41faacb832d +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:test +SEQUENCE:1 +LAST-MODIFIED:20150928T132434Z +ATTENDEE;X-JMAP-ID=me;RSVP=yes:mailto:cassandane@example.com +ORGANIZER:mailto:organizer@local +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/simple.ics b/cassandane/data/icalendar/simple.ics new file mode 100644 index 0000000000..9bcf28e602 --- /dev/null +++ b/cassandane/data/icalendar/simple.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:double yo +PRIORITY:3 +COLOR:turquoise +SEQUENCE:9 +SUMMARY;LANGUAGE=en:yo +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/translations.ics b/cassandane/data/icalendar/translations.ics new file mode 100644 index 0000000000..c673b2ad1a --- /dev/null +++ b/cassandane/data/icalendar/translations.ics @@ -0,0 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:2304fc60-a8cd-41b6-957b-4f9b4a15f3e2 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:description. +SUMMARY;LANGUAGE=en:title +LOCATION;X-JMAP-ID=loc1:On planet Earth +X-JMAP-TRANSLATION;LANGUAGE=de;X-JMAP-PROP=title:Titel +X-JMAP-TRANSLATION;LANGUAGE=de;X-JMAP-PROP=description:Beschreibung +X-JMAP-TRANSLATION;LANGUAGE=de;X-JMAP-PROP=locations.name;X-JMAP-ID=loc1:Am Planet Erde +X-JMAP-TRANSLATION;LANGUAGE=de;X-JMAP-PROP=links.title;X-JMAP-ID=loc1:No such link +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + diff --git a/cassandane/data/icalendar/utctime-with-tzid.ics b/cassandane/data/icalendar/utctime-with-tzid.ics new file mode 100644 index 0000000000..ffa5f262e2 --- /dev/null +++ b/cassandane/data/icalendar/utctime-with-tzid.ics @@ -0,0 +1,15 @@ +BEGIN:VCALENDAR +PRODID:-//Test +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTAMP;TZID=Europe/Vienna:20191214T105622Z +DTSTART;TZID=Europe/Vienna:20191219T180000Z +DTEND;TZID=Test:20191219T202000Z +UID:beac8312-f3b9-4bd8-9075-de6fdbef4832 +SUMMARY:test +CREATED:20191214T105622Z +LAST-MODIFIED:20191214T105622Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR diff --git a/docsrc/_static/logo.gif b/cassandane/data/logo.gif similarity index 100% rename from docsrc/_static/logo.gif rename to cassandane/data/logo.gif diff --git a/cassandane/data/mime/base64-body.eml b/cassandane/data/mime/base64-body.eml new file mode 100644 index 0000000000..7b3577fbeb --- /dev/null +++ b/cassandane/data/mime/base64-body.eml @@ -0,0 +1,14 @@ +From: Private Bar +To: 'Captain Foo' +Subject: RE: Hello +Date: Thu, 9 May 2019 08:35:26 +0000 +Message-ID: +References: <51f46eb4-8bef-4539-82b3-db4c08c3c885@example.com> +In-Reply-To: <51f46eb4-8bef-4539-82b3-db4c08c3c885@example.com> +Accept-Language: en-GB, en-US +Content-Language: en-US +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: base64 +MIME-Version: 1.0 + +SEVFRUVFRUVFTE8gV09STERzCg== diff --git a/cassandane/data/mime/headers-koi8r.bin b/cassandane/data/mime/headers-koi8r.bin new file mode 100644 index 0000000000..f8099be9a2 --- /dev/null +++ b/cassandane/data/mime/headers-koi8r.bin @@ -0,0 +1,8 @@ +From: " " +To: test2@local +Subject: - . +Date: Wed, 27 Feb 2019 13:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain + +This is a test email. diff --git a/cassandane/data/mime/headers-utf8.bin b/cassandane/data/mime/headers-utf8.bin new file mode 100644 index 0000000000..d2c9ea4302 --- /dev/null +++ b/cassandane/data/mime/headers-utf8.bin @@ -0,0 +1,8 @@ +From: "Фёдор Михайлович Достоевский" +To: test2@local +Subject: Москва - столица России. +Date: Wed, 27 Feb 2019 13:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain + +This is a test email. diff --git a/cassandane/data/mime/iso-2022-jp.eml b/cassandane/data/mime/iso-2022-jp.eml new file mode 100644 index 0000000000..37f990a9f1 --- /dev/null +++ b/cassandane/data/mime/iso-2022-jp.eml @@ -0,0 +1,8 @@ +From: Private Bar +To: 'Captain Foo' +Subject: RE: Hello +Date: Thu, 9 May 2019 08:35:26 +0000 +Content-Type: text/plain +MIME-Version: 1.0 + +$B%7%K%"%=%U%H%&%'%"%(%s%8%K%"(B diff --git a/cassandane/data/mime/issue2918.eml b/cassandane/data/mime/issue2918.eml new file mode 100644 index 0000000000..216945a89e --- /dev/null +++ b/cassandane/data/mime/issue2918.eml @@ -0,0 +1,43 @@ +Date: Tue, 3 Dec 2019 12:46:14 -0500 +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="15753951744.Ccd4.3440" +Content-Transfer-Encoding: 7bit + + +--15753951744.Ccd4.3440 +Date: Tue, 3 Dec 2019 12:46:14 -0500 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="15753951741.4ED41d.3440" +Content-Transfer-Encoding: 7bit + + +--15753951741.4ED41d.3440 +Date: Tue, 3 Dec 2019 12:46:14 -0500 +MIME-Version: 1.0 +Content-Type: text/plain + +a +--15753951741.4ED41d.3440 +Date: Tue, 3 Dec 2019 12:46:14 -0500 +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="15753951740.aA49.3440" +Content-Transfer-Encoding: 7bit + + +--15753951740.aA49.3440 +Date: Tue, 3 Dec 2019 12:46:14 -0500 +MIME-Version: 1.0 +Content-Type: text/plain + +b +--15753951740.aA49.3440 +Date: Tue, 3 Dec 2019 12:46:14 -0500 +MIME-Version: 1.0 +Content-Type: text/html + +c +--15753951740.aA49.3440-- + +--15753951741.4ED41d.3440-- + +--15753951744.Ccd4.3440-- diff --git a/cassandane/data/mime/msg1.eml b/cassandane/data/mime/msg1.eml new file mode 100644 index 0000000000..c3a8bd836c --- /dev/null +++ b/cassandane/data/mime/msg1.eml @@ -0,0 +1,10 @@ +From: from1@server.example.int +To: zhivko@mailtemi.com +Message-ID: +Subject: message 1 +Date: Thu, 10 May 2018 4:11:45 +0000 +MIME-Version: 1.0 +Content-Type: text/plain +Content-Transfer-Encoding: quoted-printable + +message 1 body \ No newline at end of file diff --git a/cassandane/data/mime/repair_acl.eml b/cassandane/data/mime/repair_acl.eml new file mode 100644 index 0000000000..998360446d --- /dev/null +++ b/cassandane/data/mime/repair_acl.eml @@ -0,0 +1,21 @@ +From "someone@example.com" Wed Jun 8 19:27:14 2022 +Return-Path: +Date: Wed, 08 Jun 2022 15:26:53 -0400 +From: +To: asdasd@example.com +Message-ID: <45c35132-221c-40e2-bd37-62eb4de329be@example.com> +Subject: Let's try this out +Mime-Version: 1.0 +Content-Type: multipart/alternative; + boundary=b647b28f23304467afa2c0b3dcca48ac +Content-Transfer-Encoding: 7bit + +--b647b28f23304467afa2c0b3dcca48ac +Content-Type: text/plain + +Wow so cool +--b647b28f23304467afa2c0b3dcca48ac +Content-Type: text/html + +
Wow so cool
+--b647b28f23304467afa2c0b3dcca48ac-- diff --git a/cassandane/data/mime/simple.eml b/cassandane/data/mime/simple.eml new file mode 100644 index 0000000000..68b92c7018 --- /dev/null +++ b/cassandane/data/mime/simple.eml @@ -0,0 +1,8 @@ +From: test1@local> +To: test2@local +Subject: Test subject +Date: Wed, 27 Apr 2019 13:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain + +This is a test email. diff --git a/cassandane/data/mime/unicodefdfx.eml b/cassandane/data/mime/unicodefdfx.eml new file mode 100644 index 0000000000..ee86528b25 --- /dev/null +++ b/cassandane/data/mime/unicodefdfx.eml @@ -0,0 +1,9 @@ +From: test1@local> +To: test2@local +Subject: Test subject +Date: Wed, 27 Apr 2019 13:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: base64 + +77e677e677e677e677e677e677e677e677e6 diff --git a/cassandane/data/mime/utf8-base64-replacement.eml b/cassandane/data/mime/utf8-base64-replacement.eml new file mode 100644 index 0000000000..7263b6934e --- /dev/null +++ b/cassandane/data/mime/utf8-base64-replacement.eml @@ -0,0 +1,9 @@ +From: test1@local> +To: test2@local +Subject: Test subject +Date: Wed, 27 Apr 2019 13:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain;charset=utf-8 +Content-Transfer-Encoding: base64 + +SGVsbG8g8J+YgCwgV29ybGQg77+9ICEK diff --git a/cassandane/data/mime/utf8-domain.bin b/cassandane/data/mime/utf8-domain.bin new file mode 100644 index 0000000000..e85afc1052 --- /dev/null +++ b/cassandane/data/mime/utf8-domain.bin @@ -0,0 +1,9 @@ +From: J. Besteiro +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain;charset=us-ascii +Content-Transfer-Encoding: 7bit + +hello diff --git a/cassandane/data/old-mailboxes/version10.tar.gz b/cassandane/data/old-mailboxes/version10.tar.gz new file mode 100644 index 0000000000..ecdefbfb0e Binary files /dev/null and b/cassandane/data/old-mailboxes/version10.tar.gz differ diff --git a/cassandane/data/old-mailboxes/version12.tar.gz b/cassandane/data/old-mailboxes/version12.tar.gz new file mode 100644 index 0000000000..ce253d4ad6 Binary files /dev/null and b/cassandane/data/old-mailboxes/version12.tar.gz differ diff --git a/cassandane/data/old-mailboxes/version9.tar.gz b/cassandane/data/old-mailboxes/version9.tar.gz new file mode 100644 index 0000000000..1384016317 Binary files /dev/null and b/cassandane/data/old-mailboxes/version9.tar.gz differ diff --git a/cassandane/data/vcard/invalid-utf8.eml b/cassandane/data/vcard/invalid-utf8.eml new file mode 100644 index 0000000000..b12b36e188 --- /dev/null +++ b/cassandane/data/vcard/invalid-utf8.eml @@ -0,0 +1,20 @@ +User-Agent: cassandanetest +From: +Subject: Test +Date: Fri, 30 Oct 2020 12:19:37 -0400 +Message-ID: <123456789@local> +Content-Type: text/vcard; version=3.0; charset=utf-8 +Content-Transfer-Encoding: 8bit +Content-Disposition: attachment; + filename="123456789.vcf" +MIME-Version: 1.0 + +BEGIN:VCARD +VERSION:3.0 +PRODID:-//CyrusIMAP.org//Cyrus//EN +UID:123456789 +EMAIL;TYPE=work:benot@local +FN:Test +N:Test;;;; +REV:20200708T081725Z +END:VCARD diff --git a/cassandane/doc/README.deps b/cassandane/doc/README.deps new file mode 100644 index 0000000000..b7d4588a57 --- /dev/null +++ b/cassandane/doc/README.deps @@ -0,0 +1,182 @@ +SOFTWARE DEPENDENCIES + +Cassandane needs the following software to work: + + * LWP::UserAgent + * Plack + * Test::TCP + Used for standing up local test HTTP servers for testing various bits of + Cyrus callout capabilities + + * Mail::IMAPTalk + This is a fully featured IMAP client package, available on CPAN. + + There is an Ubuntu package for this, libmail-imaptalk-perl. + + * Mail::JMAPTalk + * Net::DAVTalk + * Net::CalDAVTalk + * Net::CardDAVTalk + + Similar idea to Mail::IMAPTalk, but for JMAP/DAV/CalDAV/CardDAV + respectively. These are all available on CPAN. + + * Encode::IMAPUTF7 + Needed by Mail::IMAPTalk + CPAN package http://search.cpan.org/perldoc?Encode::IMAPUTF7 + Ubuntu package libencode-imaputf7-perl + On RH/CentOS, may need to install perl-Test-NoWarnings to build from source + + * URI, URI::Escape + Handles URIs. + CPAN package at http://search.cpan.org/dist/URI/ + Ubuntu package liburi-perl. + RH/CentOS package perl-URI + + * Digest::SHA + Does the SHA1 message digest algorithm. + CPAN package http://search.cpan.org/dist/Digest-SHA/ + Ubuntu package libdigest-sha-perl + RH/CentOS package perl-Digest-SHA + + * DateTime + Does time manipulation. + CPAN package http://search.cpan.org/dist/DateTime/ + Ubuntu package libdatetime-perl + RH/CentOS package perl-DateTime + + * BSD::Resource + Allows Cyrus to generate core dumps at all + CPAN package http://search.cpan.org/dist/BSD-Resource/ + Ubuntu package libbsd-resource-perl + RH/CentOS package perl-BSD-Resource + + * Test::Unit + Unit testing framework for Perl + CPAN package http://search.cpan.org/dist/Test-Unit/ + Ubuntu package libtest-unit-perl + RH/CentOS package perl-Test-Unit + + * (Cassandane has it's own copy of this code for now) + Test::Unit::Runner::XML + Addition to Test::Unit which outputs in jUnit's XML + format, for integration with Jenkins CI. + CPAN package http://search.cpan.org/dist/Test-Unit-Runner-Xml/ + No Ubuntu package (sorry) - but install depends on: + libxml-generator-perl libxml-xpath-perl + RH/CentOS package perl-Test-Unit-Runner-Xml + + * XML::Generator + Needed by Test::Unit::Runner::XML + CPAN package http://search.cpan.org/dist/XML-Generator/ + Ubuntu package libxml-generator-perl + RH/CentOS package perl-XML-Generator + + * Clone + Provides a Perl structure deep-clone operation + CPAN package http://search.cpan.org/dist/Clone/ + Ubuntu package libclone-perl + RH/CentOS package perl-Clone + + * Config::IniFiles + Read simple .INI style config files. + CPAN package http://search.cpan.org/~shlomif/Config-IniFiles-2.68/ + Ubuntu package libconfig-inifiles-perl + RH/CentOS package perl-Config-IniFiles + + * News::NNTPClient + Perl 5 module to talk to NNTP (RFC977) server + CPAN package http://search.cpan.org/~rva/NNTPClient-0.37/ + Ubuntu package libnews-nntpclient-perl + RH/CentOS package perl-NNTPClient + + * IO::Socket::INET6 + Object interface for AF_INET|AF_INET6 domain sockets + CPAN package http://search.cpan.org/~shlomif/IO-Socket-INET6-2.69/lib/IO/Socket/INET6.pm + Ubuntu package libio-socket-inet6-perl + RH/CentOS package perl-IO-Socket-INET6 + + * IO::Socket::SSL + Object interface for TLS sockets + CPAN package https://metacpan.org/pod/IO::Socket::SSL + Ubuntu package libio-socket-ssl-perl + RH/CentOS package perl-IO-Socket-SSL + + * Net::Server + Extensible, general Perl server engine + Ubuntu package libnet-server-perl + RH/CentOS package perl-Net-Server + + * Unix::Syslog + Perl interface to the UNIX syslog(3) calls + Ubuntu package libunix-syslog-perl + RH/CentOS package perl-Unix-Syslog + + * File::chdir + A more sensible way to change directories + Ubuntu package libfile-chdir-perl + RH/CentOS package perl-File-chdir + + * IO::Stringy + I/O on in-core objects like strings and arrays + Ubuntu package libio-stringy-perl + + * Math::Int64 + 64 bit integer module for calculating conversation IDs + cpan Math::Int64 + + * Net::LDAP::Server + Base module for implementing LDAP Server functionality in perl + Available on CPAN, Debian package libnet-ldap-server-perl + + * DBD::SQLite + Self-contained RDBMS in a DBI Driver + Debian/Ubuntu package libdbd-sqlite3-perl + + * Digest::CRC + Calculates various types of CRC sums + Debian/Ubuntu package libdigest-crc-perl + + * AnyEvent + * Convert::Base64 + * DateTime::Format::ISO8601 + * String::CRC32 + * XML::DOM + * XML::Simple + These are available from CPAN + +Debian/Ubuntu copypasta: + sudo apt-get -y install liburi-perl libdigest-sha-perl \ + libdatetime-perl libbsd-resource-perl libtest-unit-perl \ + libxml-generator-perl libxml-xpath-perl libclone-perl \ + libencode-imaputf7-perl libconfig-inifiles-perl \ + libio-socket-inet6-perl libio-socket-ssl-perl libnet-server-perl \ + libunix-syslog-perl libnews-nntpclient-perl \ + libfile-chdir-perl libio-stringy-perl \ + libanyevent-perl libjson-perl libjson-xs-perl \ + libfile-libmagic-perl libtest-xml-perl \ + libdatetime-format-iso8601-perl libdata-uuid-perl \ + libmime-types-perl libtext-levenshteinxs-perl \ + libdatetime-format-ical-perl libuniversal-require-perl \ + libtest-nowarnings-perl libtest-longstring-perl \ + libtest-warn-perl libnet-ldap-server-perl \ + libdbd-sqlite3-perl libdigest-crc-perl libxml-simple-perl + + sudo cpan Math::Int64 Mail::JMAPTalk Mail::IMAPTalk \ + Net::CalDAVTalk Net::CardDAVTalk String::CRC32 \ + IO::File::Lockable + +Fedora copypasta: + # there's heaps more to add here... needs to be tested + # XXX 2019-07-01: this is stale, someone with fedora please update! + dnf install perl-String-CRC32 perl-File-chdir perl-Unix-Syslog \ + perl-Net-Server perl-Clone perl-Test-NoWarnings perl-URI \ + perl-Digest-SHA perl-BSD-Resource perl-Test-Unit \ + perl-Test-Unit-Runner-Xml perl-XML-Generator perl-Config-IniFiles \ + perl-NNTPClient perl-IO-Socket-INET6 perl-Net-Server \ + perl-Unix-Syslog perl-File-chdir perl-Math-Int64 \ + perl-Mail-IMAPTalk perl-String-CRC32 perl-File-chdir \ + perl-Unix-Syslog perl-Net-Server perl-Net-CalDAVTalk \ + perl-Net-CardDAVTalk perl-Mail-JMAPTalk perl-AnyEvent \ + perl-File-Slurp perl-Pod-POM perl-experimental + diff --git a/cassandane/doc/adding_tests.txt b/cassandane/doc/adding_tests.txt new file mode 100644 index 0000000000..e4dd2e2977 --- /dev/null +++ b/cassandane/doc/adding_tests.txt @@ -0,0 +1,226 @@ +Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights reserved. + +This document describes how to add new tests to Cassandane. + +Source Structure +---------------- + +Test sources are Perl modules located in two directories under the +Cassandane main directory. + +Cassandane/Test/ + contains tests which exercise the Cassandane core classes, + i.e. self-tests. + +Cassandane/Cyrus/ + contains tests which exercise Cyrus. + +Cassandane uses the Perl Test::Unit framework. For more detailed +information consult the Test::Unit documentation. Each Cassandane test +module derives from the Cassandane::Unit::TestCase class, and is logically a +group of related tests. The module can define the following methods. + +new + Constructor, creates and returns a new TestCase. For Cassandane + tests, this will typically create Cassandane::Config and + Cassandane::Instance objects (see later). + +set_up + Optional method which is called by the framework before every + test is run. It has no return value and should 'die' if anything + goes wrong. For Cassandane tests, this will typically start an + Instance (see later). + +tear_down + Optional method which is called by the framework after every + test is run. It has no return value and should 'die' if anything + goes wrong. For Cassandane tests, this will typically stop an + Instance (see later). + +test_foo + Defines a test named "foo". It has no return value and should + either call $self->assert(boolean) or 'die' if anything goes wrong. + Multiple test_whatever methods can be defined in a module. + +Helper Classes +-------------- + +Cassandane contains a number of helper classes designed to make easier +the job of writing tests that access Cyrus. This section provides a +brief overview. + +Cassandane::Instance + Encapsulates an instance of Cyrus, with it's own directory + structure, configuration files, master process, and one or more + services such as imapd. + + To create a default Instance: + + my $instance = Cassandane::Instance->new(); + + To create an Instance with a non-default parameter in the + configuration file: + + my $config = Cassandane::Config->default()->clone(); + $config->set(conversations => 'on'); + my $instance = Cassandane::Instance->new(config => $config); + + By default the Instance has no services, but just runs the master + daemon. This is rarely a useful setup. To add a service, in this case + the imapd daemon: + + $instance->add_service(name => 'imap'); + + Starting the Instance creates the directory structure and + configuration files, then starts the master process and waits for + all the defined services to be running (as reported by netstat). + + $instance->start(); + + Stopping the instance kills all master process and all services + as gracefully as possible, and waits for them to die. + + $instance->stop(); + + Interactions with services are handled via one of the classed + derived from the abstract Cassandane::MessageStore class. To create + a store for a paerticular service in an Instance: + + $store = $instance->get_service('imap')->create_store(); + + For the imapd service in particular, Cassandane::IMAPMessageStore + wraps a Mail::IMAPTalk object which can be retrieved thus: + + my $imaptalk = $store->get_client(); + +Cassandane::Config + Encapsulates the configuration information present in an imapd.conf + format configuration file. Config objects are useful for passing + to the Cassandane::Instance constructor to set up Cyrus instances + with particular configuration options. + + The Config module keeps a global Config object. This object should + not be modified directly but should be cloned (see below). To get + the default object: + + my $config = Cassandane::Config->default(); + + Configs use a lightweight copy-on-write cloning mechanism. The + clone() method can be used to create a new Config object based on a + parent Config object. The child remembers it's parent. + + my $child_config = $parent_config->clone(); + + The set() and get() methods can be used to set and get key-value + pairs from a Config object. The set() method always works on the + object itself, but get() will walk back up the ancestry chain until + it finds a matching key. + + $config->set(conversations => 'on'); + $config->set(foo => '1', bar => '2'); + + my $foo = $config->get('foo'); + + The typical use for a Config object is: + + my $config = Cassandane::Config->default()->clone(); + $config->set(conversations => 'on'); + my $instance = Cassandane::Instance->new(config => $config); + +Cassandane::Message + Encapsulates an RFC822 message, plus a set of non-RFC822 attributes + expressed as key-value pairs. Message objects are returned from + MessageStore->read_message() and Generator->generate(). + + To create a new default Message object + + my $msg = Cassandane::Message->new(); + + To create a Message object read from a file handle + + my $fh = ... + my $msg = Cassandane::Message->new(fh => $fh); + + To get all the RFC822 headers of a given name, as a reference + to an array of strings: + + my $values = $msg->get_headers('Received'); + + To get an RFC822 header and enforce that there is only a single + header of that name, use + + my $value = $msg->get_header('From'); + + To set an RFC822 header, replacing any previous headers of + the same name: + + $msg->set_headers('From', 'Foo Bar '); + + To set multiple RFC822 headers with the same name, replacing + any previous headers of that name: + + my @values = ('baz', 'quux'); + $msg->set_headers('Received', @values); + + To add an RFC822 header: + + $msg->add_header('Subject', 'Hello World'); + + To set the RFC822 body (as one big string) + + $msg->set_body('....one enormous string...'); + + To get a non-RFC822 attribute (this may have be placed on the message + as a side effect of it's creation e.g. during an IMAP FETCH command): + + my $cid = $msg->get_attribute('cid); + +Cassandane::Generator + Creates new Message objects with a number of useful default values + based on random words. Has a constructor and a single function + + my $gen = Cassandane::Generator->new(); + my $msg = $gen->generate(); + + By default, messages will have values for the RFC822 body and the + following headers: + + Return-Path + Received + MIME-Version + 1.0 + Content-Type + text/plain; charset="us-ascii" + Content-Transfer-Encoding + 7bit + Subject + From + Message-ID + Date + To + X-Cassandane-Unique + a string of hex digits which uniquely defines each created + message. Unlike the Cyrus GUID concept, two Message objects + generated at different times which happen to have the same + headers and body will have different X-Cassandane-Unique values. + This can be useful for testing the identity of messages. + + Some of these can be overridden by providing options to generate() + + my $msg = $gen->generate(subject => "Hello world"); + + The following options can be used: + + date + a DateTime object + from + a Cassandane::Address object + subject + a string + to + a Cassandane::Address object + messageid + a string + +TODO: document MessageStore, IMAPMessageStore, POP3MessageStore, and +ThreadedGenerator. diff --git a/cassandane/doc/running_tests.txt b/cassandane/doc/running_tests.txt new file mode 100644 index 0000000000..e40e9c10f2 --- /dev/null +++ b/cassandane/doc/running_tests.txt @@ -0,0 +1,122 @@ +Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights reserved. + +This document describes how to run the Cassandane tests. + +Prerequisites +------------- + +Before running any Cassandane tests, you need to set up Cassandane, +Cyrus and your system. Read the file setting_up.txt and follow the +instructions there. + +Running Tests +------------- + +Cassandane tests are run out of the Cassandane directory itself, without +installing Cassandane anywhere. This is not the result of deliberate policy so +much as implementation laziness. + +All runtime state is created under the cassandane rootdir configured in +cassandane.ini (by default: /var/tmp/cass). + +Internally, Cassandane (or more precisely, the Cyrus code it exercises) needs +to be run either as the superuser or as the "cyrus" user. But you should +generally invoke Cassandane as yourself, not as "cyrus" or "root". It will +try to re-run itself using sudo, which you already configured during setup +(didn't you?) + +The script 'testrunner.pl' is your interface for running Cassandane tests. +There are several other Perl scripts in the directory, but they are utilities +which were helpful during manual testing rather than part of the test suite +itself. + +With no arguments, testrunner.pl runs all the tests that come with Cassandane +and reports the results to the terminal in the 'prettier' test report format. +The testrunner.pl exit code will be 0 if all tests passed, non-zero otherwise. + + $ ./testrunner.pl + [ OK ] Cyrus::ACL.reconstruct + [ OK ] Cyrus::ACL.move + [ OK ] Cyrus::ACL.delete + ... + +There are several test report formats to choose from, by invoking testrunner.pl +with the -f 'format' option. + +-f pretty: + Human readable output to the terminal, showing the ok/failed/error status + and name for each test, as well as the error reports from any not-ok tests. + This gets noisy in the case of failures! It's mostly useful when debugging + single tests, especially in conjunction with -vvv. + +-f prettier (the default): + As for pretty, but without the noise when problems occur. This is most + useful when running many (or all) tests at once. A list of failed tests + is written to $rootdir/failed, and the full error reports for any failed + tests are written to $rootdir/reports, so you can still access these details + if you find yourself needing them after the fact. + +-f xml: + This writes reports in jUnit format. The reports will be xml files in a + subdirectory "reports" of the current directory at the time testrunner.pl + was invoked. Note that this is NOT the same "reports" file as used by + -f prettier. This format is apparently useful for integration with various + CI systems, though it's not used by our Github CI. + +-f tap: + TAP is a common format which originated with Perl and is now widely used, + see http://en.wikipedia.org/wiki/Test_Anything_Protocol for more + information. This seems to prints a single character for each test, or + something. I'm not sure what it's useful for, since if a test fails you + don't know which one or why. + +You can run just a subset of tests by giving arguments to testrunner.pl. +Tests to run are most commonly specified as: + + * a test suite without the leading Cassandane::Cyrus + + $ ./testrunner.pl Quota + + * a single test in a single test suite + + $ ./testrunner.pl Quota.quotarename + +Multiple test suites or tests can be specified as well: + + $ ./testrunner.pl Admin Quota.quotarename + +Arguments can be negated by using a leading exclamation mark (!) or tilde (~) +character. Note that you may need to escape the ! from the shell, so ~ is +generally preferable: + + $ ./testrunner.pl ~Quota + +will run all the tests from all the suites except the Quota suite. +Arguments accumulate from left to right, so e.g. + + $ ./testrunner.pl Quota ~Quota.quotarename + +will run all the tests in the Quota suite except the quotarename test. + +The -v (or --verbose) option to testrunner.pl causes both Cassandane and +several Cyrus programs run by Cassandane to emit a lot of information to +stderr. You can specify this option multiple times for increased verbosity, +and the single-character version can be stacked, like -vvv. + +The --valgrind option to testrunner.pl runs all the Cyrus executables +using Valgrind. This is of course much slower but is recommended +because it finds many subtle bugs. The Valgrind logs are saved in +the files $rootdir/$instance/vglogs/$name.$pid. Cassandane will +examine these logs after each test finishes, and will fail the test +if there are any errors (including memory leaks) reported. + +The --cleanup option causes Cassandane to do two things. Firstly, it +immediately cleans up any files left over in $rootdir. Secondly, +it cleans up any such files after each test, unless the test fails. +This should be helpful when the filesystem in use does not have much room, +such as when running on a tmpfs filesystem. You'll probably find this useful, +so enable cassandane.cleanup in your cassandane.ini rather than typing it +all the time. Then use --no-cleanup to override it when you don't want that. + +testrunner.pl also accepts a bunch of other options that are not documented +here. Consult the script itself for the full and most up-to-date set. diff --git a/cassandane/doc/setting_up.txt b/cassandane/doc/setting_up.txt new file mode 100644 index 0000000000..b2aea23389 --- /dev/null +++ b/cassandane/doc/setting_up.txt @@ -0,0 +1,138 @@ +How To Setup A System To Run Cassandane +--------------------------------------- + +Cassandane is designed to be operated on a day-to-day basis as an +unprivileged user. However, Cassandane needs root to make some small +one-time adjustments to be performed to your system before it will run +at all. This section documents those steps. + +0. Before doing anything else, make sure you have all the pre-reqs + listed in README.deps installed. A good way to check is: + + $ cd ~/my/cassandane/workarea + $ make -j4 + ... + testrunner.pl syntax OK + Cassandane/ThreadedGenerator.pm syntax OK + Cassandane/MasterEvent.pm syntax OK + Cassandane/PortManager.pm syntax OK + Cassandane/IMAPMessageStore.pm syntax OK + ... + +1. The passwd and group maps need valid entries for user "cyrus" and group + "mail". If you want to generate coverage reports eventually, you probably + also want a group called "cyrus", and make that the "cyrus" user's primary + group. Use your system's adduser/addgroup or equivalent tools for this. + + On Debian, something like this: + + $ sudo adduser --system --group cyrus + $ sudo adduser cyrus mail + + NOTE: User 'cyrus' must actually be in 'group' mail, or the annotator + will fail to start. + +2. You need to be able to run a program as the "cyrus" user, preferably + without entering your password all the time. And you need processes + that you start with sudo to inherit your core file settings. One way of + doing this is to add the following at the *end* of your /etc/sudoers file + + Defaults:username rlimit_core=default + username ALL = (cyrus) NOPASSWD: ALL + + Obviously, replace 'username' with your username. + +3. You need to tell Cassandane how to find Cyrus, which means you need to + decide where to put Cyrus. You've got two main options: + + * Fully installed Cyrus build in some prefix, specified by passing + --prefix=/some/prefix to configure. The default prefix is + /usr/local, but that's a nuisance cause you have to install as root. + If you do this, you'll need to always pass the correct --prefix + argument to configure when building Cyrus for testing. + + $ cd ~/my/cyrus/workarea + $ ./configure --prefix=/some/prefix \ + [your other configure options] + $ make && make install + + * Partially installed Cyrus build in a temp directory. If you do this, + you'll need to always pass the correct DESTDIR when installing Cyrus + for testing. + + $ cd ~/my/cyrus/workarea + $ ./configure [your other configure options] + $ make && make DESTDIR=/var/tmp/cyrus install + + Whichever you choose, for best results, install Cyrus to a directory + on a tmpfs filesystem. You'll probably end up making a small wrapper + script with all your usual configure options anyway, so adding --prefix to + that is low additional effort. + + Now copy the cassandane.ini.example from the source tree to a file called + "cassandane.ini" in your home directory, and start configuring. + + $ cp /path/to/cyrus-imapd/cassandane/cassandane.ini.example ~/cassandane.ini + $ vi ~/cassandane.ini + [cyrus default] + prefix = [the --prefix Cyrus is configured for] + destdir = [the DESTDIR you passed to make install, if any] + + Also note that you can do other combinations too, the trick is to + set up the 'cyrus default' section in the cassandane.ini such that + + * 'prefix' is the value of --prefix you used when you ran the + Cyrus configure script. Default is /usr/cyrus (which is not + the default for the Cyrus configure script!) + + * 'destdir' is the value of DESTDIR when you did 'make install' + in the Cyrus directory. Default is empty. + +4. More cassandane.ini configuration. + + You need to tell Casssandane where to keep its run-time state. For + best performance, this should be a directory on a tmpfs filesystem. + You set this in the cassandane.rootdir setting in cassandane.ini + + While you're in there anyway, there's some other things you really ought to + set: + + * cassandane.cleanup: default is no, but "yes" is more sensible. You can + always override this as needed with the --no-cleanup option at run time + * cassandane.maxworkers: default is "1", but this is excruciatingly slow. + Anecdotally, two times the number of CPUs in your system seems about + right, if your system is not otherwise heavily loaded. + * config.zoneinfo_dir: set this to the path to the zoneinfo directory + from the cyrus-timezones package. If you got this from cyruslibs, it's + probably /usr/local/cyruslibs/share/cyrus-timezones/zoneinfo + + But for the most part, read the comments from the example file, they are + the authoritative documentation here. + +5. It's also a good idea to set some kernel tunables. + + When dumping core files, use the PID of the dumping process + in the name, so that if multiple processes dump core during the + test you'll see all the core files instead of just one named "core". + + # echo 1 >/proc/sys/kernel/core_uses_pid + + As a security feature, Linux won't generate cores for processes + which have changed ownership. This prevents any of the Cyrus + processes in your test ever dumping core, so you want to turn + that feature off. + + # echo 1 >/proc/sys/fs/suid_dumpable + + Finally, some Linux systems might require to unlimit the size of + core dumps. As suid_dumpable, this shouldn't normally be set on + production systems. + + # ulimit -c unlimited + +Now, to run Cassandane use this command + + $ cd ~/my/cassandane/workarea + $ ./testrunner.pl + + NOTE: Cassandane will internally run 'sudo' to become user 'cyrus' diff --git a/cassandane/doc/versioning_tests.txt b/cassandane/doc/versioning_tests.txt new file mode 100644 index 0000000000..938c288fc8 --- /dev/null +++ b/cassandane/doc/versioning_tests.txt @@ -0,0 +1,65 @@ +From: ellie timoney via Cyrus-devel +To: cyrus-devel@lists.andrew.cmu.edu +Subject: new cassandane feature: skip tests based on cyrus version being tested +Date: Tuesday, August 23, 2016 12:43 PM + +I've just pushed up a new feature in Cassandane for marking tests as +only applying to particular version ranges. So if you're writing tests +for a new Cyrus feature, you can mark them appropriately, and Cassandane +can still run cleanly when testing versions prior to that feature's +introduction. + +There's two new magical subroutine attribute patterns: + :min_version_x_y_z + :max_version_x_y_z +(where in both cases y and z are optional). + +These only apply to test suites inheriting from +Cassandane::Cyrus::TestCase. Test suites inheriting from +Cassandane::Unit::TestCase will ignore these attributes entirely -- but +you probably shouldn't inherit from this anyway (unless you're testing +Cassandane itself). + +So for example, you might test a feature that's new in master with +something like: + + sub test_my_new_feature + :min_version_3_0 + { + # [...] + } + +And you might continue to test some hypothetical feature that's been +discontinued on master but still exists in the stable branch with +something like: + + sub test_my_obsolete_feature + :max_version_2_5 + { + # [...] + } + +There's also a new class method on Cassandane::Instance: get_version(). +It's able to detect versions as far back as 2.5.0. So if you need to do +some version-based conditionalisation within a test function (or within +infrastructure), you can use something like: + + my ($major, $minor, $revision, $extra) = + Cassandane::Instance->get_version() + # [...] + +And there's a new infrastructure test suite, Cassandane::Test::Skip, +which tests the implementation of the skip handling. + +The end goal here is to no longer need a separate "for-cyrus-2.5" branch +in Cassandane -- I want to be able to use the same test suite for +testing all future releases (I don't plan for it to support 2.4 or +earlier). There's still some work to go in this respect, in terms of +attributing our existing tests appropriately and other little tweaks to +accommodate 2.5. But this feature now exists for use in development of +new tests. + +I've already converted the Archive, Delete and JMAP test suites to +attribute their version requirements appropriately (so have a look at my +recent commits to these modules for real world examples), and am working +through the rest (low hanging fruit first). diff --git a/cassandane/genmail3.pl b/cassandane/genmail3.pl new file mode 100755 index 0000000000..c59299e645 --- /dev/null +++ b/cassandane/genmail3.pl @@ -0,0 +1,146 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; +use DateTime; + +use lib '.'; +use Cassandane::SequenceGenerator; +use Cassandane::ThreadedGenerator; +use Cassandane::MessageStoreFactory; + +sub usage +{ + print STDERR "Usage: genmail3.pl [ --threaded ] -u uri\n"; + print STDERR " genmail3.pl [ --threaded ] [ -U user ]\n"; + print STDERR " [ -P password ] [ -F folder ]\n"; + print STDERR " [ -h host ] [ -p port ]\n"; + print STDERR " genmail3.pl [ --threaded ] path\n"; + exit(1); +} + +my $mode = 'sequence'; +my $maxmessages; +my %params = ( + type => 'imap', + host => 'localhost', + port => 9100, + folder => 'inbox', + username => 'cassandane', + password => 'testpw', + verbose => 0, +); +while (my $a = shift) +{ + if ($a eq '-u') + { + usage() if defined $params{uri}; + %params = ( uri => shift, verbose => $params{verbose} ); + } + elsif ($a eq '-h' || $a eq '--host') + { + $params{host} = shift; + usage() unless defined $params{host}; + } + elsif ($a eq '-p' || $a eq '--port') + { + $params{port} = shift; + usage() unless defined $params{port}; + } + elsif ($a eq '-F' || $a eq '--folder') + { + $params{folder} = shift; + usage() unless defined $params{folder}; + } + elsif ($a eq '-U' || $a eq '--user') + { + $params{username} = shift; + usage() unless defined $params{username}; + } + elsif ($a eq '-P' || $a eq '--password') + { + $params{password} = shift; + usage() unless defined $params{password}; + } + elsif ($a eq '-v' || $a eq '--verbose') + { + $params{verbose} = 1; + } + elsif ($a eq '-T' || $a eq '--threaded') + { + $mode = 'threaded'; + } + elsif ($a eq '-m' || $a eq '--max-messages') + { + $maxmessages = shift || usage; + $maxmessages = int(0+$maxmessages); + } + elsif ($a =~ m/^-/) + { + usage(); + } + else + { + usage() if defined $params{path}; + %params = ( path => $a, verbose => $params{verbose} ); + } +} + +my $store = Cassandane::MessageStoreFactory->create(%params); +my $now = DateTime->now()->epoch(); +my $gen; +if ($mode eq 'sequence') +{ + $gen = Cassandane::SequenceGenerator->new(); +} +elsif ($mode eq 'threaded') +{ + $gen = Cassandane::ThreadedGenerator->new(); +} + +$store->write_begin(); +while (my $msg = $gen->generate()) +{ + last if (defined $maxmessages && $maxmessages-- == 0); + $store->write_message($msg); +} +$store->write_end(); diff --git a/cassandane/imap-append.pl b/cassandane/imap-append.pl new file mode 100755 index 0000000000..0d71970808 --- /dev/null +++ b/cassandane/imap-append.pl @@ -0,0 +1,128 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; +use DateTime; + +use lib '.'; +use Cassandane::Generator; +use Cassandane::Util::DateTime qw(to_iso8601); +use Cassandane::MessageStoreFactory; + +sub usage +{ + die "Usage: imap-append.pl [ -f format [maildir] | -u uri]"; +} + +my %imap_params = ( + type => 'imap', + host => 'localhost', + port => 9100, + folder => 'inbox', + username => 'cassandane', + password => 'testpw', +); +my %mbox_params; +while (my $a = shift) +{ + if ($a eq '-f') + { + usage() if defined $mbox_params{uri}; + $mbox_params{type} = shift; + } + elsif ($a eq '-u') + { + usage() if defined $mbox_params{type}; + $mbox_params{uri} = shift; + } + elsif ($a eq '-h' || $a eq '--host') + { + $imap_params{host} = shift; + usage() unless defined $imap_params{host}; + } + elsif ($a eq '-p' || $a eq '--port') + { + $imap_params{port} = shift; + usage() unless defined $imap_params{port}; + } + elsif ($a eq '-F' || $a eq '--folder') + { + $imap_params{folder} = shift; + usage() unless defined $imap_params{folder}; + } + elsif ($a eq '-U' || $a eq '--user') + { + $imap_params{username} = shift; + usage() unless defined $imap_params{username}; + } + elsif ($a eq '-P' || $a eq '--password') + { + $imap_params{password} = shift; + usage() unless defined $imap_params{password}; + } + elsif ($a eq '-v' || $a eq '--verbose') + { + $mbox_params{verbose} = 1; + $imap_params{verbose} = 1; + } + elsif ($a =~ m/^-/) + { + usage(); + } + else + { + usage() if defined $mbox_params{filename}; + $mbox_params{filename} = $a; + } +} + +my $imap_store = Cassandane::MessageStoreFactory->create(%imap_params); +my $mbox_store = Cassandane::MessageStoreFactory->create(%mbox_params); + +$imap_store->write_begin(); +$mbox_store->read_begin(); +while (my $msg = $mbox_store->read_message()) +{ + $imap_store->write_message($msg); +} +$mbox_store->read_end(); +$imap_store->write_end(); diff --git a/cassandane/jenkins-xml-summary.pl b/cassandane/jenkins-xml-summary.pl new file mode 100755 index 0000000000..733c552267 --- /dev/null +++ b/cassandane/jenkins-xml-summary.pl @@ -0,0 +1,131 @@ +#!/usr/bin/perl +# +# Copyright (c) 2012 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; +use XML::DOM; +use Getopt::Long; + +my $report_dir = 'reports'; +my $build_url; + +sub usage +{ + print STDERR "Usage: $0 [--build-url=URL] [--report-dir=DIR]\n"; + exit(1); +} + +GetOptions( + "report-dir=s" => \$report_dir, + "build-url=s" => \$build_url, + "help" => \&usage, +) or usage; +usage if scalar(@ARGV); + +my @report_files; +opendir REPORTDIR, $report_dir + or die "Cannot open directory $report_dir for reading: $!"; +while (my $e = readdir REPORTDIR) +{ + push(@report_files, "$report_dir/$e") if ($e =~ m/^TEST-.*\.xml$/); +} +closedir REPORTDIR; +@report_files = sort { $a cmp $b } @report_files; + + +# want this url +# http://ci.cyrusimap.org/view/All/job/cyrus-imapd-master/400/ +# +# testReport/%28root%29/Cassandane__Cyrus__Quota/test_exceeding_message/ +# +# get this in $BUILD_URL +# http://ci.cyrusimap.org/view/All/job/cyrus-imapd-master/400/ + +print "Test failures and errors summary\n"; +print "================================\n"; +my $nrun = 0; +my $nerrors = 0; +my $nfailures = 0; +foreach my $file (@report_files) +{ + my $parser = new XML::DOM::Parser; + my $doc = $parser->parsefile($file); + + my ($xsuite, @wtf) = $doc->getElementsByTagName('testsuite', 0); + die "Invalid document $file" + if (!defined $xsuite || scalar(@wtf)); + + my $suite = $xsuite->getAttribute('name'); + + foreach my $xcase ( $xsuite->getElementsByTagName('testcase', 0) ) + { + my $case = $xcase->getAttribute('name'); + $case =~ s/^test_//; + + my $status = 1; + $nrun++; + my (@xfails) = $xcase->getElementsByTagName('failure', 0); + if (scalar @xfails) + { + $nfailures++; + $status = 0; + } + my (@xerrors) = $xcase->getElementsByTagName('error', 0); + if (scalar @xerrors) + { + $nerrors++; + $status = 0; + } + + next if $status; + + print "\n$suite.$case\n"; + + if (defined $build_url) + { + my $quoted_suite = $suite; + $quoted_suite =~ s/[:\/]/_/g; + my $url = "$build_url/testReport/%28root%29/$quoted_suite/test_$case/"; + print " $url\n"; + } + } +} +print "\n$nrun run, $nfailures failures, $nerrors errors\n"; diff --git a/cassandane/listmail.pl b/cassandane/listmail.pl new file mode 100755 index 0000000000..4174963594 --- /dev/null +++ b/cassandane/listmail.pl @@ -0,0 +1,90 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; +use DateTime; + +use lib '.'; +use Cassandane::MessageStoreFactory; + +sub usage +{ + die "Usage: genmail3.pl [ -f format [maildir] | -u uri]"; +} + +my %params; +while (my $a = shift) +{ + if ($a eq '-f') + { + usage() if defined $params{uri}; + $params{type} = shift; + } + elsif ($a eq '-u') + { + usage() if defined $params{type}; + $params{uri} = shift; + } + elsif ($a eq '-v') + { + $params{verbose} = 1; + } + elsif ($a =~ m/^-/) + { + usage(); + } + else + { + usage() if defined $params{path}; + $params{path} = $a; + } +} + +my $store = Cassandane::MessageStoreFactory->create(%params); + +$store->read_begin(); +while (my $msg = $store->read_message()) +{ + print "From - bogus\r\n"; + print $msg; +} +$store->read_end(); diff --git a/cassandane/pop3showafter.pl b/cassandane/pop3showafter.pl new file mode 100755 index 0000000000..8e0cb74d3f --- /dev/null +++ b/cassandane/pop3showafter.pl @@ -0,0 +1,237 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; +use DateTime; +use URI::Escape; + +use lib '.'; +use Cassandane::Generator; +use Cassandane::Util::DateTime qw(to_iso8601 from_iso8601 + from_rfc822 + to_rfc3501 from_rfc3501); +use Cassandane::MessageStoreFactory; + +sub usage +{ + die "Usage: pop3showafter.pl"; +} + +my $verbose = 1; +# Connection information for the IMAP server +my $imapport = 2143; +my $pop3port = 2110; +my %store_params = ( + host => '127.0.0.2', + folder => 'inbox.showaftertestXX', + username => 'test@vmtom.com', + password => 'testpw', + verbose => $verbose, + ); + +# +# Given a set of messages downloaded via either IMAP +# or POP protocols, check them for consistency, using +# rules encoded in genmail.pl. +# +sub check_messages($$$$) +{ + my ($store, $expected_nmsgs, $expect_internaldate, $cutoff_dt) = @_; + my $nmsgs = 0; + + $store->read_begin(); + while (my $msg = $store->read_message()) + { + $nmsgs++; + + if ($verbose) + { + printf "[%u]\n", $nmsgs; + printf " message-id=\"%s\"\n", $msg->get_header('Message-ID'); + printf " from=\"%s\"\n", $msg->get_header('From'); + } + + my $internal_dt; + my $datehdr_dt; + my $subject_dt; + my $d; + + # Check that the Date: header is present and well formed. + $d = $msg->get_header('Date'); + $datehdr_dt = from_rfc822($d) + or die "Bogus RFC822 time in Date header \"$d\""; + printf " date=\"%s\" -> %u\n", $d, $datehdr_dt->epoch() if $verbose; + + # Check that the Subject: header is present and + # encodes a datetime in ISO8601 format, as generated + my $s = $msg->get_header('Subject'); + ($d) = ($s =~ m/^message at (\S*)$/) + or die "Bogus Subject header \"$s\""; + $subject_dt = from_iso8601($d) + or die "Bogus ISO8601 time in Subject \"$s\""; + printf " subject=\"%s\" -> %u\n", $s, $subject_dt->epoch() if $verbose; + + if ($expect_internaldate) + { + # Check that an internaldate field is present and well formed. + $internal_dt = from_rfc3501($msg->get_attribute('internaldate')); + die "No or bogus INTERNALDATE" + unless defined $internal_dt; + printf " internaldate=%u\n", $internal_dt->epoch() if $verbose; + } + else + { + # For convenience, pretend the internal date was here + $internal_dt = $datehdr_dt; + } + + # Check that all three of the dates match exactly. + # If this fails, something has gone awry with the + # dataset generation in genmail.pl. + die "Invalid message: times don't match" + unless ($internal_dt->epoch() == $datehdr_dt->epoch() && + $datehdr_dt->epoch() == $subject_dt->epoch()); + + if (defined($cutoff_dt)) + { + die "Incorrectly found message before cutoff time " . $cutoff_dt->epoch() + unless ($internal_dt->epoch() > $cutoff_dt->epoch()); + } + } + $store->read_end(); + + die "Wrong number of messages, got $nmsgs, expecting $expected_nmsgs" + unless (!defined $expected_nmsgs || $nmsgs == $expected_nmsgs); + + return 1; +} + +# +# Check the value of the pop3-show-after annotation +# +my $showafter_anno = '/private/vendor/cmu/cyrus-imapd/pop3showafter'; + +sub get_pop3showafter +{ + my ($store) = @_; + + my $annos = $store->get_client()->getmetadata($store->{folder}, $showafter_anno) + or die "Cannot get annotation $showafter_anno: $@"; + + if ($annos eq "Completed") + { + return "NIL"; + } + + my $aa = $annos->{$store->{folder}}->{$showafter_anno}; + die "No data for annotation $showafter_anno" + unless defined $aa; + die "Wrong content-type for annotation $showafter_anno: " . $aa->{'content-type.shared'} + unless ($aa->{'content-type.shared'} eq 'text/plain'); + return $aa->{'value.shared'}; +} + +sub set_pop3showafter +{ + my ($store, $val) = @_; + + $store->get_client()->setannotation($store->{folder}, $showafter_anno, [ 'value.shared', $val ]) + or die "Setting annotation $showafter_anno failed: $@"; + + my $newval = get_pop3showafter($store); + die "Set $showafter_anno is not reflected in get: got \"$newval\" expecting \"$val\"" + unless ($newval eq $val); +} + +my $imap_store = Cassandane::MessageStoreFactory->create( + type => 'imap', + port => $imapport, + %store_params + ); +$imap_store->set_fetch_attributes('uid', 'internaldate'); +my $pop3_store = Cassandane::MessageStoreFactory->create( + type => 'pop3', + port => $pop3port, + %store_params + ); + +my $now = DateTime->now()->epoch(); +my $cutoff_dt = DateTime->from_epoch(epoch => $now - 86400/2); +my $gen = Cassandane::Generator->new(); + +printf "removing folder\n" if $verbose; +$imap_store->remove(); +printf "generating messages\n" if $verbose; +$imap_store->write_begin(); +my $expected_nmsgs_all = 0; +my $expected_nmsgs_after = 0; +for (my $offset = -86400 ; $offset <= 0 ; $offset += 3600) +{ + my $then = DateTime->from_epoch(epoch => $now + $offset); + my $msg = $gen->generate( + date => $then, + subject => "message at " . to_iso8601($then), + ); + $imap_store->write_message($msg); + + $expected_nmsgs_all++; + $expected_nmsgs_after++ + if ($then->epoch() > $cutoff_dt->epoch()); +} +$imap_store->write_end(); + +printf "Checking messages for validity...\n" if $verbose; +check_messages($imap_store, $expected_nmsgs_all, 1, undef); + +printf "Testing that by default POP gives us all the messages...\n" + if $verbose; +check_messages($pop3_store, $expected_nmsgs_all, 0, undef); + +printf "Testing that after setting $showafter_anno, POP gives the correct subset of messages...\n"; +set_pop3showafter($imap_store, to_rfc3501($cutoff_dt)); +check_messages($pop3_store, $expected_nmsgs_after, 0, $cutoff_dt); + +printf "Testing that after clearing $showafter_anno again, POP gives us all the messages...\n"; +set_pop3showafter($imap_store, 'NIL'); +check_messages($pop3_store, $expected_nmsgs_all, 0, undef); + +printf "done\n" if $verbose; diff --git a/cassandane/split-by-thread.pl b/cassandane/split-by-thread.pl new file mode 100755 index 0000000000..f6d95eea99 --- /dev/null +++ b/cassandane/split-by-thread.pl @@ -0,0 +1,180 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; +use DateTime; + +use lib '.'; +use Cassandane::MessageStoreFactory; + +sub usage +{ + die "Usage: split-by-thread.pl [ -f format [maildir] | -u uri]"; +} + +my %params; +while (my $a = shift) +{ + if ($a eq '-f') + { + usage() if defined $params{uri}; + $params{type} = shift; + } + elsif ($a eq '-u') + { + usage() if defined $params{type}; + $params{uri} = shift; + } + elsif ($a eq '-v') + { + $params{verbose} = 1; + } + elsif ($a =~ m/^-/) + { + usage(); + } + else + { + usage() if defined $params{path}; + $params{path} = $a; + } +} + +sub extract_refs +{ + my ($msg) = @_; + my $str = $msg->get_header('references'); + return if !defined $str; + my @refs; + + for (;;) + { + my ($msgid, $rem) = ($str =~ m/[\s,]*(<[^\s<>]+@[^\s<>]+>)(.*)/); + last if !defined $msgid; + push(@refs, $msgid); + last if !defined $rem || !length $rem; + $str = $rem; + } + + return @refs; +} + +my $store = Cassandane::MessageStoreFactory->create(%params); + +my %threads_by_msgid; +my @threads; +my $next_thread_id = 1; +my $max_msgs = 1000; + +sub thread_new +{ + my $t = + { + id => $next_thread_id++, + messages => [], + }; + push(@threads, $t); + return $t; +} + +sub thread_add_message +{ + my ($thread, $msg) = @_; + + push(@{$thread->{messages}}, $msg); + $threads_by_msgid{$msg->get_header('message-id')} = $thread; +} + +$store->read_begin(); +while (my $msg = $store->read_message()) +{ + my $msgid = $msg->get_header('message-id'); + die "duplicate msgid $msgid" + if defined $threads_by_msgid{$msgid}; + + my @refs; + eval + { + @refs = extract_refs($msg); + }; + if ($@) + { + print STDERR "Can't get references: $@"; + next; + } + + my $thread; + foreach my $ref (@refs) + { + my $t = $threads_by_msgid{$ref}; + if (defined $t && + defined $thread && + $t->{id} != $thread->{id}) + { + print STDERR "Thread clash! $t->{id} vs $thread->{id}\n"; + next; + } + $thread = $t; + } + + $thread = thread_new() + if !defined $thread; + thread_add_message($thread, $msg); + + last if (--$max_msgs == 0); +} +$store->read_end(); + +foreach my $t (@threads) +{ + next if scalar(@{$t->{messages}}) < 8; + + my $store = Cassandane::MessageStoreFactory->create( + type => 'mbox', + path => sprintf("x/thread%04u", $t->{id})); + $store->write_begin(); + foreach my $msg (@{$t->{messages}}) + { + $store->write_message($msg); + } + $store->write_end(); +} diff --git a/cassandane/sprinkle.pl b/cassandane/sprinkle.pl new file mode 100755 index 0000000000..aa2f1a2a69 --- /dev/null +++ b/cassandane/sprinkle.pl @@ -0,0 +1,125 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; +use DateTime; + +use lib '.'; +use Cassandane::Util::Log; +use Cassandane::Util::Words; +use Cassandane::MessageStoreFactory; + +sub usage +{ + die "Usage: sprinkle.pl imapuri mbox ..."; +} + +my $base_folder; +my $num_remaining = 0; +my $num_written = 0; +my $verbose = 1; + +sub choose_folder +{ + my @parts; + my $nparts = int(rand(7)); + + for (my $i = 0 ; $i < $nparts ; $i++) + { + push(@parts, random_word()); + } + + my $folder = join('.', ($base_folder, @parts)); + xlog "choosing folder $folder"; + return $folder; +} + +sub sprinkle +{ + my ($path, $imap_store) = @_; + + my $mbox_store = Cassandane::MessageStoreFactory->create(( + type => 'mbox', + path => $path )) + or die "Cannot create MBOX message store"; + + $mbox_store->read_begin(); + while (my $msg = $mbox_store->read_message()) + { + if ($num_remaining == 0) + { + $imap_store->write_end() + if $num_written; + $imap_store->set_folder(choose_folder()); + $imap_store->write_begin(); + $num_remaining = 1 + int(rand(300)); + $num_written = 0; + xlog "choosing $num_remaining messages"; + } + $imap_store->write_message($msg); + $num_remaining--; + $num_written++; + } + $mbox_store->read_end(); +} + +my $imap_store = Cassandane::MessageStoreFactory->create(( + type => 'imap', + host => 'slott02', + port => 2144, + folder => 'inbox.sprinkle', + username => 'test@vmtom.com', + password => 'testpw', + verbose => ($verbose > 1 ? 1 : 0), + )) + or die "Cannot create IMAP message store"; +$base_folder = $imap_store->{folder}; + +while (my $a = shift) +{ + sprinkle($a, $imap_store); +} + +$imap_store->write_end() + if $num_written; +$imap_store->disconnect(); + diff --git a/cassandane/start-instance.pl b/cassandane/start-instance.pl new file mode 100755 index 0000000000..f0d74e80a1 --- /dev/null +++ b/cassandane/start-instance.pl @@ -0,0 +1,158 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; + +use lib '.'; +use Cassandane::Util::Setup; +use Cassandane::Util::Log; +use Cassandane::Config; +use Cassandane::Instance; +use Cassandane::Cassini; + +my $name; +my $config = Cassandane::Config->default()->clone(); +my $start_flag = 0; +my $re_use_dir = 1; +my @services = ( 'imap' ); +$start_flag = 1 if $0 =~ m/start-instance/; + +sub latest_instance +{ + my @infos = Cassandane::Instance::list(); + die "No instances, sorry" unless scalar @infos; + @infos = sort { + $b->{ctime} <=> $a->{ctime} + } @infos; + return shift(@infos)->{name}; +} + +sub usage +{ + if ($start_flag) + { + print STDERR "Usage: start-instance.pl [ -O config-option=value ... ] [name]\n"; + } + else + { + print STDERR "Usage: stop-instance.pl [name]\n"; + } + exit(1); +} + +while (my $a = shift) +{ + if ($a eq '-O' || $a eq '--option') + { + my $vv = shift || usage; + my ($name, $value) = ($vv =~ m/^([a-z][a-z0-9-]+)=(.*)$/); + usage() unless defined $value; + $config->set($name, $value); + } + elsif ($a eq '-v' || $a eq '--verbose') + { + set_verbose(1); + } + elsif ($a eq '--reset') + { + $re_use_dir = 0; + } + elsif ($a eq '--valgrind') + { + Cassandane::Cassini->instance()->override('valgrind', 'enabled', 'yes'); + } + elsif ($a eq '--service') + { + my $vv = shift || usage; + push(@services, $vv); + } + elsif ($a eq '--latest') + { + usage() if defined $name; + $name = latest_instance(); + } + elsif ($a =~ m/^-/) + { + printf STDERR "Unknown option $a\n"; + usage(); + } + else + { + usage() if defined $name; + $name = $a; + } +} +$name ||= 'casscmd'; + +become_cyrus(); + +my $iinfo = Cassandane::Instance::exists($name); +exit(0) if (!$iinfo && !$start_flag); # nothing to stop +my $instance; +if ($iinfo && $re_use_dir) +{ + $instance = Cassandane::Instance->new( + name => $iinfo->{name}, + basedir => $iinfo->{basedir}, + re_use_dir => 1, + persistent => $start_flag ? 1 : 0, + ); +} +else +{ + $instance = Cassandane::Instance->new( + name => $name, + config => $config, + persistent => $start_flag ? 1 : 0, + ); + $instance->add_services(@services); +} + +if ($start_flag) +{ + $instance->start(); + $instance->describe(); +} +else +{ + $instance->stop(); +} diff --git a/cassandane/stop-instance.pl b/cassandane/stop-instance.pl new file mode 120000 index 0000000000..89f16e6dfd --- /dev/null +++ b/cassandane/stop-instance.pl @@ -0,0 +1 @@ +start-instance.pl \ No newline at end of file diff --git a/cassandane/testrunner.pl b/cassandane/testrunner.pl new file mode 100755 index 0000000000..6a72b9dc57 --- /dev/null +++ b/cassandane/testrunner.pl @@ -0,0 +1,389 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +use strict; +use warnings; +use File::Slurp; + +use lib '.'; +use Cassandane::Util::Setup; +use Cassandane::Unit::FormatPretty; +use Cassandane::Unit::FormatTAP; +use Cassandane::Unit::FormatXML; +use Cassandane::Unit::Runner; +use Cassandane::Unit::TestPlan; +use Cassandane::Util::Log; +use Cassandane::Cassini; +use Cassandane::Instance; + +use Data::Dumper; +$Data::Dumper::Deepcopy = 1; +$Data::Dumper::Indent = 1; +$Data::Dumper::Sortkeys = 1; +$Data::Dumper::Trailingcomma = 1; + +my %want_formats = (); +my $output_dir = 'reports'; +my $do_list = 0; +# The default really should be --no-keep-going like make +my $keep_going = 1; +my $skip_slow = 1; +my $slow_only = 0; +my $log_directory; +my @names; + +# Make sure our binary components have been built already +# -- get their names from the Makefile +open my $mf, '<', 'utils/Makefile' + or die "Can't read utils/Makefile: $!"; +my $pat = qr{^(?:PROGRAMS|LIBS)=}; +my $missing_binaries = 0; +foreach my $match (grep { m/$pat/ } <$mf>) { + chomp $match; + $match =~ s/$pat//; + foreach my $binary (split /\s+/, $match) { + my $filename = "utils/$binary"; + if (! -e $filename || ! -x $filename) { + print STDERR "$filename is not executable or is missing\n"; + $missing_binaries ++; + } + } +} +close $mf; +if ($missing_binaries) { + print STDERR "Did you run 'make' yet?\n"; + exit 1; +} + +# This disgusting hack makes Test::Unit report a useful stack trace for +# it's assert failures instead of just a file name and line number. +{ + use Error; + use Test::Unit::Exception; + + # We also convert string exceptions into Test::Unit errors. + $SIG{__DIE__} = sub + { + my ($e) = @_; + if (!ref($e)) + { + my ($text, $file, $line) = ($e =~ m/^(.*) at (.*\.pm) line (\d+)/); + if ($line) + { + local $Error::Depth = 1; + Test::Unit::Error->throw('-text' => "Perl exception: $text\n"); + } + } + die @_; + }; + + # Disable the warning about redefining T:U:E:stringify. + # We know what we're doing, dammit. + no warnings; + # This makes Error->new() capture a full stacktrace + $Error::Debug = 1; + *Test::Unit::Exception::stringify = sub + { + my ($self) = @_; + my $s = ''; + + my $o = $self->object; + $s .= $o->to_string() . "\n " if $o && $o->can('to_string'); + + # Note, -stacktrace includes -text + + my $st = $self->{-stacktrace}; + # Prune all Test::Unit internal calls + $st =~ s/Test::Unit::TestCase::run_test.*/[...framework calls elided...]/s; + $s .= $st; + + return $s; + }; +}; + +my %formatters = ( + tap => { + writes_to_stdout => 1, + formatter => sub { + my ($fh) = @_; + return Cassandane::Unit::FormatTAP->new($fh); + }, + }, + pretty => { + writes_to_stdout => 1, + formatter => sub { + my ($fh) = @_; + return Cassandane::Unit::FormatPretty->new({}, $fh); + }, + }, + prettier => { + writes_to_stdout => 1, + formatter => sub { + my ($fh) = @_; + return Cassandane::Unit::FormatPretty->new({quiet=>1}, $fh); + }, + }, + xml => { + writes_to_stdout => 0, + formatter => sub { + my ($fh) = @_; + return Cassandane::Unit::FormatXML->new({ + directory => $output_dir + }); + }, + }, +); + +become_cyrus(); + +eval { + if ( ! -d $output_dir ) { + mkdir($output_dir) + or die "Cannot make output directory \"$output_dir\": $!\n"; + } + + if (! -w $output_dir ) { + die "Cannot write to output directory \"$output_dir\"\n"; + } +}; +if ($@) { + my $eval_err = $@; + $formatters{xml}->{formatter} = sub { + die "Sorry, XML output format not available due to:\n", + "=> $eval_err"; + }; +} + +sub usage +{ + printf STDERR "Usage: testrunner.pl [options] -f [testname...]\n"; + exit(1); +} + +my $cassini_filename; +my @cassini_overrides; +my $want_rerun; + +while (my $a = shift) +{ + if ($a eq '--config') + { + $cassini_filename = shift; + } + elsif ($a eq '-c' || $a eq '--cleanup') + { + push(@cassini_overrides, ['cassandane', 'cleanup', 'yes']); + } + elsif ($a eq '--no-cleanup') + { + push(@cassini_overrides, ['cassandane', 'cleanup', 'no']); + } + elsif ($a eq '-f') + { + my $format = shift; + usage unless defined $formatters{$format}; + $want_formats{$format} = 1; + } + elsif ($a =~ m/^-f(\w+)$/) + { + my $format = $1; + usage unless defined $formatters{$format}; + $want_formats{$format} = 1; + } + elsif ($a eq '-v' || $a eq '--verbose') + { + set_verbose(get_verbose()+1); + } + elsif ($a =~ m/^-v+$/) + { + # ganged verbosity + set_verbose(get_verbose() + length($a) - 1); + } + elsif ($a eq '--valgrind') + { + push(@cassini_overrides, ['valgrind', 'enabled', 'yes']); + } + elsif ($a eq '--no-valgrind') + { + push(@cassini_overrides, ['valgrind', 'enabled', 'no']); + } + elsif ($a eq '-j' || $a eq '--jobs') + { + my $jobs = 0 + shift; + usage unless $jobs > 0; + push(@cassini_overrides, ['cassandane', 'maxworkers', $jobs]); + } + elsif ($a =~ m/^-j(\d+)$/) + { + my $jobs = 0 + $1; + usage unless $jobs > 0; + push(@cassini_overrides, ['cassandane', 'maxworkers', $jobs]); + } + elsif ($a eq '-L' || $a eq '--log-directory') + { + $log_directory = shift; + usage unless defined $log_directory; + } + elsif ($a eq '-l' || $a eq '--list') + { + $do_list++; + } + elsif ($a eq '-k' || $a eq '--keep-going') + { + # These option names stolen from GNU make + $keep_going = 1; + } + elsif ($a eq '-S' || $a eq '--stop' || $a eq '--no-keep-going') + { + # These option names stolen from GNU make + $keep_going = 0; + } + elsif ($a =~ m/^-D.*=/) + { + my ($sec, $param, $val) = ($a =~ m/^-D([^.=]+)\.([^.=]+)=(.*)$/); + push(@cassini_overrides, [$sec, $param, $val]); + } + elsif ($a eq '--slow') + { + $skip_slow = 0; + } + elsif ($a eq '--slow-only') + { + $skip_slow = 0; + $slow_only = 1; + } + elsif ($a eq '--rerun') + { + $want_rerun = 1; + } + elsif ($a =~ m/^-/) + { + usage; + } + else + { + push(@names, $a); + } +} + +my $cassini = Cassandane::Cassini->new(filename => $cassini_filename); +map { $cassini->override(@$_); } @cassini_overrides; + +Cassandane::Instance::cleanup_leftovers() + if ($cassini->bool_val('cassandane', 'cleanup')); + +if ($want_rerun) { + my $rootdir = $cassini->val('cassandane', 'rootdir', '/var/tmp/cass'); + my $failed_file = "$rootdir/failed"; + + my @failed = eval { read_file($failed_file, { chomp => 1 }) }; + if ($@) { + print STDERR "Cannot --rerun without an existing failed file.\n"; + exit 1; + } + + if (scalar @failed) { + push @names, @failed; + } + else { + # prevent accidentally running everything by default! + print STDERR "The failed file is empty; there is nothing to ", + "re-run.\n"; + exit 0; + } +} + +my $plan = Cassandane::Unit::TestPlan->new( + keep_going => $keep_going, + maxworkers => $cassini->val('cassandane', 'maxworkers') || undef, + log_directory => $log_directory, + skip_slow => $skip_slow, + slow_only => $slow_only, + ); + +if ($do_list) +{ + # Build the schedule per commandline + $plan->schedule(@names); + # dump the plan to stdout + my %plan = map { _listitem($_) => 1 } $plan->list(); + foreach my $nm (sort keys %plan) + { + print "$nm\n"; + } + exit 0; +} +else +{ + # Build the schedule per commandline + $plan->schedule(@names); + $plan->check_sanity(); + + # Run the schedule + $want_formats{prettier} = 1 if not scalar keys %want_formats; + my @writes_to_stdout = grep { + $formatters{$_}->{writes_to_stdout} + } keys %want_formats; + if (scalar @writes_to_stdout > 1) { + my $joined = join ', ', map { "'$_'" } @writes_to_stdout; + die "$joined formatters all want to write to stdout\n"; + } + + my @filters = qw(x skip_version skip_missing_features + skip_runtime_check + enable_wanted_properties); + push @filters, 'skip_slow' if $plan->{skip_slow}; + push @filters, 'slow_only' if $plan->{slow_only}; + + my $runner = Cassandane::Unit::Runner->new(); + foreach my $f (keys %want_formats) { + $runner->add_formatter($formatters{$f}->{formatter}->()); + } + $runner->filter(@filters); + + exit !$runner->do_run($plan); +} + +sub _listitem { + my $item = shift; + $item =~ s/\..*// if ($do_list == 1); + return $item; +} diff --git a/cassandane/tiny-tests/Caldav/alarm_peruser b/cassandane/tiny-tests/Caldav/alarm_peruser new file mode 100644 index 0000000000..cad0525fc9 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/alarm_peruser @@ -0,0 +1,162 @@ +#!perl +use Cassandane::Tiny; + +sub test_alarm_peruser + :MagicPlus :min_version_3_0 :needs_component_httpd :NoAltNameSpace :NoVirtDomains +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.manifold"); + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + my $service = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $invite = < + + + mailto:cassandane\@example.com + + Cassandane + + Shared calendar + + + + + +EOF + + my $reply = < + + + + /dav/calendars/user/cassandane/ + + Thanks for the share! + +EOF + + xlog $self, "create calendar"; + my $CalendarId = $mantalk->NewCalendar({name => 'Manifold Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "share to user"; + $mantalk->Request('POST', $CalendarId, $invite, + 'Content-Type' => 'application/davsharing+xml'); + + xlog $self, "fetch invite"; + my ($adds) = $CalDAV->SyncEventLinks("/dav/notifications/user/cassandane"); + $self->assert_equals(scalar %$adds, 1); + my $notification = (keys %$adds)[0]; + + xlog $self, "accept invite"; + $CalDAV->Request('POST', $notification, $reply, + 'Content-Type' => 'application/davsharing+xml'); + + xlog $self, "get calendars as manifold"; + my $ManCal = $mantalk->GetCalendars(); + $self->assert_num_equals(2, scalar @$ManCal); + my $names = join "/", sort map { $_->{name} } @$ManCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); + + xlog $self, "get calendars as cassandane"; + my $CasCal = $CalDAV->GetCalendars(); + $self->assert_num_equals(2, scalar @$CasCal); + $names = join "/", sort map { $_->{name} } @$CasCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); + + my $uuid = 'fb7b57d1-8a49-4af8-8597-2c17bab1f987'; + my $event = <{name} eq 'Manifold Calendar' } @$CasCal; + $CalDAV->Request('PUT', "$cal->{id}/$uuid.ics", $nonallevent, 'Content-Type' => 'text/calendar'); + + my $plusstore = $self->{instance}->get_service('imap')->create_store(username => 'cassandane+dav'); + my $plustalk = $plusstore->get_client(); + + my @list = $plustalk->list("", "*"); + + my @bits = split /\./, $cal->{id}; + $plustalk->select("user.manifold.#calendars.$bits[1]"); + my $res = $plustalk->fetch('1', '(rfc822.peek annotation (/* value.priv))'); + + $self->assert_does_not_match(qr/VALARM/, $res->{1}{'rfc822'}); + $self->assert_matches(qr/VALARM/, $res->{1}{'annotation'}{'/vendor/cmu/cyrus-httpd/per-user-calendar-data'}{'value.priv'}); + + $CalDAV->Request('PUT', "$cal->{id}/$uuid.ics", $allevent, 'Content-Type' => 'text/calendar'); + + $res = $plustalk->fetch('2', '(rfc822.peek annotation (/* value.priv))'); + $self->assert_does_not_match(qr/VALARM/, $res->{2}{'rfc822'}); + $self->assert_matches(qr/VALARM/, $res->{2}{'annotation'}{'/vendor/cmu/cyrus-httpd/per-user-calendar-data'}{'value.priv'}); +} diff --git a/cassandane/tiny-tests/Caldav/apple_location_notz b/cassandane/tiny-tests/Caldav/apple_location_notz new file mode 100644 index 0000000000..ee15b40999 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/apple_location_notz @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_apple_location_notz + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my $response = $CalDAV->Request('GET', $href); + + my $newcard = $response->{content}; + + $self->assert_matches(qr/geo:-37.810551,144.962840/, $newcard); +} diff --git a/cassandane/tiny-tests/Caldav/apple_location_tz b/cassandane/tiny-tests/Caldav/apple_location_tz new file mode 100644 index 0000000000..4d25308a3e --- /dev/null +++ b/cassandane/tiny-tests/Caldav/apple_location_tz @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_apple_location_tz + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my $response = $CalDAV->Request('GET', $href); + + my $newcard = $response->{content}; + + $self->assert_matches(qr/geo:-37.810551,144.962840/, $newcard); +} diff --git a/cassandane/tiny-tests/Caldav/attendee_exdate b/cassandane/tiny-tests/Caldav/attendee_exdate new file mode 100644 index 0000000000..87348e82f4 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/attendee_exdate @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_attendee_exdate + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "recurring event"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_caldav_notified( + { + recipient => "test1\@example.com", + method => 'REPLY', + event => { + uid => $uuid, + replyTo => { imip => "mailto:test1\@example.com" }, + recurrenceOverrides => { '2016-06-08T15:30:00' => undef }, + }, + }, + ); + } +} diff --git a/cassandane/tiny-tests/Caldav/caldavcreate b/cassandane/tiny-tests/Caldav/caldavcreate new file mode 100644 index 0000000000..96bd5bcb08 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/caldavcreate @@ -0,0 +1,13 @@ +#!perl +use Cassandane::Tiny; + +sub test_caldavcreate + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); +} diff --git a/cassandane/tiny-tests/Caldav/calendar_allprop b/cassandane/tiny-tests/Caldav/calendar_allprop new file mode 100644 index 0000000000..c41bd346e4 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/calendar_allprop @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_allprop + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'mycalendar'}); + $self->assert_not_null($CalendarId); + + my $proppatchXml = < + + + + #2952A3 + + + +EOF + + my $propfindXml = < + + + +EOF + + # Set color. + my $response = $CalDAV->Request('PROPPATCH', "/dav/calendars/user/cassandane/". $CalendarId, + $proppatchXml, 'Content-Type' => 'text/xml'); + + # Assert that color is set. + $response = $CalDAV->Request('PROPFIND', "/dav/calendars/user/cassandane/". $CalendarId, + $propfindXml, 'Content-Type' => 'text/xml'); + my $propstat = $response->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]; + + $self->assert_str_equals('HTTP/1.1 200 OK', + $propstat->{'{DAV:}status'}{content}); + $self->assert(exists $propstat->{'{DAV:}prop'}{'{DAV:}creationdate'}); + $self->assert(exists $propstat->{'{DAV:}prop'}{'{DAV:}getetag'}); + $self->assert(exists $propstat->{'{DAV:}prop'}{'{DAV:}resourcetype'}); + $self->assert_str_equals('mycalendar', + $propstat->{'{DAV:}prop'}{'{DAV:}displayname'}{content}); + $self->assert_str_equals('#2952A3', + $propstat->{'{DAV:}prop'}{'{http://apple.com/ns/ical/}calendar-color'}{content}); + +} diff --git a/cassandane/tiny-tests/Caldav/calendar_query b/cassandane/tiny-tests/Caldav/calendar_query new file mode 100644 index 0000000000..7119bc4e74 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/calendar_query @@ -0,0 +1,249 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_query + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $Cal = $CalDAV->GetCalendar($CalendarId); + + xlog $self, "Load some resources"; + my $vtz = <Request('PUT', $href1, $event1, 'Content-Type' => 'text/calendar'); + $CalDAV->Request('PUT', $href2, $event2, 'Content-Type' => 'text/calendar'); + $CalDAV->Request('PUT', $href3, $event3, 'Content-Type' => 'text/calendar'); + $CalDAV->Request('PUT', $href4, $event4, 'Content-Type' => 'text/calendar'); + + xlog $self, "Perform calendar-query"; + my $xml = < + + + + + + + + + + + + + + + + + + + + + + + +EOF + + my $res = $CalDAV->Request('REPORT', + "/dav/calendars/user/cassandane/$CalendarId", + $xml, Depth => 1, 'Content-Type' => 'text/xml'); + my $responses = $res->{'{DAV:}response'}; + $self->assert_equals(2, scalar @$responses); + + my $ical = Data::ICal->new(data => + $res->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content}); + $self->assert_str_equals('One-Time Event', + $ical->{entries}[0]{properties}{summary}[0]{value}); + $self->assert_str_equals($uuid1, + $ical->{entries}[0]{properties}{uid}[0]{value}); + $self->assert_str_equals('20211215T211500Z', + $ical->{entries}[0]{properties}{dtstart}[0]{value}); + $self->assert_null($ical->{entries}[0]{properties}{dtstamp}); + $self->assert_null($ical->{entries}[0]{properties}{status}); + $self->assert_null($ical->{entries}[0]{properties}{rrule}); + $self->assert_null($ical->{entries}[0]{properties}{'recurrence-id'}); + + $ical = Data::ICal->new(data => + $res->{'{DAV:}response'}[1]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content}); + + $self->assert_str_equals('Recurring Event', + $ical->{entries}[0]{properties}{summary}[0]{value}); + $self->assert_str_equals($uuid2, + $ical->{entries}[0]{properties}{uid}[0]{value}); + $self->assert_str_equals('20211215T200000Z', + $ical->{entries}[0]{properties}{dtstart}[0]{value}); + $self->assert_null($ical->{entries}[0]{properties}{dtstamp}); + $self->assert_null($ical->{entries}[0]{properties}{status}); + $self->assert_null($ical->{entries}[0]{properties}{rrule}); + $self->assert_null($ical->{entries}[0]{properties}{'recurrence-id'}); + + $self->assert_str_equals('Recurring Event', + $ical->{entries}[1]{properties}{summary}[0]{value}); + $self->assert_str_equals($uuid2, + $ical->{entries}[1]{properties}{uid}[0]{value}); + $self->assert_str_equals('20211216T200000Z', + $ical->{entries}[1]{properties}{dtstart}[0]{value}); + $self->assert_str_equals('20211216T200000Z', + $ical->{entries}[1]{properties}{'recurrence-id'}[0]{value}); + $self->assert_null($ical->{entries}[1]{properties}{dtstamp}); + $self->assert_null($ical->{entries}[1]{properties}{status}); + $self->assert_null($ical->{entries}[1]{properties}{rrule}); + + $self->assert_str_equals('Recurring Event (exception)', + $ical->{entries}[2]{properties}{summary}[0]{value}); + $self->assert_str_equals($uuid2, + $ical->{entries}[2]{properties}{uid}[0]{value}); + $self->assert_str_equals('20211217T200000Z', + $ical->{entries}[2]{properties}{dtstart}[0]{value}); + $self->assert_str_equals('20211217T200000Z', + $ical->{entries}[2]{properties}{'recurrence-id'}[0]{value}); + $self->assert_null($ical->{entries}[2]{properties}{dtstamp}); + $self->assert_null($ical->{entries}[2]{properties}{status}); + $self->assert_null($ical->{entries}[2]{properties}{rrule}); + + $self->assert_str_equals('Recurring Event', + $ical->{entries}[3]{properties}{summary}[0]{value}); + $self->assert_str_equals($uuid2, + $ical->{entries}[3]{properties}{uid}[0]{value}); + $self->assert_str_equals('20211218T200000Z', + $ical->{entries}[3]{properties}{dtstart}[0]{value}); + $self->assert_str_equals('20211218T200000Z', + $ical->{entries}[3]{properties}{'recurrence-id'}[0]{value}); + $self->assert_null($ical->{entries}[3]{properties}{dtstamp}); + $self->assert_null($ical->{entries}[3]{properties}{status}); + $self->assert_null($ical->{entries}[3]{properties}{rrule}); +} diff --git a/cassandane/tiny-tests/Caldav/calendar_setcolor b/cassandane/tiny-tests/Caldav/calendar_setcolor new file mode 100644 index 0000000000..fa3ec2eecc --- /dev/null +++ b/cassandane/tiny-tests/Caldav/calendar_setcolor @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_setcolor + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'mycalendar'}); + $self->assert_not_null($CalendarId); + + my $proppatchXml = < + + + + #2952A3 + + + +EOF + + my $propfindXml = < + + + + + +EOF + + # Assert that color isn't set. + my $response = $CalDAV->Request('PROPFIND', "/dav/calendars/user/cassandane/". $CalendarId, + $propfindXml, 'Content-Type' => 'text/xml'); + my $propstat = $response->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]; + $self->assert_str_equals('HTTP/1.1 404 Not Found', $propstat->{'{DAV:}status'}{content}); + $self->assert(exists $propstat->{'{DAV:}prop'}{'{http://apple.com/ns/ical/}calendar-color'}); + + # Set color. + $response = $CalDAV->Request('PROPPATCH', "/dav/calendars/user/cassandane/". $CalendarId, + $proppatchXml, 'Content-Type' => 'text/xml'); + + # Assert color is set. + $response = $CalDAV->Request('PROPFIND', "/dav/calendars/user/cassandane/". $CalendarId, + $propfindXml, 'Content-Type' => 'text/xml'); + $propstat = $response->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]; + $self->assert_str_equals('HTTP/1.1 200 OK', $propstat->{'{DAV:}status'}{content}); + $self->assert_str_equals('#2952A3', $propstat->{'{DAV:}prop'}{'{http://apple.com/ns/ical/}calendar-color'}{content}); + +} diff --git a/cassandane/tiny-tests/Caldav/calendaradmin_get b/cassandane/tiny-tests/Caldav/calendaradmin_get new file mode 100644 index 0000000000..385318e132 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/calendaradmin_get @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendaradmin_get + :min_version_3_8 :needs_component_httpd :AllowCalendarAdmin +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + my $res = $caldav->ua->request('GET', $caldav->request_url(""), { + headers => { + 'Authorization' => $caldav->auth_header(), + } + }); + $self->assert_str_equals('200', $res->{status}); +} diff --git a/cassandane/tiny-tests/Caldav/calendareventnotification_no_sharee b/cassandane/tiny-tests/Caldav/calendareventnotification_no_sharee new file mode 100644 index 0000000000..92bcc44156 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/calendareventnotification_no_sharee @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_no_sharee + :needs_component_httpd :min_version_3_7 +{ + my ($self) = @_; + + my $admin = $self->{adminstore}->get_client(); + + $admin->create('user.cassandane.#jmapnotification') or die; + $admin->setacl('user.cassandane.#jmapnotification', + 'cassandane' => 'lrswipkxtecdan') or die; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $href = "$CalendarId/uid1.ics"; + my $card = < 'text/calendar', + 'Authorization' => $CalDAV->auth_header(), + ); + + xlog "Create event"; + my $Response = $CalDAV->{ua}->request('PUT', $CalDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + + $self->assert_num_equals(201, $Response->{status}); + $self->assert_num_equals(0, + $admin->message_count('user.cassandane.#jmapnotification')); + + xlog "Update event"; + $card =~ s/foo/bar/s; + $Response = $CalDAV->{ua}->request('PUT', $CalDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + + $self->assert_num_equals(204, $Response->{status}); + $self->assert_num_equals(0, + $admin->message_count('user.cassandane.#jmapnotification')); + + xlog "Delete event"; + $Response = $CalDAV->{ua}->request('DELETE', $CalDAV->request_url($href), { + headers => \%Headers, + }); + + $self->assert_num_equals(204, $Response->{status}); + $self->assert_num_equals(0, + $admin->message_count('user.cassandane.#jmapnotification')); +} diff --git a/cassandane/tiny-tests/Caldav/changes_add b/cassandane/tiny-tests/Caldav/changes_add new file mode 100644 index 0000000000..84d14a438e --- /dev/null +++ b/cassandane/tiny-tests/Caldav/changes_add @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_changes_add + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $Cal = $CalDAV->GetCalendar($CalendarId); + + my $uuid = "d4643cf9-4552-4a3e-8d6c-5f318bcc5b79"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my ($adds, $removes, $errors) = $CalDAV->SyncEvents($CalendarId, syncToken => $Cal->{syncToken}); + + $self->assert_equals(scalar @$adds, 1); + $self->assert_str_equals($adds->[0]{uid}, $uuid); + $self->assert_deep_equals($removes, []); + $self->assert_deep_equals($errors, []); +} diff --git a/cassandane/tiny-tests/Caldav/changes_remove b/cassandane/tiny-tests/Caldav/changes_remove new file mode 100644 index 0000000000..feefc25cd2 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/changes_remove @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_changes_remove + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid = "d4643cf9-4552-4a3e-8d6c-5f318bcc5b79"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my $Cal = $CalDAV->GetCalendar($CalendarId); + + $CalDAV->DeleteEvent($href); + + my ($adds, $removes, $errors) = $CalDAV->SyncEvents($CalendarId, syncToken => $Cal->{syncToken}); + + $self->assert_deep_equals([], $adds); + $self->assert_equals(1, scalar @$removes); + $self->assert_str_equals("/dav/calendars/user/cassandane/" . $href, $removes->[0]); + $self->assert_deep_equals([], $errors); +} diff --git a/cassandane/tiny-tests/Caldav/conditional_delete_collection b/cassandane/tiny-tests/Caldav/conditional_delete_collection new file mode 100644 index 0000000000..fa38d0c9c4 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/conditional_delete_collection @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_conditional_delete_collection + :needs_component_httpd +{ + my ($self) = @_; + + my $caldav = $self->{caldav}; + + my $calid = $caldav->NewCalendar({name => 'foo'}); + $self->assert_not_null($calid); + + my $res = $caldav->GetCalendar($calid); + $self->assert_not_null($res); + my $synctoken = $res->{syncToken}; + + xlog $self, "Try to delete collection with bogus state token"; + $res = $caldav->ua->request('DELETE', $caldav->request_url($calid), { + headers => { + 'Authorization' => $caldav->auth_header(), + 'If' => '()' + } + }); + $self->assert_str_equals('412', $res->{status}); + + xlog $self, "Delete collection with bogus sync token"; + $res = $caldav->ua->request('DELETE', $caldav->request_url($calid), { + headers => { + 'Authorization' => $caldav->auth_header(), + 'If' => "(<$synctoken>)" + } + }); + $self->assert_str_equals('204', $res->{status}); +} diff --git a/cassandane/tiny-tests/Caldav/dav_bind b/cassandane/tiny-tests/Caldav/dav_bind new file mode 100644 index 0000000000..c5b9753e35 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/dav_bind @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_dav_bind + :min_version_3_9 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.manifold"); + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + my $service = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create calendar"; + my $CalendarId = $mantalk->NewCalendar({name => 'Manifold Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "share to user (without 'k' or 'x')"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId", "cassandane" => 'lrspwiten'); + + my $propfindXml = < + + + + + +EOF + + # Assert that {DAV:}bind and {DAV:}unbind are present. + my $res = $CalDAV->Request('PROPFIND', "/dav/calendars/user/cassandane/manifold.". $CalendarId, + $propfindXml, 'Content-Type' => 'text/xml'); + my $text = Dumper($res); + $self->assert_matches(qr/{DAV:}bind/, $text); + $self->assert_matches(qr/{DAV:}unbind/, $text); +} diff --git a/cassandane/tiny-tests/Caldav/davsharing b/cassandane/tiny-tests/Caldav/davsharing new file mode 100644 index 0000000000..4d6e906998 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/davsharing @@ -0,0 +1,137 @@ +#!perl +use Cassandane::Tiny; + +sub test_davsharing + :min_version_3_0 :needs_component_httpd :NoVirtDomains +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.manifold"); + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + my $service = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $invite = < + + + mailto:cassandane\@example.com + + Cassandane + + Shared calendar + + + + + +EOF + + my $reply = < + + + + /dav/calendars/user/cassandane/ + + Thanks for the share! + +EOF + + xlog $self, "create calendar"; + my $CalendarId = $mantalk->NewCalendar({name => 'Manifold Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "share to user"; + $mantalk->Request('POST', $CalendarId, $invite, + 'Content-Type' => 'application/davsharing+xml'); + + xlog $self, "fetch invite"; + my ($adds) = $CalDAV->SyncEventLinks("/dav/notifications/user/cassandane"); + $self->assert_equals(scalar %$adds, 1); + my $notification = (keys %$adds)[0]; + + xlog $self, "accept invite"; + $CalDAV->Request('POST', $notification, $reply, + 'Content-Type' => 'application/davsharing+xml'); + + xlog $self, "fetch invite reply"; + ($adds) = $mantalk->SyncEventLinks("/dav/notifications/user/manifold"); + $self->assert_equals(scalar %$adds, 1); + $notification = (keys %$adds)[0]; + + my $res = $mantalk->Request('GET', $notification); + my $xml = xmlToHash($res->{content}); + my $CS = 'http://calendarserver.org/ns/'; + $reply = $xml->{"{$CS}invite-reply"}; + $self->assert_not_null($reply); + $self->assert_not_null($reply->{"{$CS}invite-accepted"}); + $self->assert_str_equals($mantalk->fullpath($CalendarId) . "/", + $reply->{"{$CS}hosturl"}{'{DAV:}href'}{content}); + $self->assert_str_equals(basename($notification), + $reply->{"{$CS}in-reply-to"}{content}); + + # need to version-gate features that aren't in 3.0... + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min >= 9)) { + $self->assert_str_equals('Test User', + $reply->{"{$CS}common-name"}{content}); + } + + xlog $self, "get calendars as manifold"; + my $ManCal = $mantalk->GetCalendars(); + $self->assert_num_equals(2, scalar @$ManCal); + my $names = join "/", sort map { $_->{name} } @$ManCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); + + xlog $self, "get calendars as cassandane"; + my $CasCal = $CalDAV->GetCalendars(); + $self->assert_num_equals(2, scalar @$CasCal); + $names = join "/", sort map { $_->{name} } @$CasCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); + + xlog $self, "Update calendar name as cassandane"; + my ($CasId) = map { $_->{id} } grep { $_->{name} eq 'Manifold Calendar' } @$CasCal; + $CalDAV->UpdateCalendar({id => $CasId, name => "Cassandane Name"}); + + xlog $self, "changed as cassandane"; + $CasCal = $CalDAV->GetCalendars(); + $self->assert_num_equals(2, scalar @$CasCal); + $names = join "/", sort map { $_->{name} } @$CasCal; + $self->assert_str_equals($names, "Cassandane Name/personal"); + + xlog $self, "unchanged as manifold"; + $ManCal = $mantalk->GetCalendars(); + $self->assert_num_equals(2, scalar @$ManCal); + $names = join "/", sort map { $_->{name} } @$ManCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); + + xlog $self, "delete calendar as cassandane"; + $CalDAV->DeleteCalendar($CasId); + + xlog $self, "changed as cassandane"; + $CasCal = $CalDAV->GetCalendars(); + $self->assert_num_equals(1, scalar @$CasCal); + $names = join "/", sort map { $_->{name} } @$CasCal; + $self->assert_str_equals($names, "personal"); + + xlog $self, "unchanged as manifold"; + $ManCal = $mantalk->GetCalendars(); + $self->assert_num_equals(2, scalar @$ManCal); + $names = join "/", sort map { $_->{name} } @$ManCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); +} diff --git a/cassandane/tiny-tests/Caldav/defaultalarms b/cassandane/tiny-tests/Caldav/defaultalarms new file mode 100644 index 0000000000..72e30c104a --- /dev/null +++ b/cassandane/tiny-tests/Caldav/defaultalarms @@ -0,0 +1,122 @@ +#!perl +use Cassandane::Tiny; + +sub test_defaultalarms + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + # For JMAP calendars, we refactored CalDAV default alarm property + # handling from a regular dead DAV property to a structured value. + # This test asserts that CalDAV clients won't notice the difference. + + my $rawAlarmDateTime = < + + + + +$rawAlarmDateTime + + + + + + +$rawAlarmDate + + + + +EOF + $CalDAV->Request('PROPPATCH', "/dav/calendars/user/cassandane/Default", + $proppatchXml, 'Content-Type' => 'text/xml'); + + xlog "Get alarms"; + my $propfindXml = < + + + + + + +EOF + my $Response = $CalDAV->Request('PROPFIND', "/dav/calendars/user/cassandane/Default", + $propfindXml, 'Content-Type' => 'text/xml'); + + xlog "Assert alarm values"; + my $assert_propval = sub { + my ($Response, $propname, $wantVal, $wantStatus) = @_; + my $propStat = $Response->{'{DAV:}response'}[0]->{'{DAV:}propstat'}[0]; + my $prop = $propStat->{'{DAV:}prop'}; + $wantVal =~ s/^\s+|\s+$//g; + my $got = $prop->{'{urn:ietf:params:xml:ns:caldav}'. $propname}->{content}; + $got =~ s/^\s+|\s+$//g; + $self->assert_str_equals($wantVal, $got); + my $status = $propStat->{'{DAV:}status'}; + $self->assert_str_equals($wantStatus, $status->{content}); + }; + $assert_propval->($Response, 'default-alarm-vevent-datetime', + $rawAlarmDateTime, 'HTTP/1.1 200 OK'); + $assert_propval->($Response, 'default-alarm-vevent-date', + $rawAlarmDate, 'HTTP/1.1 200 OK'); + + xlog "Remove alarms"; + $proppatchXml = < + + + + + + + + +EOF + $CalDAV->Request('PROPPATCH', "/dav/calendars/user/cassandane/Default", + $proppatchXml, 'Content-Type' => 'text/xml'); + + xlog "Get alarms"; + $propfindXml = < + + + + + + +EOF + $Response = $CalDAV->Request('PROPFIND', "/dav/calendars/user/cassandane/Default", + $propfindXml, 'Content-Type' => 'text/xml'); + + xlog "Assert alarm values do not exist"; + $assert_propval->($Response, 'default-alarm-vevent-datetime', + '', 'HTTP/1.1 404 Not Found'); + $assert_propval->($Response, 'default-alarm-vevent-date', + '', 'HTTP/1.1 404 Not Found'); +} diff --git a/cassandane/tiny-tests/Caldav/delete_recur_extraattendee b/cassandane/tiny-tests/Caldav/delete_recur_extraattendee new file mode 100644 index 0000000000..3a8332ce6f --- /dev/null +++ b/cassandane/tiny-tests/Caldav/delete_recur_extraattendee @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_delete_recur_extraattendee + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "set up event"; + my $uuid = $CalDAV->genuuid(); + my $overrides = <_put_event($CalendarId, uuid => $uuid, lines => < $overrides); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test1\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test3\@example.com +RRULE:FREQ=WEEKLY +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->{instance}->getnotify(); + my $href = "$CalendarId/$uuid.ics"; + $self->{caldav}->Request('DELETE', $href); + + my $except = { + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test2\@example.com" => { email => "test2\@example.com" }, + "test3\@example.com" => { email => "test3\@example.com" }, + }, + }; + + my $regular = { + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test3\@example.com" => { email => "test3\@example.com" }, + }, + recurrenceOverrides => { + '2016-06-08T15:30:00' => $except, + '2016-06-15T15:30:00' => $except, + }, + }; + + $self->assert_caldav_notified( + { + method => 'CANCEL', + recipient => "test1\@example.com", + event => $regular, + }, + { + method => 'CANCEL', + recipient => "test2\@example.com", + event => { + recurrenceOverrides => { + '2016-06-08T15:30:00' => $except, + '2016-06-15T15:30:00' => $except, + }, + }, + }, + { + method => 'CANCEL', + recipient => "test3\@example.com", + event => $regular, + }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/empty_summary b/cassandane/tiny-tests/Caldav/empty_summary new file mode 100644 index 0000000000..dd022f6349 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/empty_summary @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_empty_summary + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => ''}); + $self->assert_not_null($CalendarId); + + my $uuid = "2b82ea51-50b0-4c6b-a9b4-e8ff0f931ba2"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); +} diff --git a/cassandane/tiny-tests/Caldav/event_move b/cassandane/tiny-tests/Caldav/event_move new file mode 100644 index 0000000000..042e7a6184 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/event_move @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_event_move + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid1 = "d4643cf9-4552-4a3e-8d6c-5f318bcc5b79"; + my $href = "$CalendarId/$uuid1.ics"; + my $card1 = <Request('PUT', $href, $card1, 'Content-Type' => 'text/calendar'); + + my $DestCal = $CalDAV->GetCalendar($CalendarId); + + my $uuid2 = "event2\@example.com"; + my $card2 = <Request('PUT', $href, $card2, 'Content-Type' => 'text/calendar'); + + my $SrcCal = $CalDAV->GetCalendar('Default'); + + $CalDAV->MoveEvent($href, $CalendarId); + + my ($adds, $removes, $errors) = $CalDAV->SyncEvents('Default', syncToken => $SrcCal->{syncToken}); + $self->assert_deep_equals([], $adds); + $self->assert_equals(1, scalar @$removes); + $self->assert_str_equals("/dav/calendars/user/cassandane/" . $href, $removes->[0]); + $self->assert_deep_equals([], $errors); + + ($adds, $removes, $errors) = $CalDAV->SyncEvents($CalendarId, syncToken => $DestCal->{syncToken}); + + $self->assert_equals(1, scalar @$adds); + $self->assert_str_equals($adds->[0]{uid}, $uuid2); + $self->assert_deep_equals([], $removes); + $self->assert_deep_equals([], $errors); +} diff --git a/cassandane/tiny-tests/Caldav/fantastical_strip_prior_overrides b/cassandane/tiny-tests/Caldav/fantastical_strip_prior_overrides new file mode 100644 index 0000000000..0e1c3877bb --- /dev/null +++ b/cassandane/tiny-tests/Caldav/fantastical_strip_prior_overrides @@ -0,0 +1,102 @@ +#!perl +use Cassandane::Tiny; + +sub test_fantastical_strip_prior_overrides + :needs_component_httpd :min_version_3_9 +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid = "BB19B9E8-CDFB-4163-873E-EE0B9714F919"; + my $href = "$CalendarId/$uuid.ics"; + my $event = <Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Make sure prior overrides are removed but subsequent remain"; + my $response = $CalDAV->Request('GET', $href); + my $newevent = $response->{content}; + + $self->assert_does_not_match(qr|RECURRENCE-ID;TZID=America/New_York:20230313T140000|, $newevent); + $self->assert_does_not_match(qr|RECURRENCE-ID;TZID=America/New_York:20230315T140000|, $newevent); + $self->assert_matches(qr|RECURRENCE-ID;TZID=America/New_York:20230317T140000|, $newevent); + $self->assert_matches(qr|RECURRENCE-ID;TZID=America/New_York:20230312T120000|, $newevent); + $self->assert_matches(qr|SUMMARY:Test override of RDATE before DTSTART|, $newevent); +} diff --git a/cassandane/tiny-tests/Caldav/fastmailsharing b/cassandane/tiny-tests/Caldav/fastmailsharing new file mode 100644 index 0000000000..488e3e9c41 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/fastmailsharing @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_fastmailsharing + :FastmailSharing :ReverseACLs :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.manifold"); + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + my $service = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create calendar"; + my $CalendarId = $mantalk->NewCalendar({name => 'Manifold Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId", "cassandane" => 'lrswipcdn'); + + xlog $self, "get calendars as cassandane"; + my $CasCal = $CalDAV->GetCalendars(); + $self->assert_num_equals(2, scalar @$CasCal); + my $names = join "/", sort map { $_->{name} } @$CasCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); + + xlog $self, "get calendars as manifold"; + my $ManCal = $mantalk->GetCalendars(); + $self->assert_num_equals(2, scalar @$ManCal); + $names = join "/", sort map { $_->{name} } @$ManCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); + + xlog $self, "Update calendar name as cassandane"; + my ($CasId) = map { $_->{id} } grep { $_->{name} eq 'Manifold Calendar' } @$CasCal; + $CalDAV->UpdateCalendar({id => $CasId, name => "Cassandane Name"}); + + xlog $self, "changed as cassandane"; + $CasCal = $CalDAV->GetCalendars(); + $self->assert_num_equals(2, scalar @$CasCal); + $names = join "/", sort map { $_->{name} } @$CasCal; + $self->assert_str_equals($names, "Cassandane Name/personal"); + + xlog $self, "unchanged as manifold"; + $ManCal = $mantalk->GetCalendars(); + $self->assert_num_equals(2, scalar @$ManCal); + $names = join "/", sort map { $_->{name} } @$ManCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); + + xlog $self, "delete calendar as cassandane"; + $CalDAV->DeleteCalendar($CasId); + + xlog $self, "changed as cassandane"; + $CasCal = $CalDAV->GetCalendars(); + $self->assert_num_equals(1, scalar @$CasCal); + $names = join "/", sort map { $_->{name} } @$CasCal; + $self->assert_str_equals($names, "personal"); + + xlog $self, "unchanged as manifold"; + $ManCal = $mantalk->GetCalendars(); + $self->assert_num_equals(2, scalar @$ManCal); + $names = join "/", sort map { $_->{name} } @$ManCal; + $self->assert_str_equals($names, "Manifold Calendar/personal"); +} diff --git a/cassandane/tiny-tests/Caldav/freebusy b/cassandane/tiny-tests/Caldav/freebusy new file mode 100644 index 0000000000..c36e2d4ce9 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/freebusy @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_freebusy + :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + $CalDAV->NewEvent($CalendarId, { + timeZone => 'Etc/UTC', + start => '2015-01-01T12:00:00', + duration => 'PT1H', + summary => 'waterfall', + }); + + $CalDAV->NewEvent($CalendarId, { + timeZone => 'America/New_York', + start => '2015-02-01T12:00:00', + duration => 'PT1H', + summary => 'waterfall2', + }); + + my ($data, $errors) = $CalDAV->GetFreeBusy($CalendarId); + + $self->assert_str_equals('2015-01-01T12:00:00', $data->[0]{start}); + $self->assert_str_equals('2015-02-01T17:00:00', $data->[1]{start}); + $self->assert_num_equals(2, scalar @$data); +} diff --git a/cassandane/tiny-tests/Caldav/freebusy_floating b/cassandane/tiny-tests/Caldav/freebusy_floating new file mode 100644 index 0000000000..c173e3fd5d --- /dev/null +++ b/cassandane/tiny-tests/Caldav/freebusy_floating @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_freebusy_floating + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo', timeZone => $self->MELBOURNE}); + $self->assert_not_null($CalendarId); + + $CalDAV->NewEvent($CalendarId, { + start => '2015-01-01T12:00:00', + duration => 'PT1H', + summary => 'waterfall', + }); + + $CalDAV->NewEvent($CalendarId, { + start => '2015-02-01T12:00:00', + duration => 'PT1H', + summary => 'waterfall2', + }); + + my ($data, $errors) = $CalDAV->GetFreeBusy($CalendarId); + + $self->assert_str_equals('2015-01-01T01:00:00', $data->[0]{start}); + $self->assert_str_equals('2015-02-01T01:00:00', $data->[1]{start}); + $self->assert_num_equals(2, scalar @$data); + + my $new_york = $self->NEW_YORK; + + # Change floating time zone on the calendar + my $xml = < + + + + $new_york + + + +EOF + + my $res = $CalDAV->Request('PROPPATCH', + "/dav/calendars/user/cassandane/". $CalendarId, + $xml, 'Content-Type' => 'text/xml'); + + ($data, $errors) = $CalDAV->GetFreeBusy($CalendarId); + + $self->assert_str_equals('2015-01-01T17:00:00', $data->[0]{start}); + $self->assert_str_equals('2015-02-01T17:00:00', $data->[1]{start}); + $self->assert_num_equals(2, scalar @$data); +} diff --git a/cassandane/tiny-tests/Caldav/freebusy_overrides b/cassandane/tiny-tests/Caldav/freebusy_overrides new file mode 100644 index 0000000000..696fe076ef --- /dev/null +++ b/cassandane/tiny-tests/Caldav/freebusy_overrides @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_freebusy_overrides + :min_version_3_9 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $ical = <Request('PUT', '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + my ($data, $errors) = $CalDAV->GetFreeBusy('Default'); + $self->assert_str_equals('2023-01-01T16:00:00', $data->[0]{start}); + $self->assert_str_equals('PT1H', $data->[0]{duration}); + $self->assert_str_equals('2023-12-31T16:00:00', $data->[1]{start}); + $self->assert_str_equals('PT2H', $data->[1]{duration}); + $self->assert_num_equals(2, scalar @$data); +} diff --git a/cassandane/tiny-tests/Caldav/get_control_char b/cassandane/tiny-tests/Caldav/get_control_char new file mode 100644 index 0000000000..1e67d5baad --- /dev/null +++ b/cassandane/tiny-tests/Caldav/get_control_char @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_get_control_char + :min_version_3_9 :needs_component_httpd :needs_ical_ctrl :MagicPlus +{ + my ($self) = @_; + + my $caldav = $self->{caldav}; + my $plusstore = $self->{instance}->get_service('imap' + )->create_store(username => 'cassandane+dav'); + my $imap = $plusstore->get_client(); + + # Assert that CONTROL chars are omitted when reading + # iCalendar data from disk. + + my $mimeMsg = < +Subject: test +Date: Mon, 28 Sep 2015 15:24:34 +0200 +Message-ID: +Content-Type: text/calendar; charset=utf-8; component=VEVENT +Content-Transfer-Encoding: 8bit +Content-Disposition: attachment; + filename*0="test.ics" +Content-Length: 394 +MIME-Version: 1.0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:40d6fe3c-6a51-489e-823e-3ea22f427a3e +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION:ct\x{15}rl +SUMMARY:test +CLASS:PRIVATE +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $mimeMsg =~ s/\r?\n/\r\n/gs; + $imap->append('#calendars.Default', $mimeMsg) || die $@; + + my $res = $caldav->Request('GET', '/dav/calendars/user/cassandane/Default/test.ics'); + $self->assert_matches(qr/DESCRIPTION:ctrl/, $res->{content}); +} diff --git a/cassandane/tiny-tests/Caldav/get_legacy_defaultalarm_no_uid b/cassandane/tiny-tests/Caldav/get_legacy_defaultalarm_no_uid new file mode 100644 index 0000000000..6fbe697796 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/get_legacy_defaultalarm_no_uid @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_get_legacy_defaultalarm_no_uid + :min_version_3_9 :needs_component_jmap :MagicPlus +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $plusstore = $self->{instance}->get_service('imap' + )->create_store(username => 'cassandane+dav'); + my $imap = $plusstore->get_client(); + + xlog $self, "Pretend as if JMAP default alarm migration never happened"; + $imap->setmetadata("#calendars.Default", + '/private/vendor/cmu/cyrus-jmap/defaultalerts', ''); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); + + xlog $self, "Create event with a VALARM having no UID and default alerts enabled"; + my $eventUid = '4c9aff9c-91df-4859-8026-772a82f52094'; + my $ical = <Request('PUT', + "/dav/calendars/user/cassandane/Default/test.ics", + $ical, + 'Content-Type' => 'text/calendar', + 'X-Cyrus-rewrite-usedefaultalerts' => 'false', + ); + + xlog $self, "Set CalDAV default alarms with VALARM having no UID"; + $imap->setmetadata("#calendars.Default", + '/shared/vendor/cmu/cyrus-httpd/' . + 'default-alarm-vevent-datetime', + <assert_str_equals('ok', $imap->get_last_completion_response()); + + xlog $self, "Assert VALARM in event has UID"; + $res = $caldav->Request('GET', + "/dav/calendars/user/cassandane/Default/test.ics"); + + my $vcal = Text::VCardFast::vcard2hash($res->{content}); + my @valarms = grep { $_->{type} eq 'valarm' } + @{$vcal->{objects}[0]->{objects}[0]->{objects}}; + $self->assert_num_equals(1, scalar @valarms); + $self->assert_not_null($valarms[0]{properties}{uid}); +} diff --git a/cassandane/tiny-tests/Caldav/header_cache_control b/cassandane/tiny-tests/Caldav/header_cache_control new file mode 100644 index 0000000000..875373352b --- /dev/null +++ b/cassandane/tiny-tests/Caldav/header_cache_control @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_header_cache_control + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + # Create an event + my $href = "$CalendarId/event1.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # Check that we can get the event via the CalDAV module + my $response = $CalDAV->Request('GET', $href); + $self->assert_matches(qr{An Event}, $response->{content}); + + my %Headers = ( + 'Authorization' => $CalDAV->auth_header(), + ); + my $URI = $CalDAV->request_url($href); + + # Request the event without an authorization header + $response = $CalDAV->{ua}->get($URI, { headers => {} }); + + # Should be rejected + $self->assert_num_equals(401, $response->{status}); + $self->assert_str_equals('Unauthorized', $response->{reason}); + + # Request the event with an authorization header + $response = $CalDAV->{ua}->get($URI, { headers => \%Headers }); + + # Should have Cache-Control: private set + $self->assert_matches(qr{An Event}, $response->{content}); + $self->assert_matches(qr{\bprivate\b}, + $response->{headers}->{'cache-control'}); +} diff --git a/cassandane/tiny-tests/Caldav/imap_magicplus_withdomain b/cassandane/tiny-tests/Caldav/imap_magicplus_withdomain new file mode 100644 index 0000000000..18222e9df2 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/imap_magicplus_withdomain @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_imap_magicplus_withdomain + :MagicPlus :VirtDomains :min_version_3_0 :needs_component_httpd :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user.domuser@example.com'); + + my $service = $self->{instance}->get_service("http"); + my $domdav = Net::CalDAVTalk->new( + user => 'domuser@example.com', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $domdav->NewCalendar({name => 'magicplus'}); + $self->assert_not_null($CalendarId); + + my $plusstore = $self->{instance}->get_service('imap')->create_store(username => 'domuser+dav@example.com'); + my $talk = $plusstore->get_client(); + + my $list = $talk->list('', '*'); + my ($this) = grep { $_->[2] eq "INBOX.#calendars.$CalendarId" } @$list; + $self->assert_not_null($this); +} diff --git a/cassandane/tiny-tests/Caldav/imap_plusdav b/cassandane/tiny-tests/Caldav/imap_plusdav new file mode 100644 index 0000000000..348919c1aa --- /dev/null +++ b/cassandane/tiny-tests/Caldav/imap_plusdav @@ -0,0 +1,20 @@ +#!perl +use Cassandane::Tiny; + +sub test_imap_plusdav + :MagicPlus :VirtDomains :min_version_3_0 :needs_component_httpd :NoAltNameSpace +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'magicplus'}); + $self->assert_not_null($CalendarId); + + my $plusstore = $self->{instance}->get_service('imap')->create_store(username => 'cassandane+dav'); + my $talk = $plusstore->get_client(); + + my $list = $talk->list('', '*'); + my ($this) = grep { $_->[2] eq "INBOX.#calendars.$CalendarId" } @$list; + $self->assert_not_null($this); +} diff --git a/cassandane/tiny-tests/Caldav/imap_plusdav_novirt b/cassandane/tiny-tests/Caldav/imap_plusdav_novirt new file mode 100644 index 0000000000..a1dbfea582 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/imap_plusdav_novirt @@ -0,0 +1,20 @@ +#!perl +use Cassandane::Tiny; + +sub test_imap_plusdav_novirt + :MagicPlus :min_version_3_0 :needs_component_httpd :NoAltNameSpace +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'magicplus'}); + $self->assert_not_null($CalendarId); + + my $plusstore = $self->{instance}->get_service('imap')->create_store(username => 'cassandane+dav'); + my $talk = $plusstore->get_client(); + + my $list = $talk->list('', '*'); + my ($this) = grep { $_->[2] eq "INBOX.#calendars.$CalendarId" } @$list; + $self->assert_not_null($this); +} diff --git a/cassandane/tiny-tests/Caldav/imip_encode_address b/cassandane/tiny-tests/Caldav/imip_encode_address new file mode 100644 index 0000000000..fd7f5d0fae --- /dev/null +++ b/cassandane/tiny-tests/Caldav/imip_encode_address @@ -0,0 +1,112 @@ +#!perl +use warnings; +use strict; + +use Cassandane::Tiny; +use Data::UUID; + +sub test_imip_encode_address + :needs_component_httpd + :NoStartInstances + :want_smtpdaemon +{ + my ($self) = @_; + + $self->{instance}->{config}->set('imipnotifier' => undef); + $self->_start_instances(); + $self->_setup_http_service_objects(); + + my $service = $self->{instance}->get_service("http"); + my $caldav = $self->{caldav}; + + my @testCases = ( + { mailto => 'a@example.com', + cn => 'A', + expect => 'A ', + }, + { mailto => 'b@example.com', + cn => 'A ', + expect => '"A " ', + }, + { mailto => 'c@example.com', + cn => "A \N{TOMATO} B", + expect => '=?UTF-8?Q?A_=F0=9F=8D=85_B?= ', + }, + { mailto => 'd@example.com', + cn => 'A "T" B', + expect => '"A \"T\" B" ', + }, + { mailto => 'e@example.com', + cn => 'A \ B', + expect => '"A \\\\ B" ', + }, + { mailto => 'f@example.com', + cn => 'A,B', + expect => '"A,B" ', + }, + { mailto => 'g@example.com', + cn => undef, + expect => 'g@example.com', + }, + ); + + my $uuidgen = Data::UUID->new; + + foreach my $tc (@testCases) { + my $uid = $uuidgen->create_str; + my $ical = <{cn}) { + $ical .= "ORGANIZER;CN=$tc->{cn}:MAILTO:$tc->{mailto}\n"; + } else { + $ical .= "ORGANIZER:MAILTO:$tc->{mailto}\n"; + } + + $ical .= <Request( + 'PUT', "Default/$uid.ics", $ical, + 'Content-Type' => 'text/calendar; charset=utf-8' + ); + } + + my %recipients = (); + + my $messages_dir = $self->{instance}->get_basedir() . '/smtpd'; + opendir(my $dh, $messages_dir) or die "opendir $messages_dir: $!"; + while (readdir $dh) { + next if not m/\.smtp$/; + + my $message_file = "$messages_dir/$_"; + open(my $fh, '<', $message_file) or die "open $message_file: $!"; + while (<$fh>) { + s/[\x0d\x0a]{1,2}$//; # leniently chomp eol chars + last if not $_; # empty line: end of headers + + if (m/^To: (.*(\w+\@example\.com)>?)$/) { + $recipients{$2} = $1; + } + } + close $fh; + } + closedir $dh; + + foreach my $tc (@testCases) { + $self->assert_str_equals($tc->{expect}, + $recipients{$tc->{mailto}}); + } +} diff --git a/cassandane/tiny-tests/Caldav/invite b/cassandane/tiny-tests/Caldav/invite new file mode 100644 index 0000000000..f32dd9d9c4 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/invite_add_another b/cassandane/tiny-tests/Caldav/invite_add_another new file mode 100644 index 0000000000..aa900adc6c --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_add_another @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_add_another + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + $card =~ s/ORGANIZER/ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:friend2\@example.com\nORGANIZER/; + $card =~ s/SEQUENCE:0/SEQUENCE:1/; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/invite_add_another_to_override b/cassandane/tiny-tests/Caldav/invite_add_another_to_override new file mode 100644 index 0000000000..79a43517cc --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_add_another_to_override @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_add_another_to_override + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + $card =~ s/SEQUENCE:0/SEQUENCE:1/; + $card =~ s/RECURRENCE-ID/ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:friend2\@example.com\nRECURRENCE-ID/; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/invite_change_organizer b/cassandane/tiny-tests/Caldav/invite_change_organizer new file mode 100644 index 0000000000..ed3367cdcd --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_change_organizer @@ -0,0 +1,127 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_change_organizer + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + # change organizer and move the event 1 hour later + $card = <Request('PUT', $href, $card, + 'Content-Type' => 'text/calendar', + 'Schedule-Address' => 'otherme@example.com', + 'Allow-Organizer-Change' => 'yes', + ); + + $self->assert_caldav_notified( + { + recipient => "friend\@example.com", + is_update => JSON::true, + method => 'REQUEST', + event => { + replyTo => { + imip => 'mailto:otherme@example.com', + }, + }, + }, + { recipient => "cassandane\@example.com", is_update => JSON::false, method => 'CANCEL' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/invite_change_organizer_recur b/cassandane/tiny-tests/Caldav/invite_change_organizer_recur new file mode 100644 index 0000000000..ed98f8d24e --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_change_organizer_recur @@ -0,0 +1,143 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_change_organizer_recur + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8401"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + # change organizer and make the event 1 hour shorter, removing recurrence + $card = <Request('PUT', $href, $card, + 'Content-Type' => 'text/calendar', + 'Schedule-Address' => 'otherme@example.com', + 'Allow-Organizer-Change' => 'yes', + ); + + $self->assert_caldav_notified( + { + recipient => "friend\@example.com", + is_update => JSON::true, + method => 'REQUEST', + event => { + replyTo => { + imip => 'mailto:otherme@example.com', + }, + }, + }, + { recipient => "cassandane\@example.com", is_update => JSON::false, method => 'CANCEL' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/invite_from_nonsched b/cassandane/tiny-tests/Caldav/invite_from_nonsched new file mode 100644 index 0000000000..5e0d84032f --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_from_nonsched @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_from_nonsched + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my $data = $self->{instance}->getnotify(); + + my $extra = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/invite_fullvirtual b/cassandane/tiny-tests/Caldav/invite_fullvirtual new file mode 100644 index 0000000000..dd1342d69f --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_fullvirtual @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_fullvirtual + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user.domuser@example.com'); + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "domuser\@example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <{instance}->getnotify(); + + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my $newdata = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$newdata; + $self->assert_not_null($imip); + my $payload = decode_json($imip->{MESSAGE}); + + $self->assert_str_equals($payload->{recipient}, "friend\@example.com"); +} diff --git a/cassandane/tiny-tests/Caldav/invite_samelocalpart b/cassandane/tiny-tests/Caldav/invite_samelocalpart new file mode 100644 index 0000000000..14f4a2bbe4 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_samelocalpart @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_samelocalpart + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "cassandane\@othersite.com", is_update => JSON::false, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/invite_switch_duration_to_dtend b/cassandane/tiny-tests/Caldav/invite_switch_duration_to_dtend new file mode 100644 index 0000000000..93803ab5f7 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_switch_duration_to_dtend @@ -0,0 +1,111 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_switch_duration_to_dtend + :VirtDomains :min_version_3_7 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is sent to attendee"; + $self->assert_caldav_notified( + { recipient => "friend\@example.com", + is_update => JSON::false, method => 'REQUEST' }, + ); + + xlog $self, "update event using DTEND"; + $card =~ s|DURATION:PT3H|DTEND;TZID=Australia/Melbourne:20160831T183000|; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is NOT sent to attendee"; + my $newdata = $self->{instance}->getnotify(); + my @imip = grep { $_->{METHOD} eq 'imip' } @$newdata; + $self->assert_num_equals(0, scalar(@imip)); + + xlog $self, "update event using DURATION"; + $card =~ s|DTEND;TZID=Australia/Melbourne:20160831T183000|DURATION:PT3H|; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is NOT sent to attendee"; + $newdata = $self->{instance}->getnotify(); + @imip = grep { $_->{METHOD} eq 'imip' } @$newdata; + $self->assert_num_equals(0, scalar(@imip)); + + xlog $self, "update event using DTEND with different TZID"; + $card =~ s|DURATION:PT3H|DTEND;TZID=Australia/Sydney:20160831T183000|; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is sent to attendee"; + $self->assert_caldav_notified( + { recipient => "friend\@example.com", + is_update => JSON::true, method => 'REQUEST' }, + ); + + xlog $self, "update event using DURATION"; + $card =~ s|DTEND;TZID=Australia/Sydney:20160831T183000|DURATION:PT3H|; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is sent to attendee"; + $self->assert_caldav_notified( + { recipient => "friend\@example.com", + is_update => JSON::true, method => 'REQUEST' }, + ); + + xlog $self, "update event using DTEND with same TZID"; + $card =~ s|DURATION:PT3H|DTEND;TZID=Australia/Melbourne:20160831T183000|; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is NOT sent to attendee"; + $newdata = $self->{instance}->getnotify(); + @imip = grep { $_->{METHOD} eq 'imip' } @$newdata; + $self->assert_num_equals(0, scalar(@imip)); + + xlog $self, "update event changing TZID on DTEND"; + $card =~ s|DTEND;TZID=Australia/Melbourne|DTEND;TZID=Australia/Sydney|; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is sent to attendee"; + $self->assert_caldav_notified( + { recipient => "friend\@example.com", + is_update => JSON::true, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/invite_switch_implicit_allday_to_dtend b/cassandane/tiny-tests/Caldav/invite_switch_implicit_allday_to_dtend new file mode 100644 index 0000000000..0756057056 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_switch_implicit_allday_to_dtend @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_switch_implicit_allday_to_dtend + :VirtDomains :min_version_3_7 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is sent to attendee"; + $self->assert_caldav_notified( + { recipient => "friend\@example.com", + is_update => JSON::false, method => 'REQUEST' }, + ); + + xlog $self, "update event using DTEND"; + $card =~ s|DTSTAMP|DTEND;VALUE=DATE:20160901\r\nDTSTAMP|; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is NOT sent to attendee"; + my $newdata = $self->{instance}->getnotify(); + my @imip = grep { $_->{METHOD} eq 'imip' } @$newdata; + $self->assert_num_equals(0, scalar(@imip)); + + xlog $self, "update event removing DTEND"; + $card =~ s|DTEND;VALUE=DATE:20160901\r\n||; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is NOT sent to attendee"; + $newdata = $self->{instance}->getnotify(); + @imip = grep { $_->{METHOD} eq 'imip' } @$newdata; + $self->assert_num_equals(0, scalar(@imip)); + + xlog $self, "update event to be multiple days"; + $card =~ s|DTSTAMP|DTEND;VALUE=DATE:20160902\r\nDTSTAMP|; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is sent to attendee"; + $newdata = $self->{instance}->getnotify(); + @imip = grep { $_->{METHOD} eq 'imip' } @$newdata; + $self->assert_num_equals(1, scalar(@imip)); + + xlog $self, "update event removing DTEND"; + $card =~ s|DTEND;VALUE=DATE:20160902\r\n||; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "make sure an invite is sent to attendee"; + $newdata = $self->{instance}->getnotify(); + @imip = grep { $_->{METHOD} eq 'imip' } @$newdata; + $self->assert_num_equals(1, scalar(@imip)); +} diff --git a/cassandane/tiny-tests/Caldav/invite_withheader b/cassandane/tiny-tests/Caldav/invite_withheader new file mode 100644 index 0000000000..b99e20627c --- /dev/null +++ b/cassandane/tiny-tests/Caldav/invite_withheader @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_invite_withheader + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <{instance}->getnotify(); + + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar', 'Schedule-Address' => 'cassandane@example.net'); + + my $newdata = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$newdata; + my $payload = decode_json($imip->{MESSAGE}); + + $self->assert_str_equals($payload->{recipient}, "friend\@example.com"); +} diff --git a/cassandane/tiny-tests/Caldav/managed_attachment_itip b/cassandane/tiny-tests/Caldav/managed_attachment_itip new file mode 100644 index 0000000000..1dbde5159f --- /dev/null +++ b/cassandane/tiny-tests/Caldav/managed_attachment_itip @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_managed_attachment_itip + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150806T234327Z +ORGANIZER:cassandane@example.com +ATTENDEE:attendee@local +UID:123456789 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=Australia/Melbourne:20160831T153000 +DURATION:PT1H +DTSTAMP:20150806T234327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $rawIcal, + 'Content-Type' => 'text/calendar'); + my $eventHref = '/dav/calendars/user/cassandane/Default/test.ics'; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog "Add attachment via CalDAV"; + my $url = $caldav->request_url($eventHref) . '?action=attachment-add'; + my $res = $caldav->ua->post($url, { + headers => { + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment;filename=test', + 'Prefer' => 'return=representation', + 'Authorization' => $caldav->auth_header(), + }, + content => 'davattach', + }); + $self->assert_str_equals('201', $res->{status}); + + $self->assert_caldav_notified( + { recipient => 'attendee@local', is_update => JSON::true, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/managed_attachments b/cassandane/tiny-tests/Caldav/managed_attachments new file mode 100644 index 0000000000..668fd58f4a --- /dev/null +++ b/cassandane/tiny-tests/Caldav/managed_attachments @@ -0,0 +1,120 @@ +#!perl +use Cassandane::Tiny; + +sub test_managed_attachments + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150806T234327Z +UID:123456789 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=Australia/Melbourne:20160831T153000 +DURATION:PT1H +DTSTAMP:20150806T234327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $rawIcal, + 'Content-Type' => 'text/calendar'); + my $eventHref = '/dav/calendars/user/cassandane/Default/test.ics'; + + xlog "Add attachment via CalDAV"; + my $url = $caldav->request_url($eventHref) . '?action=attachment-add'; + my $res = $caldav->ua->post($url, { + headers => { + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment;filename=test', + 'Prefer' => 'return=representation', + 'Authorization' => $caldav->auth_header(), + }, + content => 'davattach', + }); + $self->assert_str_equals('201', $res->{status}); + + my $hash = Text::VCardFast::vcard2hash($res->{content}); + my $attach = $hash->{objects}[0]{objects}[0]{properties}{attach}[0]; + + $self->assert_not_null($attach); + $self->assert_str_equals('test', $attach->{params}{filename}[0]); + $self->assert_str_equals('9', $attach->{params}{size}[0]); + $self->assert_str_equals('application/octet-stream', + $attach->{params}{fmttype}[0]); + + my $managedid = $attach->{params}{'managed-id'}[0]; + my $attachHref = $attach->{value}; + + xlog "Fetch new attachment"; + $res = $caldav->ua->request('GET', $attachHref, { + headers => { + 'Authorization' => $caldav->auth_header() + } + }); + $self->assert_str_equals('davattach', $res->{content}); + + xlog "Update attachment via CalDAV"; + $url = $caldav->request_url($eventHref) . '?action=attachment-update&managed-id=' . $managedid; + $res = $caldav->ua->post($url, { + headers => { + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment;filename=test2', + 'Prefer' => 'return=representation', + 'Authorization' => $caldav->auth_header(), + }, + content => 'davattach2', + }); + $self->assert_str_equals('200', $res->{status}); + + $hash = Text::VCardFast::vcard2hash($res->{content}); + $attach = $hash->{objects}[0]{objects}[0]{properties}{attach}[0]; + + $self->assert_not_null($attach); + $self->assert_str_equals('test2', $attach->{params}{filename}[0]); + $self->assert_str_equals('10', $attach->{params}{size}[0]); + $self->assert_str_equals('application/octet-stream', + $attach->{params}{fmttype}[0]); + + $managedid = $attach->{params}{'managed-id'}[0]; + $attachHref = $attach->{value}; + + xlog "Fetch updated attachment"; + $res = $caldav->ua->request('GET', $attachHref, { + headers => { + 'Authorization' => $caldav->auth_header() + } + }); + $self->assert_str_equals('davattach2', $res->{content}); + + xlog "Delete attachment via CalDAV"; + $url = $caldav->request_url($eventHref) . '?action=attachment-remove&managed-id=' . $managedid; + $res = $caldav->ua->post($url, { + headers => { + 'Prefer' => 'return=representation', + 'Authorization' => $caldav->auth_header(), + }, + }); + $self->assert_str_equals('200', $res->{status}); + + $hash = Text::VCardFast::vcard2hash($res->{content}); + $attach = $hash->{objects}[0]{objects}[0]{properties}{attach}; + + $self->assert_null($attach); + + xlog "Attempt to fetch deleted attachment"; + $res = $caldav->ua->request('GET', $attachHref, { + headers => { + 'Authorization' => $caldav->auth_header() + } + }); + $self->assert_str_equals('404', $res->{status}); +} diff --git a/cassandane/tiny-tests/Caldav/multiinvite_add_person_changes b/cassandane/tiny-tests/Caldav/multiinvite_add_person_changes new file mode 100644 index 0000000000..0f6642a433 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/multiinvite_add_person_changes @@ -0,0 +1,151 @@ +#!perl +use Cassandane::Tiny; + +sub test_multiinvite_add_person_changes + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'invite2'}); + $self->assert_not_null($CalendarId); + + my $uuid = "a684f618-da72-4254-9274-d11f4180696b"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "test1\@example.com", is_update => JSON::false, method => 'REQUEST' }, + { recipient => "test2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + # add an override instance + $card =~ s/An Event/An Event just us/; + $card =~ s/SEQUENCE:0/SEQUENCE:1/; + my $override = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "test1\@example.com", + is_update => JSON::true, + method => 'REQUEST', + event => { + uid => $uuid, + replyTo => { imip => "mailto:cassandane\@example.com" }, + recurrenceOverrides => { + '2016-06-08T15:30:00' => { + title => "An Event with a different friend", + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test3\@example.com" => { email => "test3\@example.com" }, + }, + }, + }, + start => '2016-06-01T15:30:00', + title => "An Event just us", + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test2\@example.com" => { email => "test2\@example.com" }, + }, + }, + }, + { recipient => "test2\@example.com", + is_update => JSON::true, + method => 'REQUEST', + event => { + uid => $uuid, + replyTo => { imip => "mailto:cassandane\@example.com" }, + recurrenceOverrides => { + '2016-06-08T15:30:00' => undef, + }, + start => '2016-06-01T15:30:00', + title => "An Event just us", + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test2\@example.com" => { email => "test2\@example.com" }, + }, + }, + }, + { recipient => "test3\@example.com", + is_update => JSON::false, + method => 'REQUEST', + event => { + uid => $uuid, + replyTo => { imip => "mailto:cassandane\@example.com" }, + recurrenceOverrides => { + '2016-06-08T15:30:00' => { + title => "An Event with a different friend", + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test3\@example.com" => { email => "test3\@example.com" }, + }, + }, + }, + }, + }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/multiinvite_add_person_only b/cassandane/tiny-tests/Caldav/multiinvite_add_person_only new file mode 100644 index 0000000000..cb16785f39 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/multiinvite_add_person_only @@ -0,0 +1,108 @@ +#!perl +use Cassandane::Tiny; + +sub test_multiinvite_add_person_only + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'invite3'}); + $self->assert_not_null($CalendarId); + + my $uuid = "db5c26fd-238f-41e4-a679-54cc9d9c8efc"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "test1\@example.com", is_update => JSON::false, method => 'REQUEST' }, + { recipient => "test2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + # add an override instance + my $override = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # only test3 is notified + $self->assert_caldav_notified( + { recipient => "test3\@example.com", + is_update => JSON::false, + method => 'REQUEST', + event => { + uid => $uuid, + replyTo => { imip => "mailto:cassandane\@example.com" }, + recurrenceOverrides => { + '2016-06-08T15:30:00' => { + title => "An Event", + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test3\@example.com" => { email => "test3\@example.com" }, + }, + }, + }, + }, + }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/multiinvite_remove_person_only b/cassandane/tiny-tests/Caldav/multiinvite_remove_person_only new file mode 100644 index 0000000000..e93a8375c7 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/multiinvite_remove_person_only @@ -0,0 +1,102 @@ +#!perl +use Cassandane::Tiny; + +sub test_multiinvite_remove_person_only + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'invite3'}); + $self->assert_not_null($CalendarId); + + my $uuid = "db5c26fd-238f-41e4-a679-54cc9d9c8efc"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "test1\@example.com", is_update => JSON::false, method => 'REQUEST' }, + { recipient => "test2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + { recipient => "test3\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + # add an override instance + my $override = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # only test3 is notified with an RDATE + $self->assert_caldav_notified( + { recipient => "test3\@example.com", + is_update => JSON::true, + method => 'REQUEST', + event => { + uid => $uuid, + replyTo => { imip => "mailto:cassandane\@example.com" }, + recurrenceOverrides => { + '2016-06-08T15:30:00' => undef, + }, + }, + }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/netcaldavtalktests_fromical b/cassandane/tiny-tests/Caldav/netcaldavtalktests_fromical new file mode 100644 index 0000000000..66186a5c27 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/netcaldavtalktests_fromical @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_netcaldavtalktests_fromical + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $cassini = Cassandane::Cassini->instance(); + my $basedir = $cassini->val('caldavtalk', 'basedir'); + + unless ($basedir) { + xlog $self, "Not running test, no caldavtalk"; + return; + } + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $Calendar = $CalDAV->GetCalendar($CalendarId); + + my $testdir = "$basedir/testdata"; + opendir(DH, $testdir); + my @list; + while (my $item = readdir(DH)) { + next unless $item =~ m/(.*).ics/; + push @list, $1; + } + closedir(DH); + + foreach my $name (sort @list) { + my $ical = slurp($testdir, $name, 'ics'); + my $api = slurp($testdir, $name, 'je'); + my $data = decode_json($api); + my $uid = $data->[0]{uid}; + + xlog $self, "put $name as text/calendar and fetch back as JSON"; + $CalDAV->Request("PUT", "$CalendarId/$uid.ics", $ical, 'Content-Type' => 'text/calendar'); + my $serverapi = $CalDAV->Request("GET", "$CalendarId/$uid.ics", '', 'Accept' => 'application/event+json'); + my $serverdata = decode_json($serverapi->{content}); + $self->assert_deep_equals($CalDAV->NormaliseEvent($data->[0]), $CalDAV->NormaliseEvent($serverdata)); + } +} diff --git a/cassandane/tiny-tests/Caldav/netcaldavtalktests_fromje b/cassandane/tiny-tests/Caldav/netcaldavtalktests_fromje new file mode 100644 index 0000000000..f0473adbcf --- /dev/null +++ b/cassandane/tiny-tests/Caldav/netcaldavtalktests_fromje @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_netcaldavtalktests_fromje + :min_version_3_1 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $cassini = Cassandane::Cassini->instance(); + my $basedir = $cassini->val('caldavtalk', 'basedir'); + + unless ($basedir) { + xlog $self, "Not running test, no caldavtalk"; + return; + } + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $Calendar = $CalDAV->GetCalendar($CalendarId); + + my $testdir = "$basedir/testdata"; + opendir(DH, $testdir); + my @list; + while (my $item = readdir(DH)) { + next unless $item =~ m/(.*).ics/; + push @list, $1; + } + closedir(DH); + + foreach my $name (sort @list) { + my $api = slurp($testdir, $name, 'je'); + my $data = decode_json($api); + my $uid = $data->[0]{uid}; + + xlog $self, "put $name as application/event+json and fetch back as JSON"; + $CalDAV->Request("PUT", "$CalendarId/$uid.ics", $api, 'Content-Type' => 'application/event+json'); + my $serverapi = $CalDAV->Request("GET", "$CalendarId/$uid.ics", '', 'Accept' => 'application/event+json'); + my $serverdata = decode_json($serverapi->{content}); + $self->assert_deep_equals($CalDAV->NormaliseEvent($data->[0]), $CalDAV->NormaliseEvent($serverdata)); + } +} diff --git a/cassandane/tiny-tests/Caldav/propfind_principal b/cassandane/tiny-tests/Caldav/propfind_principal new file mode 100644 index 0000000000..9f9dea67f3 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/propfind_principal @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_propfind_principal + :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.reallyprivateuser"); + $admintalk->setacl("user.reallyprivateuser", "reallyprivateuser" => "lrswipkxtecda"); + + my $service = $self->{instance}->get_service("http"); + my $caltalk = Net::CalDAVTalk->new( + user => "reallyprivateuser", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create calendar"; + my $CalendarId = $caltalk->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $CalDAV = $self->{caldav}; + + xlog $self, "principal property search"; + + my $xml = < + + + + + INDIVIDUAL + + + + + + +EOF + + my $res = $CalDAV->Request('REPORT', '/dav/principals', $xml, Depth => 0, 'Content-Type' => 'text/xml'); + my $text = Dumper($res); + $self->assert_does_not_match(qr/reallyprivateuser/, $text); +} diff --git a/cassandane/tiny-tests/Caldav/put_changes_etag b/cassandane/tiny-tests/Caldav/put_changes_etag new file mode 100644 index 0000000000..fd45d37fd5 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/put_changes_etag @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_changes_etag + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $href = "$CalendarId/uid1.ics"; + my $card = < 'text/calendar', + 'Authorization' => $CalDAV->auth_header(), + ); + + my $Response = $CalDAV->{ua}->request('PUT', $CalDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + + $self->assert_num_equals(201, $Response->{status}); + my $etag = $Response->{headers}{etag}; + $self->assert_not_null($etag); + + $Response = $CalDAV->{ua}->request('HEAD', $CalDAV->request_url($href), { + headers => \%Headers, + }); + + # the etag shouldn't have changed + $self->assert_num_equals(200, $Response->{status}); + my $etag2 = $Response->{headers}{etag}; + $self->assert_not_null($etag2); + $self->assert_str_equals($etag2, $etag); + + $card =~ s/HasUID1/HasUID2/s; + + $Response = $CalDAV->{ua}->request('PUT', $CalDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + + # no content, we're replacing a thing + $self->assert_num_equals(204, $Response->{status}); + my $etag3 = $Response->{headers}{etag}; + $self->assert_not_null($etag2); + + # the content has changed, so the etag MUST change + $self->assert_str_not_equals($etag, $etag3); + + $Response = $CalDAV->{ua}->request('HEAD', $CalDAV->request_url($href), { + headers => \%Headers, + }); + + # the etag shouldn't have changed again + $self->assert_num_equals(200, $Response->{status}); + my $etag4 = $Response->{headers}{etag}; + $self->assert_not_null($etag4); + $self->assert_str_equals($etag4, $etag3); +} diff --git a/cassandane/tiny-tests/Caldav/put_control_char b/cassandane/tiny-tests/Caldav/put_control_char new file mode 100644 index 0000000000..cbdd35b3e0 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/put_control_char @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_control_char + :min_version_3_9 :needs_component_httpd :needs_ical_ctrl :MagicPlus +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + # Assert that CONTROL chars are omitted when reading + # iCalendar data during PUT. + + my $ical = <Request('PUT', '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + my $res = $caldav->Request('GET', '/dav/calendars/user/cassandane/Default/test.ics'); + $self->assert_matches(qr/DESCRIPTION:ctrl/, $res->{content}); +} diff --git a/cassandane/tiny-tests/Caldav/put_date_with_tzid b/cassandane/tiny-tests/Caldav/put_date_with_tzid new file mode 100644 index 0000000000..0d4bb1b67a --- /dev/null +++ b/cassandane/tiny-tests/Caldav/put_date_with_tzid @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_date_with_tzid + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $href = "$CalendarId/datewith.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); +} diff --git a/cassandane/tiny-tests/Caldav/put_nouid b/cassandane/tiny-tests/Caldav/put_nouid new file mode 100644 index 0000000000..a29b5791ac --- /dev/null +++ b/cassandane/tiny-tests/Caldav/put_nouid @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_nouid + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $href = "$CalendarId/nouid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar') }; + my $Err = $@; + $self->assert_matches(qr/valid-calendar-object-resource/, $Err); +} diff --git a/cassandane/tiny-tests/Caldav/put_strip_scheduleforcesend b/cassandane/tiny-tests/Caldav/put_strip_scheduleforcesend new file mode 100644 index 0000000000..9d2a14e2c2 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/put_strip_scheduleforcesend @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_strip_scheduleforcesend + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "Default/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + my $response = $CalDAV->Request('GET', $href); + $self->assert(not ($response->{content} =~ m/SCHEDULE-FORCE-SEND/)); +} diff --git a/cassandane/tiny-tests/Caldav/put_toolarge b/cassandane/tiny-tests/Caldav/put_toolarge new file mode 100644 index 0000000000..ae93572439 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/put_toolarge @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_toolarge + :min_version_3_5 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid = "d4643cf9-4552-4a3e-8d6c-5f318bcc5b79"; + my $href = "$CalendarId/$uuid.ics"; + my $desc = ('x') x 100000; + my $event = <Request('PUT', $href, $event, 'Content-Type' => 'text/calendar') }; + my $Err = $@; + $self->assert_matches(qr/max-resource-size/, $Err); +} diff --git a/cassandane/tiny-tests/Caldav/put_usedefaultalerts_no_etag b/cassandane/tiny-tests/Caldav/put_usedefaultalerts_no_etag new file mode 100644 index 0000000000..4589fd4ad5 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/put_usedefaultalerts_no_etag @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_usedefaultalerts_no_etag + :min_version_3_7 +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "PROPPATCH default alarms on the calendar"; + my $proppatchXml = < + + + + +BEGIN:VALARM +UID:alert1 +TRIGGER:-PT1H +ACTION:DISPLAY +DESCRIPTION:alarm +END:VALARM + + + + +EOF + $caldav->Request('PROPPATCH', "/dav/calendars/user/cassandane/Default", + $proppatchXml, 'Content-Type' => 'text/xml'); + + my %Headers = ( + 'Content-Type' => 'text/calendar', + 'Authorization' => $caldav->auth_header(), + ); + + xlog "PUT event with useDefaultAlerts set"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DURATION:PT1H +UID:40d6fe3c-6a51-489e-823e-3ea22f427a3e +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:test1 +LAST-MODIFIED:20150928T132434Z +X-JMAP-USEDEFAULTALERTS;VALUE=BOOLEAN:TRUE +END:VEVENT +END:VCALENDAR +EOF + my $href = $caldav->request_url('/dav/calendars/user/cassandane/Default/test1.ics'); + my $res = $caldav->{ua}->request('PUT', $href, { + content => $ical, headers => \%Headers, + }); + + xlog "Assert no ETag is returned"; + $self->assert_null($res->{headers}{etag}); + + xlog "Assert ETag is returned for HEAD"; + $res = $caldav->{ua}->request('HEAD', $href, { + headers => \%Headers, + }); + $self->assert_not_null($res->{headers}{etag}); + + xlog "PUT event without useDefaultAlerts set"; + $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DURATION:PT1H +UID:a0d80e97-746d-4443-9a13-7f7cd8af9f81 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:test1 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $href = $caldav->request_url('/dav/calendars/user/cassandane/Default/test2.ics'); + $res = $caldav->{ua}->request('PUT', $href, { + content => $ical, headers => \%Headers, + }); + + xlog "Assert ETag is returned"; + my $etag = $res->{headers}{etag}; + $self->assert_not_null($etag); + + xlog "Assert ETag matches for HEAD"; + $res = $caldav->{ua}->request('HEAD', $href, { + headers => \%Headers, + }); + $self->assert_str_equals($etag, $res->{headers}{etag}); +} diff --git a/cassandane/tiny-tests/Caldav/recurring_freebusy b/cassandane/tiny-tests/Caldav/recurring_freebusy new file mode 100644 index 0000000000..0625fbda38 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/recurring_freebusy @@ -0,0 +1,90 @@ +#!perl +use Cassandane::Tiny; + +sub test_recurring_freebusy + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4319-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my ($Data) = $CalDAV->GetFreeBusy($CalendarId); + + $self->assert(@$Data > 50); + $self->assert_str_equals("Etc/UTC", $Data->[0]{timeZone}); + $self->assert_str_equals("Etc/UTC", $Data->[1]{timeZone}); + $self->assert_str_equals("Etc/UTC", $Data->[2]{timeZone}); + # etc + $self->assert_str_equals("2016-08-31T05:30:00", $Data->[0]{start}); + $self->assert_str_equals("2016-09-14T06:30:00", $Data->[1]{start}); + $self->assert_str_equals("2016-09-21T05:30:00", $Data->[2]{start}); + # and so on + $self->assert_str_equals("PT3H", $Data->[0]{duration}); + $self->assert_str_equals("PT2H", $Data->[1]{duration}); + $self->assert_str_equals("PT3H", $Data->[2]{duration}); +} diff --git a/cassandane/tiny-tests/Caldav/remove_oneattendee_recurring b/cassandane/tiny-tests/Caldav/remove_oneattendee_recurring new file mode 100644 index 0000000000..aeecd82e6c --- /dev/null +++ b/cassandane/tiny-tests/Caldav/remove_oneattendee_recurring @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_remove_oneattendee_recurring + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "recurring event"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + my $overrides = <_put_event($CalendarId, uuid => $uuid, lines => < $overrides); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test1\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test2\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test3\@example.com +RRULE:FREQ=WEEKLY +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + + $self->assert_caldav_notified( + { + method => 'REQUEST', + recipient => "test1\@example.com", + is_update => JSON::true, + event => { + recurrenceOverrides => { + '2016-06-08T15:30:00' => { + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test3\@example.com" => { email => "test3\@example.com" }, + }, + start => '2016-06-08T16:00:00', + }, + }, + }, + }, + { + method => 'REQUEST', + recipient => "test2\@example.com", + is_update => JSON::true, + event => { + start => '2016-06-01T15:30:00', + recurrenceOverrides => { '2016-06-08T15:30:00' => undef }, + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test2\@example.com" => { email => "test2\@example.com" }, + "test3\@example.com" => { email => "test3\@example.com" }, + }, + }, + }, + { + method => 'REQUEST', + recipient => "test3\@example.com", + is_update => JSON::true, + event => { + recurrenceOverrides => { + '2016-06-08T15:30:00' => { + participants => { + "cassandane\@example.com" => { email => "cassandane\@example.com" }, + "test1\@example.com" => { email => "test1\@example.com" }, + "test3\@example.com" => { email => "test3\@example.com" }, + }, + start => '2016-06-08T16:00:00', + }, + }, + }, + }, + ); + } +} diff --git a/cassandane/tiny-tests/Caldav/rename b/cassandane/tiny-tests/Caldav/rename new file mode 100644 index 0000000000..6a9d7a6118 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/rename @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_rename + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + xlog $self, "create calendar"; + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + xlog $self, "fetch again"; + my $Calendar = $CalDAV->GetCalendar($CalendarId); + $self->assert_not_null($Calendar); + + xlog $self, "check name matches"; + $self->assert_str_equals('foo', $Calendar->{name}); + + xlog $self, "change name"; + my $NewId = $CalDAV->UpdateCalendar({ id => $CalendarId, name => 'bar'}); + $self->assert_str_equals($CalendarId, $NewId); + + xlog $self, "fetch again"; + my $NewCalendar = $CalDAV->GetCalendar($NewId); + $self->assert_not_null($NewCalendar); + + xlog $self, "check new name stuck"; + $self->assert_str_equals('bar', $NewCalendar->{name}); +} diff --git a/cassandane/tiny-tests/Caldav/replication_delete b/cassandane/tiny-tests/Caldav/replication_delete new file mode 100644 index 0000000000..78f502fe54 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/replication_delete @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication_delete + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + $self->run_replication(); + $self->check_replication('cassandane'); + + my $href = "$CalendarId/event1.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + my $response = $CalDAV->Request('GET', $href); + my $value = $response->{content}; + $self->assert_matches(qr/An Event/, $value); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $CalDAV->DeleteCalendar($CalendarId); + + $self->run_replication(); + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/Caldav/reply b/cassandane/tiny-tests/Caldav/reply new file mode 100644 index 0000000000..c440f8a023 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/reply @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +sub test_reply + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # we don't say anything when we add a NEEDS-ACTION item + $self->assert_caldav_notified(); + + $card =~ s/PARTSTAT=NEEDS-ACTION/PARTSTAT=ACCEPTED/; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # we sent a reply + $self->assert_caldav_notified( + { + method => 'REPLY', + recipient => 'friend@example.com', + event => { + participants => { + 'cassandane@example.com' => { + 'scheduleStatus' => 'accepted', + 'email' => 'cassandane@example.com' + }, + }, + }, + }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/reply_scheduleaddress b/cassandane/tiny-tests/Caldav/reply_scheduleaddress new file mode 100644 index 0000000000..34bc9523af --- /dev/null +++ b/cassandane/tiny-tests/Caldav/reply_scheduleaddress @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +sub test_reply_scheduleaddress + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar', 'Schedule-Address' => 'othercas@example.com'); + + # we don't say anything when we add a NEEDS-ACTION item + $self->assert_caldav_notified(); + + $card =~ s/PARTSTAT=NEEDS-ACTION/PARTSTAT=ACCEPTED/; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar', 'Schedule-Address' => 'othercas@example.com'); + + # we sent a reply from the correct address + $self->assert_caldav_notified( + { + method => 'REPLY', + recipient => 'friend@example.com', + event => { + participants => { + 'othercas@example.com' => { + 'scheduleStatus' => 'accepted', + 'email' => 'othercas@example.com', + }, + }, + }, + }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/reply_withothers b/cassandane/tiny-tests/Caldav/reply_withothers new file mode 100644 index 0000000000..646bcf2f20 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/reply_withothers @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_reply_withothers + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CalendarId = $CalDAV->NewCalendar({name => 'hello'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # we don't say anything when we add a NEEDS-ACTION item + $self->assert_caldav_notified(); + + $card =~ s/PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane/PARTSTAT=ACCEPTED:MAILTO:cassandane/; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # we sent a reply + $self->assert_caldav_notified( + { + method => 'REPLY', + recipient => 'friend@example.com', + event => { + participants => { + 'cassandane@example.com' => { + 'scheduleStatus' => 'accepted', + 'email' => 'cassandane@example.com' + }, + }, + }, + }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_1_create b/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_1_create new file mode 100644 index 0000000000..6f1eb1e7f9 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_1_create @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6638_3_2_1_1_create + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "default schedule agent -> REQUEST"; + $self->_put_event($CalendarId, lines => <assert_caldav_notified( + { recipient => "test1\@example.com", is_update => JSON::false, method => 'REQUEST' }, + { recipient => "test2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + xlog $self, "schedule agent SERVER -> REQUEST"; + $self->_put_event($CalendarId, lines => <assert_caldav_notified( + { recipient => "test1\@example.com", is_update => JSON::false, method => 'REQUEST' }, + { recipient => "test2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + xlog $self, "schedule agent CLIENT -> nothing"; + $self->_put_event($CalendarId, lines => <assert_caldav_notified(); + + xlog $self, "schedule agent NONE -> nothing"; + $self->_put_event($CalendarId, lines => <assert_caldav_notified(); +} diff --git a/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_2_modify b/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_2_modify new file mode 100644 index 0000000000..caca761bef --- /dev/null +++ b/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_2_modify @@ -0,0 +1,334 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6638_3_2_1_2_modify + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + # 4 x 4 matrix: + # +---------------+-----------------------------------------------+ + # | | Modified | + # | +-----------+-----------+-----------+-----------+ + # | | | SERVER | CLIENT | NONE | + # | | | (default) | | | + # +===+===========+===========+===========+===========+===========+ + # | | | -- | REQUEST / | -- | -- | + # | O | | | ADD | | | + # | r +-----------+-----------+-----------+-----------+-----------+ + # | i | SERVER | CANCEL | REQUEST | CANCEL | CANCEL | + # | g | (default) | | | | | + # | i +-----------+-----------+-----------+-----------+-----------+ + # | n | CLIENT | -- | REQUEST / | -- | -- | + # | a | | | ADD | | | + # | l +-----------+-----------+-----------+-----------+-----------+ + # | | NONE | -- | REQUEST / | -- | -- | + # | | | | ADD | | | + # +---+-----------+-----------+-----------+-----------+-----------+ + + xlog $self, " / "; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + xlog $self, " / SERVER"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified( + { recipient => "test1\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + } + + xlog $self, " / CLIENT"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-AGENT=CLIENT:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + xlog $self, " / NONE"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-AGENT=NONE:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + xlog $self, "SERVER / "; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified( + { recipient => "test1\@example.com", method => 'CANCEL' }, + ); + } + + xlog $self, "SERVER / SERVER"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified( + { recipient => "test1\@example.com", is_update => JSON::true }, + ); + } + + xlog $self, "SERVER / CLIENT"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-AGENT=CLIENT:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified( + { recipient => "test1\@example.com", method => 'CANCEL' }, + ); + } + + xlog $self, "SERVER / NONE"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-AGENT=NONE:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified( + { recipient => "test1\@example.com", method => 'CANCEL' }, + ); + } + + xlog $self, "CLIENT / "; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + xlog $self, "CLIENT / SERVER"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + # XXX - should be a new request is_update => true + $self->assert_caldav_notified( + #{ recipient => "test1\@example.com", is_update => JSON::true, method => 'REQUEST' }, + { recipient => "test1\@example.com", method => 'REQUEST' }, + ); + } + + xlog $self, "CLIENT / CLIENT"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-AGENT=CLIENT:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + xlog $self, "CLIENT / NONE"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-AGENT=NONE:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + xlog $self, "NONE / "; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + xlog $self, "NONE / SERVER"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + # XXX - should be a new request is_update => true + $self->assert_caldav_notified( + #{ recipient => "test1\@example.com", is_update => JSON::true, method => 'REQUEST' }, + { recipient => "test1\@example.com", method => 'REQUEST' }, + ); + } + + xlog $self, "NONE / CLIENT"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-AGENT=CLIENT:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + xlog $self, "NONE / NONE"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-AGENT=NONE:MAILTO:test1\@example.com +ORGANIZER;CN=Test User:MAILTO:cassandane\@example.com +EOF + $self->assert_caldav_notified(); + } + + # XXX - check that the SCHEDULE-STATUS property is set correctly... + + xlog $self, "Forbidden organizer change"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + eval { $self->_put_event($CalendarId, uuid => $uuid, lines => < "update"); }; +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:test1\@example.com +ORGANIZER:MAILTO:test1\@example.com +EOF + my $err = $@; + $self->assert_matches(qr/allowed-attendee-scheduling-object-change/, $err); + } +} diff --git a/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_3_remove b/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_3_remove new file mode 100644 index 0000000000..848ab98783 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_3_remove @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6638_3_2_1_3_remove + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "default => CANCEL"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $CalDAV->Request('DELETE', "$CalendarId/$uuid.ics"); + $self->assert_caldav_notified( + { recipient => "test1\@example.com", method => 'CANCEL' }, + ); + } + + xlog $self, "SERVER => CANCEL"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $CalDAV->Request('DELETE', "$CalendarId/$uuid.ics"); + $self->assert_caldav_notified( + { recipient => "test1\@example.com", method => 'CANCEL' }, + ); + } + + xlog $self, "CLIENT => nothing"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $CalDAV->Request('DELETE', "$CalendarId/$uuid.ics"); + $self->assert_caldav_notified(); + } + + xlog $self, "NONE => nothing"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $CalDAV->Request('DELETE', "$CalendarId/$uuid.ics"); + $self->assert_caldav_notified(); + } +} diff --git a/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_setpartstat_agentclient b/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_setpartstat_agentclient new file mode 100644 index 0000000000..1c8a017029 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/rfc6638_3_2_1_setpartstat_agentclient @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6638_3_2_1_setpartstat_agentclient + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "attempt to set the partstat to something other than NEEDS-ACTION, agent was client"; + $self->_put_event($CalendarId, lines => <assert_caldav_notified( + { recipient => "test2\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_1_attendee_allowed_changes b/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_1_attendee_allowed_changes new file mode 100644 index 0000000000..32a06d90b8 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_1_attendee_allowed_changes @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6638_3_2_2_1_attendee_allowed_changes + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "change summary"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + eval { $self->_put_event($CalendarId, uuid => $uuid, lines => < "updated event"); }; +ATTENDEE;CN=Test User;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=ACCEPTED:MAILTO:test1\@example.com +ORGANIZER:MAILTO:test1\@example.com +EOF + my $err = $@; + # XXX - changing summary isn't rejected yet, should be + #$self->assert_matches(qr/allowed-attendee-scheduling-object-change/, $err); + } + + xlog $self, "change organizer"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + eval { $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_matches(qr/allowed-attendee-scheduling-object-change/, $err); + } +} diff --git a/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_2_attendee_create b/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_2_attendee_create new file mode 100644 index 0000000000..bc65079945 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_2_attendee_create @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6638_3_2_2_2_attendee_create + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "agent "; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_caldav_notified( + { recipient => "test1\@example.com", method => 'REPLY' }, + ); + } + + xlog $self, "agent SERVER"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_caldav_notified( + { recipient => "test1\@example.com", method => 'REPLY' }, + ); + } + + xlog $self, "agent CLIENT"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_caldav_notified(); + } + + xlog $self, "agent NONE"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_caldav_notified(); + } +} diff --git a/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_3_attendee_modify b/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_3_attendee_modify new file mode 100644 index 0000000000..cabb1164f9 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/rfc6638_3_2_2_3_attendee_modify @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6638_3_2_2_3_attendee_modify + :needs_component_httpd +{ + my ($self) = @_; + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'test'}); + $self->assert_not_null($CalendarId); + + xlog $self, "attendee-modify"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_caldav_notified( + { recipient => "test1\@example.com", method => 'REPLY' }, + ); + } + + xlog $self, "attendee-modify CLIENT"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_caldav_notified(); + } + + xlog $self, "attendee-modify NONE"; + { + my $uuid = $CalDAV->genuuid(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <{instance}->getnotify(); + $self->_put_event($CalendarId, uuid => $uuid, lines => <assert_caldav_notified(); + } +} diff --git a/cassandane/tiny-tests/Caldav/sched_busytime_query b/cassandane/tiny-tests/Caldav/sched_busytime_query new file mode 100644 index 0000000000..bb8b84875c --- /dev/null +++ b/cassandane/tiny-tests/Caldav/sched_busytime_query @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_sched_busytime_query + :min_version_3_4 :needs_component_httpd :NoVirtDomains +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.friend"); + $admintalk->setacl("user.friend", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.friend", friend => 'lrswipkxtecdn'); + + my $service = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "friend", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $query = <Request('POST', 'Outbox', + $query, 'Content-Type' => 'text/calendar'); + my $text = Dumper($res); + $self->assert_matches(qr/schedule-response/, $text); +} diff --git a/cassandane/tiny-tests/Caldav/shared_invite_as_secretary b/cassandane/tiny-tests/Caldav/shared_invite_as_secretary new file mode 100644 index 0000000000..b23ffa1e7b --- /dev/null +++ b/cassandane/tiny-tests/Caldav/shared_invite_as_secretary @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_shared_invite_as_secretary + :VirtDomains :min_version_3_1 :needs_component_httpd :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.test"); + $admintalk->setacl("user.test", "test" => "lrswipkxtecda"); + + my $service = $self->{instance}->get_service("http"); + my $testtalk = Net::CalDAVTalk->new( + user => "test", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $xml = < + + + + + mailto:test\@example.com + + + + +EOF + + $testtalk->Request('PROPPATCH', "/dav/principals/user/test", $xml, + 'Content-Type' => 'text/xml'); + + xlog $self, "create calendar"; + my $CalendarId = $testtalk->NewCalendar({name => 'Team Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "set calendar-user-address-set for all sharees"; + $testtalk->Request('PROPPATCH', "/dav/calendars/user/test/$CalendarId", $xml, + 'Content-Type' => 'text/xml'); + + xlog $self, "share to user"; + $admintalk->setacl("user.test.#calendars.$CalendarId", + "cassandane" => 'lrswipcdn'); + + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "subscribe to shared calendar"; + my $imapstore = $self->{instance}->get_service('imap')->create_store( + username => "cassandane"); + my $imaptalk = $imapstore->get_client(); + $imaptalk->subscribe("user.test.#calendars.$CalendarId"); + + xlog $self, "get calendars as cassandane"; + my $CasCal = $CalDAV->GetCalendars(); + my $sharedCalendarId = $CasCal->[1]{href}; + + $xml = < + + + + + mailto:test\@example.com + + + + +EOF + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$sharedCalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REPLY' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/shared_multiget b/cassandane/tiny-tests/Caldav/shared_multiget new file mode 100644 index 0000000000..a670221a54 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/shared_multiget @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_shared_multiget + :needs_component_httpd :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create second user"; + $admintalk->create("user.test"); + $admintalk->setacl("user.test", "test" => "lrswipkxtean"); + + xlog $self, "Provision calendars user"; + my $service = $self->{instance}->get_service("http"); + my $testtalk = Net::CalDAVTalk->new( + user => "test", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "Share default calendar to cassandane"; + $admintalk->setacl("user.test.#calendars.Default", "cassandane" => 'lrswin'); + + xlog $self, "Subscribe to shared calendar"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->subscribe("user.test.#calendars.Default"); + + xlog $self, "Get calendars as cassandane"; + my $CalDAV = $self->{caldav}; + my $CasCal = $CalDAV->GetCalendars(); + my $sharedId = $CasCal->[1]{href}; + + my $href = $CalDAV->NewEvent('Default', { + timeZone => 'Etc/UTC', + start => '2015-01-01T12:00:00', + duration => 'PT1H', + summary => 'waterfall', + }); + + my $sharedHref = $CalDAV->NewEvent($sharedId, { + timeZone => 'America/New_York', + start => '2015-02-01T12:00:00', + duration => 'PT1H', + summary => 'waterfall2', + }); + + my $xmlMultiget = < + + + + + /dav/calendars/user/cassandane/$href + $sharedHref + +EOF + + xlog "Run calendar-multiget report"; + $mgRes = $CalDAV->Request('REPORT', 'Default', $xmlMultiget, + 'Content-Type' => 'application/xml', + ); + + my $icaldata = $mgRes->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content}; + $self->assert_matches(qr|DTSTART:20150101T120000Z|, $icaldata); + + $icaldata = $mgRes->{'{DAV:}response'}[1]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content}; + $self->assert_matches(qr|DTSTART;TZID=America/New_York:20150201T120000|, $icaldata); +} diff --git a/cassandane/tiny-tests/Caldav/shared_reply_as_secretary b/cassandane/tiny-tests/Caldav/shared_reply_as_secretary new file mode 100644 index 0000000000..7d8281d0e7 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/shared_reply_as_secretary @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_shared_reply_as_secretary + :VirtDomains :min_version_3_1 :needs_component_httpd :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.test"); + $admintalk->setacl("user.test", "test" => "lrswipkxtecda"); + + my $service = $self->{instance}->get_service("http"); + my $testtalk = Net::CalDAVTalk->new( + user => "test", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $xml = < + + + + + mailto:test\@example.com + + + + +EOF + + $testtalk->Request('PROPPATCH', "/dav/principals/user/test", $xml, + 'Content-Type' => 'text/xml'); + + xlog $self, "create calendar"; + my $CalendarId = $testtalk->NewCalendar({name => 'Team Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "set calendar-user-address-set for all sharees"; + $testtalk->Request('PROPPATCH', "/dav/calendars/user/test/$CalendarId", $xml, + 'Content-Type' => 'text/xml'); + + xlog $self, "share to user"; + $admintalk->setacl("user.test.#calendars.$CalendarId", + "cassandane" => 'lrswipcdn'); + + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "subscribe to shared calendar"; + my $imapstore = $self->{instance}->get_service('imap')->create_store( + username => "cassandane"); + my $imaptalk = $imapstore->get_client(); + $imaptalk->subscribe("user.test.#calendars.$CalendarId"); + + xlog $self, "get calendars as cassandane"; + my $CasCal = $CalDAV->GetCalendars(); + my $sharedCalendarId = $CasCal->[1]{href}; + + $xml = < + + + + + mailto:test\@example.com + + + + +EOF + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$sharedCalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/shared_team_invite_sharee b/cassandane/tiny-tests/Caldav/shared_team_invite_sharee new file mode 100644 index 0000000000..9c18c94b02 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/shared_team_invite_sharee @@ -0,0 +1,109 @@ +#!perl +use Cassandane::Tiny; + +sub test_shared_team_invite_sharee + :VirtDomains :min_version_3_1 :needs_component_httpd :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.test"); + $admintalk->setacl("user.test", "test" => "lrswipkxtecda"); + + my $service = $self->{instance}->get_service("http"); + my $testtalk = Net::CalDAVTalk->new( + user => "test", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $xml = < + + + + + mailto:test\@example.com + + + + +EOF + + $testtalk->Request('PROPPATCH', "/dav/principals/user/test", $xml, + 'Content-Type' => 'text/xml'); + + xlog $self, "create calendar"; + my $CalendarId = $testtalk->NewCalendar({name => 'Team Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "share to user"; + $admintalk->setacl("user.test.#calendars.$CalendarId", + "cassandane" => 'lrswipcdn'); + + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "subscribe to shared calendar"; + my $imapstore = $self->{instance}->get_service('imap')->create_store( + username => "cassandane"); + my $imaptalk = $imapstore->get_client(); + $imaptalk->subscribe("user.test.#calendars.$CalendarId"); + + xlog $self, "get calendars as cassandane"; + my $CasCal = $CalDAV->GetCalendars(); + my $sharedCalendarId = $CasCal->[1]{href}; + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "/dav/calendars/user/test/$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "cassandane\@example.com", is_update => JSON::false, method => 'REQUEST' }, + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + xlog $self, "update PARTSTAT as sharee"; + $href = "$sharedCalendarId/$uuid.ics"; + $card =~ s/PARTSTAT=NEEDS-ACTION/PARTSTAT=ACCEPTED/; + + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "test\@example.com", is_update => JSON::false, method => 'REPLY' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/shared_team_invite_sharer b/cassandane/tiny-tests/Caldav/shared_team_invite_sharer new file mode 100644 index 0000000000..e73de38c8c --- /dev/null +++ b/cassandane/tiny-tests/Caldav/shared_team_invite_sharer @@ -0,0 +1,109 @@ +#!perl +use Cassandane::Tiny; + +sub test_shared_team_invite_sharer + :VirtDomains :min_version_3_1 :needs_component_httpd :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.test"); + $admintalk->setacl("user.test", "test" => "lrswipkxtecda"); + + my $service = $self->{instance}->get_service("http"); + my $testtalk = Net::CalDAVTalk->new( + user => "test", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $xml = < + + + + + mailto:test\@example.com + + + + +EOF + + $testtalk->Request('PROPPATCH', "/dav/principals/user/test", $xml, + 'Content-Type' => 'text/xml'); + + xlog $self, "create calendar"; + my $CalendarId = $testtalk->NewCalendar({name => 'Team Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "share to user"; + $admintalk->setacl("user.test.#calendars.$CalendarId", + "cassandane" => 'lrswipcdn'); + + my $CalDAV = Net::CalDAVTalk->new( + user => "cassandane", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "subscribe to shared calendar"; + my $imapstore = $self->{instance}->get_service('imap')->create_store( + username => "cassandane"); + my $imaptalk = $imapstore->get_client(); + $imaptalk->subscribe("user.test.#calendars.$CalendarId"); + + xlog $self, "get calendars as cassandane"; + my $CasCal = $CalDAV->GetCalendars(); + my $sharedCalendarId = $CasCal->[1]{href}; + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$sharedCalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "test\@example.com", is_update => JSON::false, method => 'REQUEST' }, + { recipient => "friend\@example.com", is_update => JSON::false, method => 'REQUEST' }, + ); + + xlog $self, "update PARTSTAT as sharer"; + $href = "/dav/calendars/user/test/$CalendarId/$uuid.ics"; + $card =~ s/PARTSTAT=NEEDS-ACTION/PARTSTAT=ACCEPTED/; + + $testtalk->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $self->assert_caldav_notified( + { recipient => "cassandane\@example.com", is_update => JSON::false, method => 'REPLY' }, + ); +} diff --git a/cassandane/tiny-tests/Caldav/summary_with_embedded_newlines b/cassandane/tiny-tests/Caldav/summary_with_embedded_newlines new file mode 100644 index 0000000000..a1eab8cf76 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/summary_with_embedded_newlines @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_summary_with_embedded_newlines + :needs_component_httpd :MagicPlus :NoAltNameSpace +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = < 'text/calendar', + 'Authorization' => $CalDAV->auth_header(), + ); + + xlog "Create event"; + my $Response = $CalDAV->{ua}->request('PUT', $CalDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + + # This only succeeds if we properly encode the SUMMARY + # as a Subject header field when constructing the message on disk + $self->assert_num_equals(201, $Response->{status}); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + + xlog "Check Subject header"; + my $subject = "=?UTF-8?Q?Send_image_for_61st_anniversary_exhibition_at_gallery_--_Inclu?=\r\n"; + $subject .= " =?UTF-8?Q?deyour_name,_the_title_and_the_media.__To_be_of_appropriate_q?=\r\n"; + $subject .= " =?UTF-8?Q?uality,_theideal_image_file_size_should_be_300_-_500_kilobyte?=\r\n"; + $subject .= " =?UTF-8?Q?s.=0A=0AUse_your_lastname_and_an_abbreviated_title_as_the_fil?=\r\n"; + $subject .= " =?UTF-8?Q?e_name(Lastname=5FTitle.jpg).=0A=0APlease_send_to:___foo\@exam?=\r\n"; + $subject .= " =?UTF-8?Q?ple.net=0A=0A?="; + + my $store = $self->{instance}->get_service('imap')->create_store(username => 'cassandane+dav'); + my $imaptalk = $store->get_client(); + $imaptalk->select("INBOX.#calendars.$CalendarId"); + $Response = $imaptalk->fetch(1, '(BODY.PEEK[HEADER.FIELDS (SUBJECT)])'); + $self->assert_str_equals($Response->{1}->{headers}->{subject}[0], $subject); +} diff --git a/cassandane/tiny-tests/Caldav/summary_with_trailing_newlines b/cassandane/tiny-tests/Caldav/summary_with_trailing_newlines new file mode 100644 index 0000000000..7391fae8c5 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/summary_with_trailing_newlines @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_summary_with_trailing_newlines + :needs_component_httpd :MagicPlus :NoAltNameSpace +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = < 'text/calendar', + 'Authorization' => $CalDAV->auth_header(), + ); + + xlog "Create event"; + my $Response = $CalDAV->{ua}->request('PUT', $CalDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + + # This only succeeds if we strip trailing newlines from the SUMMARY + # when used as a Subject header field when constructing the message on disk + $self->assert_num_equals(201, $Response->{status}); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); +} diff --git a/cassandane/tiny-tests/Caldav/supports_event b/cassandane/tiny-tests/Caldav/supports_event new file mode 100644 index 0000000000..d549c1b05c --- /dev/null +++ b/cassandane/tiny-tests/Caldav/supports_event @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +sub test_supports_event + :min_version_3_1 :needs_component_httpd :needs_component_jmap +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $Calendar = $CalDAV->GetCalendar($CalendarId); + + $self->assert($Calendar->{_can_event}); +} diff --git a/cassandane/tiny-tests/Caldav/url_nodomains b/cassandane/tiny-tests/Caldav/url_nodomains new file mode 100644 index 0000000000..0b0c1df7e4 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/url_nodomains @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_url_nodomains + :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "create calendar"; + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + xlog $self, "fetch again"; + my $Calendar = $CalDAV->GetCalendar($CalendarId); + $self->assert_not_null($Calendar); + + xlog $self, "check that the href has no domain"; + $self->assert_str_equals("/dav/calendars/user/cassandane/$CalendarId/", $Calendar->{href}); +} diff --git a/cassandane/tiny-tests/Caldav/url_virtdom_domain b/cassandane/tiny-tests/Caldav/url_virtdom_domain new file mode 100644 index 0000000000..79e94d09fb --- /dev/null +++ b/cassandane/tiny-tests/Caldav/url_virtdom_domain @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_url_virtdom_domain + :VirtDomains :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.test\@example.com"); + $admintalk->setacl("user.test\@example.com", "test\@example.com" => "lrswipkxtecda"); + + my $service = $self->{instance}->get_service("http"); + my $caltalk = Net::CalDAVTalk->new( + user => "test\@example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create calendar"; + my $CalendarId = $caltalk->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + xlog $self, "fetch again"; + my $Calendar = $caltalk->GetCalendar($CalendarId); + $self->assert_not_null($Calendar); + + xlog $self, "check that the href has domain"; + $self->assert_str_equals("/dav/calendars/user/test\@example.com/$CalendarId/", $Calendar->{href}); +} diff --git a/cassandane/tiny-tests/Caldav/url_virtdom_extradomain b/cassandane/tiny-tests/Caldav/url_virtdom_extradomain new file mode 100644 index 0000000000..bedae2cff3 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/url_virtdom_extradomain @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_url_virtdom_extradomain + :VirtDomains :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $service = $self->{instance}->get_service("http"); + my $caltalk = Net::CalDAVTalk->new( + user => "cassandane%example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create calendar"; + my $CalendarId = $caltalk->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + xlog $self, "fetch again"; + my $Calendar = $caltalk->GetCalendar($CalendarId); + $self->assert_not_null($Calendar); + + xlog $self, "check that the href has domain"; + $self->assert_str_equals("/dav/calendars/user/cassandane\@example.com/$CalendarId/", $Calendar->{href}); +} diff --git a/cassandane/tiny-tests/Caldav/url_virtdom_nodomain b/cassandane/tiny-tests/Caldav/url_virtdom_nodomain new file mode 100644 index 0000000000..1580874c75 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/url_virtdom_nodomain @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_url_virtdom_nodomain + :VirtDomains :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "create calendar"; + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + xlog $self, "fetch again"; + my $Calendar = $CalDAV->GetCalendar($CalendarId); + $self->assert_not_null($Calendar); + + xlog $self, "check that the href has no domain"; + $self->assert_str_equals("/dav/calendars/user/cassandane/$CalendarId/", $Calendar->{href}); +} diff --git a/cassandane/tiny-tests/Caldav/user_rename b/cassandane/tiny-tests/Caldav/user_rename new file mode 100644 index 0000000000..3848dcb700 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/user_rename @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_user_rename + :AllowMoves :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "create calendar"; + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + xlog $self, "fetch again"; + my $Calendar = $CalDAV->GetCalendar($CalendarId); + $self->assert_not_null($Calendar); + + xlog $self, "check name matches"; + $self->assert_str_equals('foo', $Calendar->{name}); + + xlog $self, "rename user"; + $admintalk->rename("user.cassandane", "user.newuser"); + + my $service = $self->{instance}->get_service("http"); + my $newtalk = Net::CalDAVTalk->new( + user => 'newuser', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "fetch as new user $CalendarId"; + my $NewCalendar = $newtalk->GetCalendar($CalendarId); + $self->assert_not_null($NewCalendar); + + xlog $self, "check new name stuck"; + $self->assert_str_equals($NewCalendar->{name}, 'foo'); +} diff --git a/cassandane/tiny-tests/Caldav/user_rename_dom b/cassandane/tiny-tests/Caldav/user_rename_dom new file mode 100644 index 0000000000..30dd4d92b7 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/user_rename_dom @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_user_rename_dom + :AllowMoves :VirtDomains :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user.test\@example.com"); + $admintalk->setacl("user.test\@example.com", "test\@example.com" => "lrswipkxtecda"); + + my $service = $self->{instance}->get_service("http"); + my $oldtalk = Net::CalDAVTalk->new( + user => "test\@example.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create calendar"; + my $CalendarId = $oldtalk->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + xlog $self, "fetch again"; + my $Calendar = $oldtalk->GetCalendar($CalendarId); + $self->assert_not_null($Calendar); + + xlog $self, "check name matches"; + $self->assert_str_equals($Calendar->{name}, 'foo'); + + xlog $self, "rename user"; + $admintalk->rename("user.test\@example.com", "user.test2\@example2.com"); + + my $newtalk = Net::CalDAVTalk->new( + user => "test2\@example2.com", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "fetch as new user $CalendarId"; + my $NewCalendar = $newtalk->GetCalendar($CalendarId); + $self->assert_not_null($NewCalendar); + + xlog $self, "check new name stuck"; + $self->assert_str_equals($NewCalendar->{name}, 'foo'); +} diff --git a/cassandane/tiny-tests/Caldav/utf8_url b/cassandane/tiny-tests/Caldav/utf8_url new file mode 100644 index 0000000000..a2c07786f1 --- /dev/null +++ b/cassandane/tiny-tests/Caldav/utf8_url @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_utf8_url + :min_version_3_9 :needs_component_httpd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uid = "%E2%98%83"; # percent-encoded ☃"; + my $href = $CalDAV->request_url('') . "/$CalendarId/$uid.ics"; + + my $event = < 'text/calendar; charset=utf-8', + 'Authorization' => $CalDAV->auth_header()); + + utf8::encode($event); + + # This will fail if the UTF-8 resource name isn't handled properly + my $res = $CalDAV->{ua}->request('PUT', $href, { + headers => \%headers, + content => $event + }); + $self->assert_str_equals('201', $res->{status}); + + $res = $CalDAV->{ua}->request('GET', $href, { + headers => \%headers + }); + $self->assert_str_equals('200', $res->{status}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/allday_notz b/cassandane/tiny-tests/CaldavAlarm/allday_notz new file mode 100644 index 0000000000..7b081f916f --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/allday_notz @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_allday_notz + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start today + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(days => 1)); + $startdt->truncate(to => 'day'); + my $start = $startdt->strftime('%Y%m%d'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(days => 1)); + my $end = $enddt->strftime('%Y%m%d'); + + my $utc = DateTime::Format::ISO8601->new->parse_datetime($start . 'T000000Z'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "95989f3d-575f-4828-9610-6f16b9d54d04"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $utc->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $utc->epoch() + 60 ); + + $self->assert_alarms({summary => 'allday', start => $start, timezone => '[floating]'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/allday_sametz b/cassandane/tiny-tests/CaldavAlarm/allday_sametz new file mode 100644 index 0000000000..04a69cc405 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/allday_sametz @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +sub test_allday_sametz + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $tz = <NewCalendar({name => 'foo', timeZone => $tz}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Brisbane'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in 2 days + # (need to go 2 days out to account for the timezone of the testing location, + # otherwise we may store the event locally AFTER the adjusted alarm trigger) + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(days => 2)); + $startdt->truncate(to => 'day'); + my $start = $startdt->strftime('%Y%m%d'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(days => 1)); + my $end = $enddt->strftime('%Y%m%d'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "95989f3d-575f-4828-9610-6f16b9d54d04"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch() + 60 ); + + $self->assert_alarms({summary => 'allday', start => $start, timezone => 'Australia/Brisbane'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/disable_high_freq b/cassandane/tiny-tests/CaldavAlarm/disable_high_freq new file mode 100644 index 0000000000..62ec645dae --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/disable_high_freq @@ -0,0 +1,165 @@ +#!perl +use Cassandane::Tiny; + +# this test depends on calendar_min_alarm_interval=61 which is configured in new() +sub test_disable_high_freq + :min_version_3_7 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Etc/UTC'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%SZ'); + my $startsec = $startdt->second; + my $startmin = $startdt->minute; + + # create hourly, minutely and secondly occurring events + # + # hourly with interval= + # 1,31,61: should result in an alarm since there is a > 60s interval + # + # minutely with interval= + # 1: should NOT result in an alarm since there is only a 60s interval + # 31,61: should NOT result in an alarm since there is a > 60s interval + # + # secondly with interval= + # 1,31: should NOT result in an alarm since there is a < 60s interval + # 61 should result in an alarm since there is a 60s interval + # + for my $freq (qw(HOURLY MINUTELY SECONDLY)) { + for (my $int = 1; $int < 90; $int += 30) { + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9-$freq-$int"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + } + } + + # create minutely occurring events with bysecond=startsec + # + # interval=1 should NOT result in an alarm since there is only a 60s interval + # + # interval=2 should result in an alarm since there is a 120s interval + # + for (my $int = 1; $int < 3; $int += 1) { + my $freq = 'MINUTELY'; + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9-$freq-$int-$startsec"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + } + + # create hourly occurring events with a set of byminute + # + # byminute=startmin and +1 should fail since there is only a 60s interval + # + # byminute=startmin and +2 should succeed since there is a 120s interval + # + my $bymin_ok = ($startmin + 2) % 60; + foreach my $addend (1..2) { + my $bymin = ($startmin + $addend) % 60; + + my $freq = 'HOURLY'; + my $int = 1; + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9-$freq-$int-$bymin"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + } + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + # assert that only the alarms that fire at >= 61s intervals are created + $self->assert_alarms({summary => 'HOURLY-1', start => $start }, + {summary => 'HOURLY-31', start => $start }, + {summary => 'HOURLY-61', start => $start }, + {summary => 'MINUTELY-31', start => $start }, + {summary => 'MINUTELY-61', start => $start }, + {summary => 'SECONDLY-61', start => $start }, + {summary => "MINUTELY-2-$startsec", start => $start }, + {summary => "HOURLY-1-$bymin_ok", start => $start }); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/floating_differenttz b/cassandane/tiny-tests/CaldavAlarm/floating_differenttz new file mode 100644 index 0000000000..6ae51d7799 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/floating_differenttz @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_floating_differenttz + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + return if not $self->{test_calalarmd}; + + my $CalDAV = $self->{caldav}; + + my $tz = <NewCalendar({name => 'foo', timeZone => $tz}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + my $syd = DateTime::TimeZone->new( name => 'Australia/Sydney' ); + my $ny = DateTime::TimeZone->new( name => 'America/New_York' ); + my $offset = $syd->offset_for_datetime($now) - $ny->offset_for_datetime($now); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "95989f3d-575f-4828-9610-6f16b9d54d04"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + # no alarms + $self->assert_alarms(); + + # trigger processing in New York + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 + $offset ); + + # alarm fires + $self->assert_alarms({summary => 'Floating', timezone => 'America/New_York', start => $start}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/floating_notz b/cassandane/tiny-tests/CaldavAlarm/floating_notz new file mode 100644 index 0000000000..23a7bc49a1 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/floating_notz @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_floating_notz + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $utc = DateTime::Format::ISO8601->new->parse_datetime($start . 'Z'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "95989f3d-575f-4828-9610-6f16b9d54d04"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $utc->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $utc->epoch() + 60 ); + + $self->assert_alarms({summary => 'Floating', start => $start, timezone => '[floating]'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/floating_sametz b/cassandane/tiny-tests/CaldavAlarm/floating_sametz new file mode 100644 index 0000000000..bde2759b65 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/floating_sametz @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_floating_sametz + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $tz = <NewCalendar({name => 'foo', timeZone => $tz}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "95989f3d-575f-4828-9610-6f16b9d54d04"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Floating'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/override b/cassandane/tiny-tests/CaldavAlarm/override new file mode 100644 index 0000000000..2d00c1c3e2 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/override @@ -0,0 +1,112 @@ +#!perl +use Cassandane::Tiny; + +sub test_override + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define an event that started almost an hour ago and repeats hourly + my $startdt = $now->clone(); + $startdt->subtract(DateTime::Duration->new(minutes => 59, seconds => 55)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # the next event will start in a few seconds + my $recuriddt = $now->clone(); + $recuriddt->add(DateTime::Duration->new(seconds => 5)); + my $recurid = $recuriddt->strftime('%Y%m%dT%H%M%S'); + + my $rstartdt = $recuriddt->clone(); + my $recurstart = $recuriddt->strftime('%Y%m%dT%H%M%S'); + + my $renddt = $rstartdt->clone(); + $renddt->add(DateTime::Duration->new(seconds => 15)); + my $recurend = $renddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + # trigger processing of alarms + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'exception', start => $recurstart}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/override_double b/cassandane/tiny-tests/CaldavAlarm/override_double new file mode 100644 index 0000000000..28eb431c31 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/override_double @@ -0,0 +1,116 @@ +#!perl +use Cassandane::Tiny; + +sub test_override_double + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define an event that started almost an hour ago and repeats hourly + my $startdt = $now->clone(); + $startdt->subtract(DateTime::Duration->new(minutes => 59, seconds => 55)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # the next event will start in a few seconds + my $recuriddt = $now->clone(); + $recuriddt->add(DateTime::Duration->new(seconds => 5)); + my $recurid = $recuriddt->strftime('%Y%m%dT%H%M%S'); + + my $rstartdt = $recuriddt->clone(); + my $recurstart = $recuriddt->strftime('%Y%m%dT%H%M%S'); + + my $renddt = $rstartdt->clone(); + $renddt->add(DateTime::Duration->new(seconds => 15)); + my $recurend = $renddt->strftime('%Y%m%dT%H%M%S'); + + my $lastrepl = $recuriddt->clone(); + $lastrepl->add(DateTime::Duration->new(minutes => 60)); + my $lastalarm = $lastrepl->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + # trigger processing of alarms + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 6000 ); + + $self->assert_alarms({summary => 'exception', start => $recurstart}, {summary => 'main', start => $lastalarm}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/override_exception b/cassandane/tiny-tests/CaldavAlarm/override_exception new file mode 100644 index 0000000000..0213d34ee4 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/override_exception @@ -0,0 +1,114 @@ +#!perl +use Cassandane::Tiny; + +sub test_override_exception + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define an event that started almost an hour ago and repeats hourly + my $startdt = $now->clone(); + $startdt->subtract(DateTime::Duration->new(minutes => 59, seconds => 55)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # the next event will start in a few seconds + my $recuriddt = $now->clone(); + $recuriddt->add(DateTime::Duration->new(seconds => 5)); + my $recurid = $recuriddt->strftime('%Y%m%dT%H%M%S'); + + # but it starts a few seconds after the regular start + my $rstartdt = $now->clone(); + $rstartdt->add(DateTime::Duration->new(seconds => 15)); + my $recurstart = $rstartdt->strftime('%Y%m%dT%H%M%S'); + + my $renddt = $rstartdt->clone(); + $renddt->add(DateTime::Duration->new(seconds => 15)); + my $recurend = $renddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + # trigger processing of alarms + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'exception', start => $recurstart}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/override_multiuser b/cassandane/tiny-tests/CaldavAlarm/override_multiuser new file mode 100644 index 0000000000..3aa824e441 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/override_multiuser @@ -0,0 +1,196 @@ +#!perl +use Cassandane::Tiny; + +sub test_override_multiuser + :min_version_3_1 :needs_component_calalarmd :NoAltNameSpace +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $AdminTalk = $self->{adminstore}->get_client(); + $AdminTalk->create("user.foo"); + $AdminTalk->setacl("user.cassandane.#calendars.$CalendarId", "foo", "lrswipkxtecdn789"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo"); + my $footalk = $foostore->get_client(); + $footalk->subscribe("user.cassandane.#calendars.$CalendarId"); + + my $service = $self->{instance}->get_service("http"); + my $FooDAV = Net::CalDAVTalk->new( + user => 'foo', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $cal = $FooDAV->GetCalendar("cassandane.$CalendarId"); + $self->assert_not_null($cal); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $nextweekdt = $now->clone(); + $nextweekdt->add(DateTime::Duration->new(days => 7)); + my $nextweek = $nextweekdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + my $nwenddt = $nextweekdt->clone(); + $nwenddt->add(DateTime::Duration->new(seconds => 15)); + my $nwend = $nwenddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $cardtmpl = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my $Events = $CalDAV->GetEvents("$CalendarId"); + my $FooEvents = $FooDAV->GetEvents("cassandane.$CalendarId"); + $self->assert_num_equals(1, scalar @$Events); + $self->assert_num_equals(1, scalar @$FooEvents); + $self->assert_null($Events->[0]{alerts}); + $self->assert_null($FooEvents->[0]{alerts}); + + my $foocard = $cardtmpl; + $foocard =~ s/SUMMARY:EV2/SUMMARY:EV2\n$latealarm/; + $FooDAV->Request('PUT', $FooEvents->[0]{href}, $foocard, 'Content-Type' => 'text/calendar'); + + my $cascard = $cardtmpl; + $cascard =~ s/SUMMARY:EV1/SUMMARY:EV1\n$alarm/; + $CalDAV->Request('PUT', $Events->[0]{href}, $cascard, 'Content-Type' => 'text/calendar'); + + $Events = $CalDAV->GetEvents("$CalendarId"); + $FooEvents = $FooDAV->GetEvents("cassandane.$CalendarId"); + $self->assert_num_equals(1, scalar @$Events); + $self->assert_num_equals(1, scalar @$FooEvents); + $self->assert_null($Events->[0]{alerts}); + $self->assert_null($FooEvents->[0]{alerts}); + + # XXX - assert the recurrences + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'EV1', userId => 'cassandane', alarmTime => $start, action => 'email', start => $start}); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $nextweekdt->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $nextweekdt->epoch() + 60 ); + + # need to version-gate features that aren't in 3.1... + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min >= 9)) { + $self->assert_alarms({summary => 'EV2', userId => 'foo', calendarOwner => 'cassandane', alarmTime => $nextweek, action => 'display', start => $nextweek}); + } + else { + $self->assert_alarms({summary => 'EV2', userId => 'foo', alarmTime => $nextweek, action => 'display', start => $nextweek}); + } +} diff --git a/cassandane/tiny-tests/CaldavAlarm/recurring_absolute_trigger b/cassandane/tiny-tests/CaldavAlarm/recurring_absolute_trigger new file mode 100644 index 0000000000..f6a13dd54b --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/recurring_absolute_trigger @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +sub test_recurring_absolute_trigger + :min_version_3_7 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%SZ'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%SZ'); + + # set the trigger to notify us at the start of the event + my $trigger = $startdt->strftime('%Y%m%dT%H%M%SZ'); + + # calculate start time for second instance + my $recuriddt = $startdt->clone(); + $recuriddt->add(DateTime::Duration->new(days => 1)); + my $recurid = $recuriddt->strftime('%Y%m%dT%H%M%SZ'); + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + # adjust now to UTC + $now->add(DateTime::Duration->new(seconds => $now->offset())); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + # fire alarms for first instance + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Simple', start => $start, action => 'display'}, + {summary => 'Simple', start => $start, action => 'email'}); + + # fire alarm for second instance + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 86400 + 60 ); + + $self->assert_alarms({summary => 'Simple', start => $recurid, action => 'email'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/recurring_allday_floating b/cassandane/tiny-tests/CaldavAlarm/recurring_allday_floating new file mode 100644 index 0000000000..f7023e9aca --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/recurring_allday_floating @@ -0,0 +1,163 @@ +#!perl +use Cassandane::Tiny; + +sub test_recurring_allday_floating + :min_version_3_9 :needs_component_calalarmd :needs_component_jmap +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $jmap = $self->{jmap}; + +my $UTC = <NewCalendar({name => 'foo', timeZone => $UTC}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->today(); + $now->set_time_zone('Etc/UTC'); + + # define the event to start next week + my $startdt = $now->clone(); + $startdt->add(days => 7); + my $start = $startdt->strftime('%Y%m%d'); + + # set the trigger to notify us 5 hours before the event + my $trigger="-PT5H"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $now->subtract(hours => 5); + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms(); + + $now->add(days => 7); + + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60); + + $self->assert_alarms({summary => 'Simple', start => $start}); + + $now->add(days => 7); + $startdt->add(days => 7); + $start = $startdt->strftime('%Y%m%d'); + + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60); + + $self->assert_alarms({summary => 'Simple', start => $start}); + + # Change floating time zone on the calendar 2 hours to the east + my $xml = < + + + + Etc/GMT-2 + + + +EOF + + my $res = $CalDAV->Request('PROPPATCH', + "/dav/calendars/user/cassandane/". $CalendarId, + $xml, 'Content-Type' => 'text/xml'); + + $now->add(days => 7); + $startdt->add(days => 7); + $start = $startdt->strftime('%Y%m%d'); + + # Need to trigger 2 hours earlier + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() - 7200 + 60); + + $self->assert_alarms({summary => 'Simple', start => $start}); + + # Change floating time zone on the calendar 2 more hours to the east + $res = $jmap->CallMethods([ + ['Calendar/set', {update => {$CalendarId => { + timeZone => "Etc/GMT-4" + }}}, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_not_null($res->[0][1]{updated}); + + $now->add(days => 7); + $startdt->add(days => 7); + $start = $startdt->strftime('%Y%m%d'); + + # Need to trigger 4 hours earlier + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() - 14400 + 60); + + $self->assert_alarms({summary => 'Simple', start => $start}); + + # Change floating time zone on the calendar back to original + $xml = < + + + + $UTC + + + +EOF + + $res = $CalDAV->Request('PROPPATCH', + "/dav/calendars/user/cassandane/". $CalendarId, + $xml, 'Content-Type' => 'text/xml'); + + $now->add(days => 7); + $startdt->add(days => 7); + $start = $startdt->strftime('%Y%m%d'); + + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60); + + $self->assert_alarms({summary => 'Simple', start => $start}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/replication_at1 b/cassandane/tiny-tests/CaldavAlarm/replication_at1 new file mode 100644 index 0000000000..9729cb163e --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/replication_at1 @@ -0,0 +1,137 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication_at1 + :min_version_3_0 :needs_component_calalarmd :NoReplicaOnly +{ + my ($self) = @_; + + $self->assert_not_null($self->{replica}); + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define an event that starts now and repeats hourly + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 60)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 60)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # the next event will start in a few seconds + my $recuriddt = $startdt->clone(); + $recuriddt->add(DateTime::Duration->new(minutes => 60)); + my $recurid = $recuriddt->strftime('%Y%m%dT%H%M%S'); + + # but it starts a few seconds after the regular start + my $rstartdt = $recuriddt->clone(); + $rstartdt->add(DateTime::Duration->new(seconds => 15)); + my $recurstart = $recuriddt->strftime('%Y%m%dT%H%M%S'); + + my $renddt = $rstartdt->clone(); + $renddt->add(DateTime::Duration->new(seconds => 60)); + my $recurend = $renddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # replicate to the other end + $self->run_replication(); + + # clean notification cache + $self->{instance}->getnotify(); + + # trigger processing of alarms + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 500 ); + $self->assert_alarms({summary => 'main'}); + + # no alarm when you run the second time + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 500 ); + $self->assert_alarms(); + + # replicate to the other end + $self->run_replication(); + + # running on the replica gets the exception, not the first instance + $self->{replica}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5000 ); + $self->assert_alarms({summary => 'exception'}); + + # no alarm when you run the second time + $self->{replica}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5000 ); + $self->assert_alarms(); + + # running on the master still gets the exception, because it doesn't know about the change + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5000 ); + $self->assert_alarms({summary => 'exception'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/replication_withalarms_in_tz_with_dst b/cassandane/tiny-tests/CaldavAlarm/replication_withalarms_in_tz_with_dst new file mode 100644 index 0000000000..eab3a7b5e9 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/replication_withalarms_in_tz_with_dst @@ -0,0 +1,111 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication_withalarms_in_tz_with_dst + :min_version_3_0 :needs_component_calalarmd :NoReplicaOnly +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event which recurs weekly + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5 ); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->run_replication(nosyncback => 1); + + $self->assert_alarms(); + + $self->{replica}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5 ); + + $self->assert_alarms(); + + $self->{replica}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5 ); + + $self->assert_alarms(); + + $self->run_replication(nosyncback => 1); + + $self->assert_alarms(); + + # Check if DST is applicable + my $tdt = $now->clone(); + $tdt->add(DateTime::Duration->new(seconds => ((86400 * 7) + 5))); + if (!$tdt->is_dst()) { + # Not in DST, add an hour + $tdt = $tdt->add(DateTime::Duration->new(seconds => (60 * 60))); + } + + $self->{replica}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $tdt->epoch()); + + # should be a new alarm here + $self->assert_alarms({summary => 'Simple'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/replication_withalarms_in_tz_without_dst b/cassandane/tiny-tests/CaldavAlarm/replication_withalarms_in_tz_without_dst new file mode 100644 index 0000000000..ea2e24dd41 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/replication_withalarms_in_tz_without_dst @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication_withalarms_in_tz_without_dst + :min_version_3_0 :needs_component_calalarmd :NoReplicaOnly +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Asia/Singapore'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event which recurs weekly + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5 ); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->run_replication(nosyncback => 1); + + $self->assert_alarms(); + + $self->{replica}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5 ); + + $self->assert_alarms(); + + $self->{replica}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 5 ); + + $self->assert_alarms(); + + $self->run_replication(nosyncback => 1); + + $self->assert_alarms(); + + $self->{replica}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + (86400*7) + 5); + + # should be a new alarm here + $self->assert_alarms({summary => 'Simple'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/reschedule_exception b/cassandane/tiny-tests/CaldavAlarm/reschedule_exception new file mode 100644 index 0000000000..fec28d88b1 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/reschedule_exception @@ -0,0 +1,190 @@ +#!perl +use Cassandane::Tiny; + +sub test_reschedule_exception + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + # FIXME disable this test until calalarmd is fixed + return; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Europe/Vienna'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define an event that started yesterday and repeats daily + my $startdt = $now->clone(); + $startdt->subtract(DateTime::Duration->new(hours => 23)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(minutes => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # the next event will start in one hour + my $recuriddt = $now->clone(); + $recuriddt->add(DateTime::Duration->new(hours => 1)); + my $recurid = $recuriddt->strftime('%Y%m%dT%H%M%S'); + + # but it exceptionally starts in two hours + my $rstartdt = $now->clone(); + $rstartdt->add(DateTime::Duration->new(hours => 2)); + my $recurstart = $rstartdt->strftime('%Y%m%dT%H%M%S'); + my $renddt = $rstartdt->clone(); + $renddt->add(DateTime::Duration->new(minutes => 15)); + my $recurend = $renddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + # trigger processing of alarms: wall clock for calalarmd is 10 seconds *after* + # the occurrence of the exception. This will trigger it to fire its alarm. + xlog $self, "run calalarmd"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $rstartdt->epoch() + 10 ); + + $self->assert_alarms({summary => 'exception', start => $recurstart}); + + # reschedule the exception event to start one hour later than the original + # exceptional start time. + $rstartdt->add(DateTime::Duration->new(hours => 1)); + $recurstart = $rstartdt->strftime('%Y%m%dT%H%M%S'); + $renddt = $rstartdt->clone(); + $renddt->add(DateTime::Duration->new(minutes => 15)); + $recurend = $renddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "Re-run calalarmd"; + # trigger processing of alarms: wall clock now is 10 seconds after the + # newly scheduled exception time + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $rstartdt->epoch() + 10 ); + + $self->assert_alarms({summary => 'rescheduled', start => $recurstart}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/reschedule_later b/cassandane/tiny-tests/CaldavAlarm/reschedule_later new file mode 100644 index 0000000000..0bebc74816 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/reschedule_later @@ -0,0 +1,106 @@ +#!perl +use Cassandane::Tiny; + +sub test_reschedule_later + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Simple', start => $start}); + + # define the event to start in a few seconds + my $newstartdt = $startdt->clone(); + $newstartdt->add(DateTime::Duration->new(seconds => 86400)); + my $newstart = $newstartdt->strftime('%Y%m%dT%H%M%S'); + + my $newenddt = $enddt->clone(); + $newenddt->add(DateTime::Duration->new(seconds => 86400)); + my $newend = $newenddt->strftime('%Y%m%dT%H%M%S'); + + $card =~ s/$start/$newstart/; + $card =~ s/$end/$newend/; + + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # nothing happens 1 second later + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 61 ); + + $self->assert_alarms(); + + # alarm happens one day later + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 + 86400 ); + + $self->assert_alarms({summary => 'Simple', start => $newstart}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple b/cassandane/tiny-tests/CaldavAlarm/simple new file mode 100644 index 0000000000..d452a17bd3 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Simple', start => $start, timezone => 'Australia/Sydney'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple_attendee_no_partstat b/cassandane/tiny-tests/CaldavAlarm/simple_attendee_no_partstat new file mode 100644 index 0000000000..a57a8a0f54 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple_attendee_no_partstat @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple_attendee_no_partstat + :min_version_3_7 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Simple', start => $start, timezone => 'Australia/Sydney'}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple_cancelled b/cassandane/tiny-tests/CaldavAlarm/simple_cancelled new file mode 100644 index 0000000000..06642abc10 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple_cancelled @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple_cancelled + :min_version_3_7 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms(); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple_declined b/cassandane/tiny-tests/CaldavAlarm/simple_declined new file mode 100644 index 0000000000..81e24f2338 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple_declined @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple_declined + :min_version_3_7 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms(); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple_declined_alias b/cassandane/tiny-tests/CaldavAlarm/simple_declined_alias new file mode 100644 index 0000000000..98cfd27fa8 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple_declined_alias @@ -0,0 +1,106 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple_declined_alias + :min_version_3_7 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $xml = < + + + + + mailto:cassandane\@example.com + mailto:cass\@example.com + + + + +EOF + + $CalDAV->Request('PROPPATCH', "/dav/principals/user/cassandane", $xml, + 'Content-Type' => 'text/xml'); + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms(); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple_multiuser b/cassandane/tiny-tests/CaldavAlarm/simple_multiuser new file mode 100644 index 0000000000..9b653cf805 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple_multiuser @@ -0,0 +1,157 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple_multiuser + :min_version_3_1 :needs_component_calalarmd :NoAltNameSpace +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $AdminTalk = $self->{adminstore}->get_client(); + $AdminTalk->create("user.foo"); + $AdminTalk->setacl("user.cassandane.#calendars.$CalendarId", "foo", "lrswipkxtecdn789"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo"); + my $footalk = $foostore->get_client(); + $footalk->subscribe("user.cassandane.#calendars.$CalendarId"); + + my $service = $self->{instance}->get_service("http"); + my $FooDAV = Net::CalDAVTalk->new( + user => 'foo', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $cal = $FooDAV->GetCalendar("cassandane.$CalendarId"); + $self->assert_not_null($cal); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + my $latedt = $startdt->clone(); + $latedt->add(DateTime::Duration->new(seconds => 300)); + my $late = $latedt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $cardtmpl = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my $Events = $CalDAV->GetEvents("$CalendarId"); + my $FooEvents = $FooDAV->GetEvents("cassandane.$CalendarId"); + $self->assert_num_equals(1, scalar @$Events); + $self->assert_num_equals(1, scalar @$FooEvents); + $self->assert_not_null($Events->[0]{alerts}); + # foo event does not yet have alarms + $self->assert_null($FooEvents->[0]{alerts}); + + my $foocard = $cardtmpl; + $foocard =~ s/XXALARMDATAXX/$latealarm/; + $FooDAV->Request('PUT', $FooEvents->[0]{href}, $foocard, 'Content-Type' => 'text/calendar'); + + $Events = $CalDAV->GetEvents("$CalendarId"); + $FooEvents = $FooDAV->GetEvents("cassandane.$CalendarId"); + $self->assert_num_equals(1, scalar @$Events); + $self->assert_num_equals(1, scalar @$FooEvents); + $self->assert_not_null($Events->[0]{alerts}); + # foo event has alarms + $self->assert_not_null($FooEvents->[0]{alerts}); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Simple', userId => 'cassandane', alarmTime => $start, start => $start}); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 600 ); + + $self->assert_alarms({summary => 'Simple', userId => 'foo', alarmTime => $late, start => $start}); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 900 ); + + $self->assert_alarms(); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple_multiuser_sametime b/cassandane/tiny-tests/CaldavAlarm/simple_multiuser_sametime new file mode 100644 index 0000000000..a2cd552902 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple_multiuser_sametime @@ -0,0 +1,131 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple_multiuser_sametime + :min_version_3_1 :needs_component_calalarmd :NoAltNameSpace +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $AdminTalk = $self->{adminstore}->get_client(); + $AdminTalk->create("user.foo"); + $AdminTalk->setacl("user.cassandane.#calendars.$CalendarId", "foo", "lrswipkxtecdn789"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo"); + my $footalk = $foostore->get_client(); + $footalk->subscribe("user.cassandane.#calendars.$CalendarId"); + + my $service = $self->{instance}->get_service("http"); + my $FooDAV = Net::CalDAVTalk->new( + user => 'foo', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $cal = $FooDAV->GetCalendar("cassandane.$CalendarId"); + $self->assert_not_null($cal); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "c10bea47-f280-4fba-b627-d1bc263c7666"; + my $href = "$CalendarId/$uuid.ics"; + my $cardtmpl = <Request('PUT', "cassandane.$href", $foocard, 'Content-Type' => 'text/calendar'); + + my $Events = $CalDAV->GetEvents("$CalendarId"); + my $FooEvents = $FooDAV->GetEvents("cassandane.$CalendarId"); + $self->assert_num_equals(1, scalar @$Events); + $self->assert_num_equals(1, scalar @$FooEvents); + # cassandane event does not yet have alarms + $self->assert_null($Events->[0]{alerts}); + $self->assert_not_null($FooEvents->[0]{alerts}); + + my $card = $cardtmpl; + $CalDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + $Events = $CalDAV->GetEvents("$CalendarId"); + $FooEvents = $FooDAV->GetEvents("cassandane.$CalendarId"); + $self->assert_num_equals(1, scalar @$Events); + $self->assert_num_equals(1, scalar @$FooEvents); + # now both have alarms + $self->assert_not_null($Events->[0]{alerts}); + $self->assert_not_null($FooEvents->[0]{alerts}); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Simple', userId => 'foo', alarmTime => $start, start => $start}, + {summary => 'Simple', userId => 'cassandane', alarmTime => $start, start => $start}); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple_multiuser_secretary b/cassandane/tiny-tests/CaldavAlarm/simple_multiuser_secretary new file mode 100644 index 0000000000..73a9852b20 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple_multiuser_secretary @@ -0,0 +1,160 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple_multiuser_secretary + :min_version_3_3 :needs_component_calalarmd :NoAltNameSpace :needs_component_jmap +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $AdminTalk = $self->{adminstore}->get_client(); + $AdminTalk->create("user.foo"); + $AdminTalk->setacl("user.cassandane.#calendars.$CalendarId", "foo", "lrswipkxtecdn789"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo"); + my $footalk = $foostore->get_client(); + $footalk->subscribe("user.cassandane.#calendars.$CalendarId"); + + my $service = $self->{instance}->get_service("http"); + my $FooDAV = Net::CalDAVTalk->new( + user => 'foo', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $cal = $FooDAV->GetCalendar("cassandane.$CalendarId"); + $self->assert_not_null($cal); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + my $latedt = $startdt->clone(); + $latedt->add(DateTime::Duration->new(seconds => 300)); + my $late = $latedt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $cardtmpl = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + my $Events = $CalDAV->GetEvents("$CalendarId"); + my $FooEvents = $FooDAV->GetEvents("cassandane.$CalendarId"); + $self->assert_num_equals(1, scalar @$Events); + $self->assert_num_equals(1, scalar @$FooEvents); + $self->assert_not_null($Events->[0]{alerts}); + # foo event does not yet have alarms + $self->assert_null($FooEvents->[0]{alerts}); + + my $foocard = $cardtmpl; + $foocard =~ s/XXALARMDATAXX/$latealarm/; + $FooDAV->Request('PUT', $FooEvents->[0]{href}, $foocard, 'Content-Type' => 'text/calendar'); + + $Events = $CalDAV->GetEvents("$CalendarId"); + $FooEvents = $FooDAV->GetEvents("cassandane.$CalendarId"); + $self->assert_num_equals(1, scalar @$Events); + $self->assert_num_equals(1, scalar @$FooEvents); + $self->assert_not_null($Events->[0]{alerts}); + # foo event has alarms + $self->assert_not_null($FooEvents->[0]{alerts}); + + # set secretary mode + my $xml = < + + + + secretary + + + +EOF + $CalDAV->Request('PROPPATCH', "/dav/calendars/user/cassandane", $xml, + 'Content-Type' => 'text/xml'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Simple', userId => 'cassandane', alarmTime => $start, start => $start}); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 600 ); + + $self->assert_alarms(); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/simple_reconstruct b/cassandane/tiny-tests/CaldavAlarm/simple_reconstruct new file mode 100644 index 0000000000..a9a00a4379 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/simple_reconstruct @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +sub test_simple_reconstruct + :min_version_3_0 :needs_component_calalarmd +{ + my ($self) = @_; + + my $CalDAV = $self->{caldav}; + + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $enddt = $startdt->clone(); + $enddt->add(DateTime::Duration->new(seconds => 15)); + my $end = $enddt->strftime('%Y%m%dT%H%M%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$CalendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() - 60 ); + + $self->{instance}->run_command({ cyrus => 1 }, 'dav_reconstruct', 'cassandane'); + + $self->assert_alarms(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + $self->assert_alarms({summary => 'Simple', start => $start}); + + $self->{instance}->run_command({ cyrus => 1 }, 'dav_reconstruct', 'cassandane'); + + $self->assert_alarms(); +} diff --git a/cassandane/tiny-tests/CaldavAlarm/suppress_duplicates b/cassandane/tiny-tests/CaldavAlarm/suppress_duplicates new file mode 100644 index 0000000000..34888cbd63 --- /dev/null +++ b/cassandane/tiny-tests/CaldavAlarm/suppress_duplicates @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_suppress_duplicates + :needs_component_calalarmd +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + my $now = DateTime->now(); + $now->set_time_zone('Etc/UTC'); + + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%SZ'); + + my $ical = <Request('PUT', 'Default/test.ics', $ical, + 'Content-Type' => 'text/calendar'); + + $self->{instance}->getnotify(); + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60); + + $self->assert_alarms( + {action => 'display', alarmTime => $start, alertId => 'displayAlert1' }, + {action => 'email', alarmTime => $start, alertId => 'emailAlert1' }, + ); +} diff --git a/cassandane/tiny-tests/Carddav/addressbook_default_name b/cassandane/tiny-tests/Carddav/addressbook_default_name new file mode 100644 index 0000000000..7c691a1b65 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/addressbook_default_name @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_default_name + :needs_component_httpd +{ + my ($self) = @_; + + my $carddav = $self->{carddav}; + + xlog $self, 'PROPFIND default displayname'; + my $res = $carddav->Request( + 'PROPFIND', + 'Default', + x('D:propfind', $carddav->NS(), + x('D:prop', + x('D:displayname'), + ), + ), + 'Content-Type' => 'application/xml', + 'Depth' => '0' + ); + + $self->assert_str_equals('Personal', $res->{'{DAV:}response'}[0]{ + '{DAV:}propstat'}[0]{'{DAV:}prop'}{'{DAV:}displayname'}{content}); + + xlog $self, "PROPPATCH remove displayname"; + $res = $carddav->Request( + 'PROPPATCH', + 'Default', + x('D:propertyupdate', $carddav->NS(), + x('D:remove', + x('D:prop', + x('D:displayname'), + ) + ) + ), + ); + + xlog $self, 'PROPFIND default displayname'; + $res = $carddav->Request( + 'PROPFIND', + 'Default', + x('D:propfind', $carddav->NS(), + x('D:prop', + x('D:displayname'), + ), + ), + 'Content-Type' => 'application/xml', + 'Depth' => '0' + ); + $self->assert_str_equals('Default', $res->{'{DAV:}response'}[0]{ + '{DAV:}propstat'}[0]{'{DAV:}prop'}{'{DAV:}displayname'}{content}); +} diff --git a/cassandane/tiny-tests/Carddav/bulk_import_export b/cassandane/tiny-tests/Carddav/bulk_import_export new file mode 100644 index 0000000000..efee0cf25b --- /dev/null +++ b/cassandane/tiny-tests/Carddav/bulk_import_export @@ -0,0 +1,78 @@ +#!perl +use Cassandane::Tiny; + +sub test_bulk_import_export + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + + my $uid1 = "3b678b69-ca41-461e-b2c7-f96b9fe48d68"; + my $uid2 = "addr1\@example.com"; + my $uid3 = "addr2\@example.com"; + + my $single = < 'text/vcard', + 'Authorization' => $CardDAV->auth_header(), + ); + + xlog $self, "Import a single vCard"; + my $res = $CardDAV->{ua}->request('POST', $CardDAV->request_url($Id), { + content => $single, + headers => \%Headers, + }); + $self->assert_num_equals(207, $res->{status}); + + my $xml = XMLin($res->{content}); + $self->assert_str_equals($uid3, $xml->{'D:response'}{'D:propstat'}{'D:prop'}{'CS:uid'}); + + xlog $self, "Import multiple vCards"; + $res = $CardDAV->{ua}->request('POST', $CardDAV->request_url($Id), { + content => $multiple, + headers => \%Headers, + }); + $self->assert_num_equals(207, $res->{status}); + + $xml = XMLin($res->{content}); + $self->assert_str_equals($uid2, $xml->{'D:response'}[0]{'D:propstat'}{'D:prop'}{'CS:uid'}); + $self->assert_str_equals("urn:uuid:$uid1", $xml->{'D:response'}[1]{'D:propstat'}{'D:prop'}{'CS:uid'}); + + xlog $self, "Export the vCards"; + $res = $CardDAV->{ua}->request('GET', $CardDAV->request_url($Id), { + headers => \%Headers, + }); + $self->assert_num_equals(200, $res->{status}); + $self->assert_matches(qr/UID:$uid3\r\nN:Gump/, $res->{content}); + $self->assert_matches(qr/UID:$uid2\r\nFN:Cyrus Daboo/, $res->{content}); + $self->assert_matches(qr/UID:$uid1\r\nFN:Eric York/, $res->{content}); +} diff --git a/cassandane/tiny-tests/Carddav/carddavcreate b/cassandane/tiny-tests/Carddav/carddavcreate new file mode 100644 index 0000000000..13d2c039e6 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/carddavcreate @@ -0,0 +1,13 @@ +#!perl +use Cassandane::Tiny; + +sub test_carddavcreate + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); +} diff --git a/cassandane/tiny-tests/Carddav/control_chars_repaired b/cassandane/tiny-tests/Carddav/control_chars_repaired new file mode 100644 index 0000000000..8042f25178 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/control_chars_repaired @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_control_chars_repaired + :min_version_3_0 :needs_component_httpd :NoStartInstances +{ + my ($self) = @_; + + # from 3.0-3.2, this behaviour was optional and required the + # carddav_repair_vcard switch to be set + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj == 3 && ($min >= 0 && $min <= 2)) { + $self->{instance}->{config}->set('carddav_repair_vcard' => 'yes'); + } + $self->_start_instances(); + + # :NoStartInstances magic means set_up() didn't do this bit for us + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + $self->{carddav} = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + my $href = "$Id/bar.vcf"; + + my $card = <new_fromstring($card); + my $path = $CardDAV->NewContact($Id, $VCard); + my $res = $CardDAV->GetContact($path); + $self->assert_str_equals($res->{properties}{fn}[0]{value}, 'Forrest Gump'); +} diff --git a/cassandane/tiny-tests/Carddav/control_chars_unrepaired b/cassandane/tiny-tests/Carddav/control_chars_unrepaired new file mode 100644 index 0000000000..257f2ebe2a --- /dev/null +++ b/cassandane/tiny-tests/Carddav/control_chars_unrepaired @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_control_chars_unrepaired + :min_version_3_0 :max_version_3_2 :needs_component_httpd + :NoStartInstances +{ + my ($self) = @_; + + # make sure we don't try to repair by default + $self->{instance}->{config}->set('carddav_repair_vcard' => 'no'); + $self->_start_instances(); + + # :NoStartInstances magic means set_up() didn't do this bit for us + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + $self->{carddav} = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + my $href = "$Id/bar.vcf"; + + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard') }; + my $Err = $@; + $self->assert_matches(qr/valid-address-data/, $Err); +} diff --git a/cassandane/tiny-tests/Carddav/counters b/cassandane/tiny-tests/Carddav/counters new file mode 100644 index 0000000000..0dd63f4502 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/counters @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_counters + :Conversations :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + my $KEY = "/private/vendor/cmu/cyrus-imapd/usercounters"; + my ($maj, $min) = Cassandane::Instance->get_version(); + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + + my $talk = $self->{store}->get_client(); + + my $counters1 = $talk->getmetadata("", $KEY); + $counters1 = $counters1->{''}{$KEY}; + #"3 22 20 16 22 0 20 14 22 0 1571356860") + + + my ($v1, $all1, $mail1, $cal1, $card1, $notes1, $mailfolders1, $calfolders1, $cardfolders1, $notesfolders1, $quota1, $racl1, $valid1, $nothing1) = split / /, $counters1; + + if ($maj < 3 || $maj == 3 && $min == 0) { + # 3.0 and earlier did not have quotamodseq or raclmodseq, but + # uidvalidity was still the last field + $valid1 = $quota1; + $quota1 = undef; + } + + my $VCard = Net::CardDAVTalk::VCard->new_fromstring(<NewContact($Id, $VCard); + + my $counters2 = $talk->getmetadata("", $KEY); + $counters2 = $counters2->{''}{$KEY}; + + my ($v2, $all2, $mail2, $cal2, $card2, $notes2, $mailfolders2, $calfolders2, $cardfolders2, $notesfolders2, $quota2, $racl2, $valid2, $nothing2) = split / /, $counters2; + + if ($maj < 3 || $maj == 3 && $min == 0) { + # 3.0 and earlier did not have quotamodseq or raclmodseq, but + # uidvalidity was still the last field + $valid2 = $quota2; + $quota2 = undef; + } + + $self->assert_num_equals($v1, $v2); + $self->assert_num_not_equals($all1, $all2); + $self->assert_num_equals($mail1, $mail2); + $self->assert_num_equals($cal1, $cal2); + $self->assert_num_not_equals($card1, $card2); + $self->assert_num_equals($notes1, $notes2); + $self->assert_num_equals($mailfolders1, $mailfolders2); + $self->assert_num_equals($calfolders1, $calfolders2); + $self->assert_num_equals($cardfolders1, $cardfolders2); + $self->assert_num_equals($notesfolders1, $notesfolders2); + if ($maj > 3 || $maj == 3 && $min >= 1) { + # quotamodseq and raclmodseq added in 3.1 + $self->assert_num_equals($quota1, $quota2); + $self->assert_num_equals($racl1, $racl2); + } + else { + $self->assert_null($quota2); + $self->assert_null($racl2); + } + $self->assert_num_equals($valid1, $valid2); + $self->assert_null($nothing1); + $self->assert_null($nothing2); +} diff --git a/cassandane/tiny-tests/Carddav/dblookup_email2uids b/cassandane/tiny-tests/Carddav/dblookup_email2uids new file mode 100644 index 0000000000..070cb2605a --- /dev/null +++ b/cassandane/tiny-tests/Carddav/dblookup_email2uids @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_dblookup_email2uids + :needs_component_httpd :min_version_3_9 +{ + + my ($self) = @_; + my $carddav = $self->{carddav}; + + my @testCases = ({ + uid => '05d07827-b63e-4dd8-9c99-eae2a2fde81b', + email => 'test1@local', + }, { + uid => '6badd234-32c0-47ed-8c2e-a036ce5f245e', + email => "randomemail_7246_1_1698767683\@subdomain1-cef511bc-7805-11ee-9a8d-1695e2485a5b.example.com", + }); + + for my $tc (@testCases) { + my $card = <{uid} +FN:Test1 +EMAIL:$tc->{email} +REV:20220217T152253Z +END:VCARD +EOF + + $card =~ s/\r?\n/\r\n/gs; + $carddav->Request('PUT', "Default/$tc->{uid}.vcf", + $card, 'Content-Type' => 'text/vcard'); + + my $httpService = $self->{instance}->get_service("http"); + my $dbLookupUrl = "http://" + . $httpService->host . ":" . $httpService->port + . "/dblookup/email2uids"; + my $httpRes = $carddav->ua->get($dbLookupUrl, { + headers => { + User => 'cassandane', + Key => $tc->{email}, + Mailbox => 'Default', + }, + }); + $self->assert_deep_equals([$tc->{uid}], + decode_json($httpRes->{content})); + } +} diff --git a/cassandane/tiny-tests/Carddav/delete_default_addressbook b/cassandane/tiny-tests/Carddav/delete_default_addressbook new file mode 100644 index 0000000000..9f4173ed45 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/delete_default_addressbook @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_delete_default_addressbook + :min_version_3_6 :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + + my %Headers = ( + 'Authorization' => $CardDAV->auth_header() + ); + + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + + my $href = $CardDAV->request_url($Id); + my $res = $CardDAV->ua->request('DELETE', $href, { headers => \%Headers }); + $self->assert_num_equals(204, $res->{status}); + + $href = $CardDAV->request_url('Default'); + $res = $CardDAV->ua->request('DELETE', $href, { headers => \%Headers }); + $self->assert_num_equals(405, $res->{status}); +} diff --git a/cassandane/tiny-tests/Carddav/empty_filter b/cassandane/tiny-tests/Carddav/empty_filter new file mode 100644 index 0000000000..b6c241b122 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/empty_filter @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_empty_filter + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + + my $xml = < + + + + + + +EOF + + my $Str = <new_fromstring($Str); + + $CardDAV->NewContact($Id, $VCard); + + my $res = $CardDAV->Request('REPORT', "/dav/addressbooks/user/cassandane/$Id", $xml, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_not_null($res->{"{DAV:}response"}); +} diff --git a/cassandane/tiny-tests/Carddav/filter b/cassandane/tiny-tests/Carddav/filter new file mode 100644 index 0000000000..5c9cbd355f --- /dev/null +++ b/cassandane/tiny-tests/Carddav/filter @@ -0,0 +1,142 @@ +#!perl +use Cassandane::Tiny; + +sub test_filter + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + + my $xml1 = < + + + eric + + + +EOF + + my $xml2 = < + + + gump; + + + +EOF + + my $xml3 = < + + + daboo + + + +EOF + + my $homeset = "/dav/addressbooks/user/cassandane"; + my $bookId = "Default"; + + my $uid1 = "3b678b69-ca41-461e-b2c7-f96b9fe48d68"; + my $uid2 = "addr1\@example.com"; + my $uid3 = "addr2\@example.com"; + + my $vcard1 = Net::CardDAVTalk::VCard->new_fromstring(<new_fromstring(<new_fromstring() doesn't split multi-valued properties + my $vcard3 = <NewContact($bookId, $vcard1); + my $href2 = $CardDAV->NewContact($bookId, $vcard2); + + my $href3 = "$bookId/$uid3.vcf"; + eval { $CardDAV->Request('PUT', $href3, $vcard3, 'Content-Type' => 'text/vcard') }; + + # test multi-valued property using CardDAV record + my $res = $CardDAV->Request('REPORT', "$homeset/$bookId", + $xml1, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_str_equals("$homeset/$href3", + $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); + + # test by parsing resource + $xml1 =~ s|||; + + $res = $CardDAV->Request('REPORT', "$homeset/$bookId", + $xml1, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_str_equals("$homeset/$href3", + $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); + + # test structured property using CardDAV record + $res = $CardDAV->Request('REPORT', "$homeset/$bookId", + $xml2, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_str_equals("$homeset/$href1", + $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); + + # test by parsing resource + $xml2 =~ s|||; + + $res = $CardDAV->Request('REPORT', "$homeset/$bookId", + $xml2, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_str_equals("$homeset/$href1", + $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); + + # test string property using CardDAV record + $res = $CardDAV->Request('REPORT', "$homeset/$bookId", + $xml3, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_str_equals("$homeset/$href2", + $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); + + # test by parsing resource + $xml3 =~ s|||; + + $res = $CardDAV->Request('REPORT', "$homeset/$bookId", + $xml3, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_str_equals("$homeset/$href2", + $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); + $self->assert_str_equals("$homeset/$href1", + $res->{"{DAV:}response"}[1]{"{DAV:}href"}{content}); +} diff --git a/cassandane/tiny-tests/Carddav/filter_x_props b/cassandane/tiny-tests/Carddav/filter_x_props new file mode 100644 index 0000000000..638ad72941 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/filter_x_props @@ -0,0 +1,91 @@ +#!perl +use Cassandane::Tiny; + +sub test_filter_x_props + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + + my $uid1 = "addr1\@example.com"; + my $uid2 = "addr2\@example.com"; + my $uid3 = "3b678b69-ca41-461e-b2c7-f96b9fe48d68"; + + my $xml1 = < + + + group + + + +EOF + + my $xml2 = <<"EOF"; + + + + $uid2 + + + +EOF + + my $homeset = "/dav/addressbooks/user/cassandane"; + my $bookId = "Default"; + + my $vcard1 = Net::CardDAVTalk::VCard->new_fromstring(<new_fromstring(<new_fromstring(<NewContact($bookId, $vcard1); + my $href2 = $CardDAV->NewContact($bookId, $vcard2); + my $href3 = $CardDAV->NewContact($bookId, $vcard3); + + my $res = $CardDAV->Request('REPORT', "$homeset/$bookId", + $xml1, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_str_equals("$homeset/$href3", + $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); + + $res = $CardDAV->Request('REPORT', "$homeset/$bookId", + $xml2, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_str_equals("$homeset/$href3", + $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); +} diff --git a/cassandane/tiny-tests/Carddav/homeset_extradomain b/cassandane/tiny-tests/Carddav/homeset_extradomain new file mode 100644 index 0000000000..21269f48b4 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/homeset_extradomain @@ -0,0 +1,21 @@ +#!perl +use Cassandane::Tiny; + +sub test_homeset_extradomain + :ReverseACLs :min_version_3_0 :needs_component_httpd +{ + my ($self) = @_; + + my $service = $self->{instance}->get_service("http"); + my $talk = Net::CardDAVTalk->new( + user => 'cassandane%extradomain.com', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $self->assert_str_equals($talk->{basepath}, "/dav/addressbooks/user/cassandane\@extradomain.com"); +} diff --git a/cassandane/tiny-tests/Carddav/huge_group b/cassandane/tiny-tests/Carddav/huge_group new file mode 100644 index 0000000000..990072b029 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/huge_group @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +# If we handle the large number of properties properly, this test will succeed. +# If we overrun the libical ring buffer, this test might fail, +# but it will definitely cause valgrind errors. +sub test_huge_group + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + + my $members; + + for (1..2500) { + my $ug = Data::UUID->new; + my $uuid = $ug->create_str(); + $members .= "MEMBER:urn:uuid:$_\r\n"; + } + + my $uid = "3b678b69-ca41-461e-b2c7-f96b9fe48d68"; + my $href = "Default/group.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + my $response = $CardDAV->Request('GET', $href); + my $value = $response->{content}; + $self->assert_matches(qr/$uid/, $value); + + $card =~ s/FN:/NOTE:2500 members\r\nFN:/; + + $CardDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + $response = $CardDAV->Request('GET', $href); + $value = $response->{content}; + $self->assert_matches(qr/$uid/, $value); + $self->assert_matches(qr/2500 members/, $value); +} diff --git a/cassandane/tiny-tests/Carddav/many_emails b/cassandane/tiny-tests/Carddav/many_emails new file mode 100644 index 0000000000..74b7e0da4a --- /dev/null +++ b/cassandane/tiny-tests/Carddav/many_emails @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_many_emails + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + + my $Phones = join("\r\n", map { sprintf("TEL;TYPE=HOME:(101) 555-%04d", $_) } (1..1000)); + my $Emails = join("\r\n", map { sprintf("EMAIL;TYPE=INTERNET:user%04d\@example.com", $_) } (1..1000)); + + my $Str = <new_fromstring($Str); + + $CardDAV->NewContact($Id, $VCard); +} diff --git a/cassandane/tiny-tests/Carddav/multiget b/cassandane/tiny-tests/Carddav/multiget new file mode 100644 index 0000000000..e9fe645bec --- /dev/null +++ b/cassandane/tiny-tests/Carddav/multiget @@ -0,0 +1,66 @@ +#!perl +use Cassandane::Tiny; + +sub test_multiget + :needs_component_httpd :min_version_3_7 +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + + my $Str = <new_fromstring($Str); + + my $path = $CardDAV->NewContact($Id, $VCard); + + my $xml = < + + + + + nonsense + /dav/addressbooks/ + /dav/addressbooks/user/ + /dav/addressbooks/user/cassandane/ + /dav/addressbooks/user/cassandane/$Id + /dav/addressbooks/user/cassandane/$path + /dav/addressbooks/user/cassandane/$Id/nonexistent + /dav/addressbooks/user/cassandane/nonexistent + +EOF + + my $res = $CardDAV->Request('REPORT', "/dav/addressbooks/user/cassandane/$Id", $xml, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_not_null($res->{"{DAV:}response"}); + $self->assert_str_equals('nonsense', $res->{"{DAV:}response"}[0]{"{DAV:}href"}{content}); + $self->assert_str_equals('HTTP/1.1 403 Forbidden', $res->{"{DAV:}response"}[0]{"{DAV:}status"}{content}); + $self->assert_str_equals('/dav/addressbooks/', $res->{"{DAV:}response"}[1]{"{DAV:}href"}{content}); + $self->assert_str_equals('HTTP/1.1 403 Forbidden', $res->{"{DAV:}response"}[1]{"{DAV:}status"}{content}); + $self->assert_str_equals('/dav/addressbooks/user/', $res->{"{DAV:}response"}[2]{"{DAV:}href"}{content}); + $self->assert_str_equals('HTTP/1.1 403 Forbidden', $res->{"{DAV:}response"}[2]{"{DAV:}status"}{content}); + $self->assert_str_equals('/dav/addressbooks/user/cassandane/', $res->{"{DAV:}response"}[3]{"{DAV:}href"}{content}); + $self->assert_str_equals('HTTP/1.1 403 Forbidden', $res->{"{DAV:}response"}[3]{"{DAV:}status"}{content}); + $self->assert_str_equals("/dav/addressbooks/user/cassandane/$Id/", $res->{"{DAV:}response"}[4]{"{DAV:}href"}{content}); + $self->assert_str_equals('HTTP/1.1 403 Forbidden', $res->{"{DAV:}response"}[4]{"{DAV:}status"}{content}); + $self->assert_str_equals("/dav/addressbooks/user/cassandane/$path", $res->{"{DAV:}response"}[5]{"{DAV:}href"}{content}); + $self->assert_str_equals('HTTP/1.1 200 OK', $res->{"{DAV:}response"}[5]{"{DAV:}propstat"}[0]{"{DAV:}status"}{content}); + $self->assert_str_equals("/dav/addressbooks/user/cassandane/$Id/nonexistent", $res->{"{DAV:}response"}[6]{"{DAV:}href"}{content}); + $self->assert_str_equals('HTTP/1.1 404 Not Found', $res->{"{DAV:}response"}[6]{"{DAV:}status"}{content}); + $self->assert_str_equals('/dav/addressbooks/user/cassandane/nonexistent/', $res->{"{DAV:}response"}[7]{"{DAV:}href"}{content}); + $self->assert_str_equals('HTTP/1.1 404 Not Found', $res->{"{DAV:}response"}[7]{"{DAV:}status"}{content}); +} diff --git a/cassandane/tiny-tests/Carddav/no_filter b/cassandane/tiny-tests/Carddav/no_filter new file mode 100644 index 0000000000..48da105cff --- /dev/null +++ b/cassandane/tiny-tests/Carddav/no_filter @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_no_filter + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + + my $xml = < + + + + + +EOF + + my $Str = <new_fromstring($Str); + + $CardDAV->NewContact($Id, $VCard); + + my $res = $CardDAV->Request('REPORT', "/dav/addressbooks/user/cassandane/$Id", $xml, Depth => 0, 'Content-Type' => 'text/xml'); + + $self->assert_not_null($res->{"{DAV:}response"}); +} diff --git a/cassandane/tiny-tests/Carddav/put_bday_noyear b/cassandane/tiny-tests/Carddav/put_bday_noyear new file mode 100644 index 0000000000..4ba22bfa83 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/put_bday_noyear @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_bday_noyear + :needs_component_httpd :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + my $href = "$Id/bar.vcf"; + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $card = < 'text/vcard', + 'Authorization' => $CardDAV->auth_header(), + ); + + xlog $self, "PUT vCard v3 with no-year BDAY -- should fail"; + my $Response = $CardDAV->{ua}->request('PUT', $CardDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + $self->assert_num_equals(403, $Response->{status}); + + xlog $self, "PUT vCard v4 with no-year BDAY"; + $card =~ s/3.0/4.0/; + $Response = $CardDAV->{ua}->request('PUT', $CardDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + $self->assert_num_equals(201, $Response->{status}); + + my $res = $CardDAV->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=3.0'); + + $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|BDAY;X-APPLE-OMIT-YEAR=1604:1604(-)?04(-)?15|, + $card); + + xlog $self, "PUT vCard v3 with omit-year BDAY"; + $Response = $CardDAV->{ua}->request('PUT', $CardDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + $self->assert_num_equals(204, $Response->{status}); + + $res = $CardDAV->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|BDAY:--0415|, $card); +} diff --git a/cassandane/tiny-tests/Carddav/put_get_v3_v4 b/cassandane/tiny-tests/Carddav/put_get_v3_v4 new file mode 100644 index 0000000000..9053045568 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/put_get_v3_v4 @@ -0,0 +1,119 @@ +#!perl +use Cassandane::Tiny; + +sub test_put_get_v3_v4 + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + my $href = "$Id/bar.vcf"; + my $uid = "3b678b69-ca41-461e-b2c7-f96b9fe48d68"; + my $photo = "R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + my $logo = "http://bubbagump.com/logo.jpg"; + my $sound = "ABCDEF"; + my $lat = "30.3912"; + my $lon = "-88.8610"; + my $tel = "+1-800-555-1212"; + my $email1 = "shrimp\@bubbagump.com"; + my $email2 = "bubba\@bubbagump.com"; + my $tzid = "America/New_York"; + + my $card = < 'text/vcard', + 'Authorization' => $CardDAV->auth_header(), + ); + + xlog $self, "PUT vCard v3 with text UID"; + my $Response = $CardDAV->{ua}->request('PUT', $CardDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + $self->assert_num_equals(201, $Response->{status}); + + xlog $self, "GET as vCard v4"; + my $response = $CardDAV->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + my $newcard = $response->{content}; + $newcard =~ s/\r?\n[ \t]+//gs; # unfold long properties + $self->assert_matches(qr/UID:urn:uuid:$uid/, $newcard); + $self->assert_matches(qr/PHOTO;TYPE=$video:data:image\/gif;base64,$photo/, + $newcard); + $self->assert_matches(qr/LOGO;MEDIATYPE=image\/jpeg:$logo/, $newcard); + $self->assert_matches(qr/GEO(;VALUE=$uri)?:geo:$lat,$lon/, $newcard); + $self->assert_matches(qr/TEL;TYPE=foo$typesep$work;PREF=1:/, $newcard); + $self->assert_matches(qr/EMAIL;PREF=1:$email1/, $newcard); + $self->assert_matches(qr/EMAIL:$email2/, $newcard); + $self->assert_matches(qr/TZ;VALUE=$utcoff:-05(00)?/, $newcard); + $self->assert_matches(qr/TZ(;VALUE=$text)?:$tzid/, $newcard); + + xlog $self, "PUT same vCard as v4 with some edits"; + $newcard =~ s|END:|SOUND;MEDIATYPE=audio/mp3:data:;base64,$sound\r\nEND:|; + $newcard =~ s/:\+1/;VALUE=URI:tel:+1/; + $newcard =~ s/EMAIL;PREF=1:/EMAIL;PREF=2:/; + $newcard =~ s/:$email2/;PREF=1:$email2/; + $newcard =~ s/-0500/-05/; + $newcard =~ s/TZ;VALUE=TEXT:/TZ:/; + + $Response = $CardDAV->{ua}->request('PUT', $CardDAV->request_url($href), { + content => $newcard, + headers => \%Headers, + }); + $self->assert_num_equals(204, $Response->{status}); + + xlog $self, "GET as vCard v3"; + $tel =~ s/\+/\\+/; # escape the '+' for matching + + $response = $CardDAV->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=3.0'); + $newcard = $response->{content}; + $newcard =~ s/\r?\n[ \t]+//gs; # unfold long properties + $self->assert_matches(qr/UID:$uid/, $newcard); + $self->assert_matches(qr/PHOTO;TYPE=$video(,$gif)?;$binary(;TYPE=$gif)?:$photo/, + $newcard); + $self->assert_matches(qr/LOGO;VALUE=$uri;TYPE=JPEG:$logo/, $newcard); + $self->assert_matches(qr/SOUND(;TYPE=$mp3)?;$binary(;TYPE=$mp3)?:$sound/, + $newcard); + $self->assert_matches(qr/GEO:$lat;$lon/, $newcard); + $self->assert_matches(qr/TEL;TYPE=foo$typesep$work$typesep$pref(;PREF=1)?:$tel/, + $newcard); + $self->assert_matches(qr/EMAIL(;PREF=2)?:$email1/, $newcard); + $self->assert_matches(qr/EMAIL(;PREF=1)?;TYPE=$pref:$email2/, $newcard); + $self->assert_matches(qr/TZ(;VALUE=$utcoff)?:-05(:)?00/, $newcard); + $self->assert_matches(qr/TZ;VALUE=$text:$tzid/, $newcard); +} diff --git a/cassandane/tiny-tests/Carddav/replication b/cassandane/tiny-tests/Carddav/replication new file mode 100644 index 0000000000..4a7d1b82b4 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/replication @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication + :needs_component_httpd :needs_component_replication +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + + my $ABookId = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($ABookId); + + $self->run_replication(); + $self->check_replication('cassandane'); + + my $uid = "3b678b69-ca41-461e-b2c7-f96b9fe48d68"; + my $href = "$ABookId/card.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + my $response = $CardDAV->Request('GET', $href); + my $value = $response->{content}; + $self->assert_matches(qr/$uid/, $value); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $card =~ s/;FOO=bar:Forrest Gump/:/; + + $CardDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $card =~ s/REV:/NICKNAME:Captain\r\nREV:/; + + $CardDAV->Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $CardDAV->DeleteContact($href); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $CardDAV->DeleteAddressBook($ABookId); + + $self->run_replication(); + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/Carddav/sharing_contactpaths b/cassandane/tiny-tests/Carddav/sharing_contactpaths new file mode 100644 index 0000000000..f9ed7c587a --- /dev/null +++ b/cassandane/tiny-tests/Carddav/sharing_contactpaths @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_sharing_contactpaths + :VirtDomains :CrossDomains :FastMailSharing :ReverseACLs :min_version_3_0 + :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user.user1\@example.com"); + $admintalk->setacl("user.user1\@example.com", "user1\@example.com", 'lrswipkxtecdan'); + $admintalk->create("user.user2\@example.org"); + $admintalk->setacl("user.user2\@example.org", "user2\@example.org", 'lrswipkxtecdan'); + + my $service = $self->{instance}->get_service("http"); + my $talk1 = Net::CardDAVTalk->new( + user => 'user1@example.com', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $talk2 = Net::CardDAVTalk->new( + user => 'user2@example.org', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $talk2->NewAddressBook("Shared", name => "Shared Address Book"); + my $VCard = Net::CardDAVTalk::VCard->new_fromstring(<NewContact('Default', $VCard); + $talk2->NewContact('Shared', $VCard); + + $admintalk->setacl("user.user2.#addressbooks.Shared\@example.org", "user1\@example.com", 'lrsn'); + + my $Addressbooks = $talk1->GetAddressBooks(); + + $self->assert_str_equals('Personal', $Addressbooks->[0]{name}); + $self->assert_str_equals('Default', $Addressbooks->[0]{path}); + $self->assert_str_equals('/dav/addressbooks/user/user1@example.com/Default/', $Addressbooks->[0]{href}); + $self->assert_num_equals(0, $Addressbooks->[0]{isReadOnly}); + + $self->assert_str_equals('Shared Address Book', $Addressbooks->[1]{name}); + $self->assert_str_equals('/dav/addressbooks/zzzz/user2@example.org/Shared', $Addressbooks->[1]{path}); + $self->assert_str_equals('/dav/addressbooks/zzzz/user2@example.org/Shared/', $Addressbooks->[1]{href}); + $self->assert_num_equals(1, $Addressbooks->[1]{isReadOnly}); + + # check Default, and Shared: both zzzz and user versions + my @paths = ($Addressbooks->[0]{path}, $Addressbooks->[1]{path}, $Addressbooks->[1]{path}); + $paths[2] =~ s/zzzz/user/; + foreach my $path (@paths) { + my $Events = $talk1->GetContacts($path); + # is a subpath of the contact + $self->assert_matches(qr/^$path/, $Events->[0]{CPath}); + } +} diff --git a/cassandane/tiny-tests/Carddav/sharing_crossdomain b/cassandane/tiny-tests/Carddav/sharing_crossdomain new file mode 100644 index 0000000000..3047ea9369 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/sharing_crossdomain @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_sharing_crossdomain + :VirtDomains :CrossDomains :FastMailSharing :ReverseACLs :min_version_3_0 + :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user.user1\@example.com"); + $admintalk->setacl("user.user1\@example.com", "user1\@example.com", 'lrswipkxtecdan'); + $admintalk->create("user.user2\@example.org"); + $admintalk->setacl("user.user2\@example.org", "user2\@example.org", 'lrswipkxtecdan'); + + my $service = $self->{instance}->get_service("http"); + my $talk1 = Net::CardDAVTalk->new( + user => 'user1@example.com', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $talk2 = Net::CardDAVTalk->new( + user => 'user2@example.org', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $talk2->NewAddressBook("Shared", name => "Shared Address Book"); + $admintalk->setacl("user.user2.#addressbooks.Shared\@example.org", "user1\@example.com", 'lrsn'); + + my $Addressbooks = $talk1->GetAddressBooks(); + + $self->assert_str_equals('Personal', $Addressbooks->[0]{name}); + $self->assert_str_equals('Default', $Addressbooks->[0]{path}); + $self->assert_str_equals('/dav/addressbooks/user/user1@example.com/Default/', $Addressbooks->[0]{href}); + $self->assert_num_equals(0, $Addressbooks->[0]{isReadOnly}); + + $self->assert_str_equals('Shared Address Book', $Addressbooks->[1]{name}); + $self->assert_str_equals('/dav/addressbooks/zzzz/user2@example.org/Shared', $Addressbooks->[1]{path}); + $self->assert_str_equals('/dav/addressbooks/zzzz/user2@example.org/Shared/', $Addressbooks->[1]{href}); + $self->assert_num_equals(1, $Addressbooks->[1]{isReadOnly}); +} diff --git a/cassandane/tiny-tests/Carddav/sharing_rename_sharee b/cassandane/tiny-tests/Carddav/sharing_rename_sharee new file mode 100644 index 0000000000..4e1647c943 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/sharing_rename_sharee @@ -0,0 +1,73 @@ +#!perl +use Cassandane::Tiny; + +sub test_sharing_rename_sharee + :AllowMoves :NoAltNamespace :ReverseACLs :min_version_3_7 + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user.foo"); + $admintalk->setacl("user.foo", "foo", 'lrswipkxtecdan'); + + my $service = $self->{instance}->get_service("http"); + my $cardtalk = Net::CardDAVTalk->new( + user => 'foo', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $CardDAV->NewAddressBook("Shared", name => "Shared Address Book"); + $admintalk->setacl("user.cassandane.#addressbooks.Shared", "foo", 'lrsn'); + + xlog $self, "subscribe to shared calendar"; + my $imapstore = $self->{instance}->get_service('imap')->create_store( + username => "foo"); + my $imaptalk = $imapstore->get_client(); + $imaptalk->subscribe("user.cassandane.#addressbooks.Shared"); + + my $Addressbooks = $cardtalk->GetAddressBooks(); + + $self->assert_str_equals('Personal', $Addressbooks->[0]{name}); + $self->assert_str_equals('Default', $Addressbooks->[0]{path}); + $self->assert_str_equals('/dav/addressbooks/user/foo/Default/', $Addressbooks->[0]{href}); + $self->assert_num_equals(0, $Addressbooks->[0]{isReadOnly}); + + $self->assert_str_equals('Shared Address Book', $Addressbooks->[1]{name}); + $self->assert_str_equals('cassandane.Shared', $Addressbooks->[1]{path}); + $self->assert_str_equals('/dav/addressbooks/user/foo/cassandane.Shared/', $Addressbooks->[1]{href}); + $self->assert_num_equals(1, $Addressbooks->[1]{isReadOnly}); + + $admintalk->rename('user.foo', 'user.bar'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + $cardtalk = Net::CardDAVTalk->new( + user => 'bar', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $Addressbooks = $cardtalk->GetAddressBooks(); + + $self->assert_str_equals('Personal', $Addressbooks->[0]{name}); + $self->assert_str_equals('Default', $Addressbooks->[0]{path}); + $self->assert_str_equals('/dav/addressbooks/user/bar/Default/', $Addressbooks->[0]{href}); + $self->assert_num_equals(0, $Addressbooks->[0]{isReadOnly}); + + $self->assert_str_equals('Shared Address Book', $Addressbooks->[1]{name}); + $self->assert_str_equals('cassandane.Shared', $Addressbooks->[1]{path}); + $self->assert_str_equals('/dav/addressbooks/user/bar/cassandane.Shared/', $Addressbooks->[1]{href}); + $self->assert_num_equals(1, $Addressbooks->[1]{isReadOnly}); +} diff --git a/cassandane/tiny-tests/Carddav/sharing_samedomain b/cassandane/tiny-tests/Carddav/sharing_samedomain new file mode 100644 index 0000000000..f1f1325508 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/sharing_samedomain @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_sharing_samedomain + :VirtDomains :FastMailSharing :ReverseACLs :min_version_3_0 + :needs_component_httpd +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user.user1\@example.com"); + $admintalk->setacl("user.user1\@example.com", "user1\@example.com", 'lrswipkxtecdan'); + $admintalk->create("user.user2\@example.com"); + $admintalk->setacl("user.user2\@example.com", "user2\@example.com", 'lrswipkxtecdan'); + + my $service = $self->{instance}->get_service("http"); + my $talk1 = Net::CardDAVTalk->new( + user => 'user1@example.com', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $talk2 = Net::CardDAVTalk->new( + user => 'user2@example.com', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $talk2->NewAddressBook("Shared", name => "Shared Address Book"); + $admintalk->setacl("user.user2.#addressbooks.Shared\@example.com", "user1\@example.com", 'lrsn'); + + my $Addressbooks = $talk1->GetAddressBooks(); + + $self->assert_str_equals('Personal', $Addressbooks->[0]{name}); + $self->assert_str_equals('Default', $Addressbooks->[0]{path}); + $self->assert_str_equals('/dav/addressbooks/user/user1@example.com/Default/', $Addressbooks->[0]{href}); + $self->assert_num_equals(0, $Addressbooks->[0]{isReadOnly}); + + $self->assert_str_equals('Shared Address Book', $Addressbooks->[1]{name}); + $self->assert_str_equals('/dav/addressbooks/zzzz/user2@example.com/Shared', $Addressbooks->[1]{path}); + $self->assert_str_equals('/dav/addressbooks/zzzz/user2@example.com/Shared/', $Addressbooks->[1]{href}); + $self->assert_num_equals(1, $Addressbooks->[1]{isReadOnly}); +} diff --git a/cassandane/tiny-tests/Carddav/sync_collection b/cassandane/tiny-tests/Carddav/sync_collection new file mode 100644 index 0000000000..809fa988a0 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/sync_collection @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_sync_collection + :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + + my $homeset = "/dav/addressbooks/user/cassandane"; + my $bookId = "Default"; + + my $uid1 = "3b678b69-ca41-461e-b2c7-f96b9fe48d68"; + my $uid2 = "addr1\@example.com"; + my $uid3 = "addr2\@example.com"; + + my $vcard1 = Net::CardDAVTalk::VCard->new_fromstring(<new_fromstring(<new_fromstring(<NewContact($bookId, $vcard1); + my $href2 = $CardDAV->NewContact($bookId, $vcard2); + + my ($adds, $removes, $errors, $syncToken) = + $CardDAV->SyncContactLinks($bookId); + + $self->assert_equals(scalar %$adds, 2); + $self->assert_not_null($adds->{"$homeset/$href1"}); + $self->assert_not_null($adds->{"$homeset/$href2"}); + $self->assert_deep_equals($removes, []); + $self->assert_deep_equals($errors, []); + + $CardDAV->DeleteContact("$homeset/$href1"); + + my $href3 = $CardDAV->NewContact($bookId, $vcard3); + + ($adds, $removes, $errors, $syncToken) = + $CardDAV->SyncContactLinks($bookId, syncToken => $syncToken); + + $self->assert_equals(scalar %$adds, 1); + $self->assert_not_null($adds->{"$homeset/$href3"}); + $self->assert_equals(scalar @$removes, 1); + $self->assert_str_equals("$homeset/$href1", $removes->[0]); + $self->assert_deep_equals($errors, []); + + ($adds, $removes, $errors, $syncToken) = + $CardDAV->SyncContactLinks($bookId, syncToken => $syncToken); + + $self->assert_deep_equals($adds, {}); + $self->assert_deep_equals($removes, []); + $self->assert_deep_equals($errors, []); +} diff --git a/cassandane/tiny-tests/Carddav/too_large b/cassandane/tiny-tests/Carddav/too_large new file mode 100644 index 0000000000..c05ba02dbb --- /dev/null +++ b/cassandane/tiny-tests/Carddav/too_large @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_too_large + :needs_component_httpd :min_version_3_5 +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + my $href = "$Id/bar.vcf"; + + my $notes = ('x') x 100000; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard') }; + my $Err = $@; + $self->assert_matches(qr/max-resource-size/, $Err); +} diff --git a/cassandane/tiny-tests/Carddav/tspecial_resource_name b/cassandane/tiny-tests/Carddav/tspecial_resource_name new file mode 100644 index 0000000000..73ddeae4f9 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/tspecial_resource_name @@ -0,0 +1,26 @@ +#!perl +use Cassandane::Tiny; + +sub test_tspecial_resource_name + :needs_component_httpd +{ + my ($self) = @_; + + my $carddav = $self->{carddav}; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + my $res = $carddav->Request('GET', $href); + $self->assert_matches(qr/\r\nUID:123456789\r\n/, $res->{content}); +} diff --git a/cassandane/tiny-tests/Carddav/version_ignore_whitespace b/cassandane/tiny-tests/Carddav/version_ignore_whitespace new file mode 100644 index 0000000000..f19e6701f0 --- /dev/null +++ b/cassandane/tiny-tests/Carddav/version_ignore_whitespace @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_version_ignore_whitespace + :min_version_3_3 :needs_component_httpd +{ + my ($self) = @_; + + my $CardDAV = $self->{carddav}; + my $Id = $CardDAV->NewAddressBook('foo'); + $self->assert_not_null($Id); + $self->assert_str_equals($Id, 'foo'); + my $href = "$Id/bar.vcf"; + + my $card = <new_fromstring($card); + my $path = $CardDAV->NewContact($Id, $VCard); + my $res = $CardDAV->GetContact($path); + $self->assert_str_equals($res->{properties}{version}[0]{value}, '3.0'); +} diff --git a/cassandane/tiny-tests/FastMail/ajaxui_jmapcontacts_contactgroup_set b/cassandane/tiny-tests/FastMail/ajaxui_jmapcontacts_contactgroup_set new file mode 100644 index 0000000000..c4d200d6ff --- /dev/null +++ b/cassandane/tiny-tests/FastMail/ajaxui_jmapcontacts_contactgroup_set @@ -0,0 +1,97 @@ +#!perl +use Cassandane::Tiny; + +sub test_ajaxui_jmapcontacts_contactgroup_set + :min_version_3_1 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $service = $self->{instance}->get_service("http"); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user.masteruser"); + $admintalk->setacl("user.masteruser", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.masteruser", masteruser => 'lrswipkxtecdn'); + $admintalk->create("user.masteruser.#addressbooks.Default", ['TYPE', 'ADDRESSBOOK']); + $admintalk->create("user.masteruser.#addressbooks.Shared", ['TYPE', 'ADDRESSBOOK']); + $admintalk->setacl("user.masteruser.#addressbooks.Default", "masteruser" => 'lrswipkxtecdn') or die; + $admintalk->setacl("user.masteruser.#addressbooks.Shared", "masteruser" => 'lrswipkxtecdn') or die; + $admintalk->setacl("user.masteruser.#addressbooks.Shared", "cassandane" => 'lrswipkxtecdn') or die; + + my $mastertalk = Net::CardDAVTalk->new( + user => "masteruser", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $res; + + xlog $self, "create contact group"; + $res = $self->_fmjmap_ok('ContactGroup/set', + accountId => 'cassandane', + create => { + "k2519" => { + name => "personal group", + addressbookId => 'Default', + contactIds => [], + otherAccountContactIds => { + masteruser => [], + }, + }, + }, + update => {}, + destroy => [], + ); + my $groupid = $res->{created}{"k2519"}{id}; + $self->assert_not_null($groupid); + + $res = $self->_fmjmap_ok('ContactGroup/get', + ids => [$groupid], + ); + + $self->assert_num_equals(1, scalar @{$res->{list}}); + # check the rest? + + xlog $self, "create contact group"; + $res = $self->_fmjmap_ok('ContactGroup/set', + accountId => 'masteruser', + create => { + "k2520" => { + name => "shared group", + addressbookId => 'Shared', + contactIds => [], + otherAccountContactIds => {}, + }, + }, + update => {}, + destroy => [], + ); + my $sgroupid = $res->{created}{"k2520"}{id}; + $self->assert_not_null($sgroupid); + + xlog $self, "create invalid shared contact group"; + $res = $self->_fmjmap_ok('ContactGroup/set', + accountId => 'masteruser', + create => { + "k2521" => { + name => "invalid group", + addressbookId => 'Default', + contactIds => [], + otherAccountContactIds => {}, + }, + }, + update => {}, + destroy => [], + ); + + $self->assert_not_null($res->{notCreated}{"k2521"}); + $self->assert_null($res->{created}{"k2521"}); + + # now let's create a contact and put it in the event... +} diff --git a/cassandane/tiny-tests/FastMail/carddav_rewrite_v4card_on_get b/cassandane/tiny-tests/FastMail/carddav_rewrite_v4card_on_get new file mode 100644 index 0000000000..e182eea938 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/carddav_rewrite_v4card_on_get @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +sub test_carddav_rewrite_v4card_on_get + :needs_component_httpd :needs_dependency_icalvcard +{ + my ($self) = @_; + my $CardDAV = $self->{carddav}; + + my $Id = $CardDAV->NewAddressBook('foo'); + my $href = "$Id/test.vcf"; + my $uid = "3b678b69-ca41-461e-b2c7-f96b9fe48d68"; + my $image = "R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + + # The UID and PHOTO property values of this version 4.0 + # vCard are bogus. The UID property value should either + # be a valid URI or it should have the VALUE=TEXT + # parameter set. The PHOTO property value must be a URI + # but instead it uses version 3.0 encoding for embedding + # data. + # + # This test asserts that we accept such data but rewrite on GET. + # + # The alternatives are: + # 1. Reject on PUT. This is problematic, as we might have + # been the ones writing that bogus data in the first place: + # https://github.com/cyrusimap/cyrus-imapd/commit/b8a879ccf22d52d336662d506d3a14ddf341b60b + # + # 2. Rewrite on PUT. This looks to be the preferrable + # solution in the long run, but it will require us to + # check how clients deal with us rewriting the UID value + # on PUT. + + my $card = < 'text/vcard; version=4.0', + 'Authorization' => $CardDAV->auth_header(), + ); + + xlog $self, "PUT vCard v4 with v3 values"; + my $Response = $CardDAV->{ua}->request('PUT', $CardDAV->request_url($href), { + content => $card, + headers => \%Headers, + }); + $self->assert_num_equals(201, $Response->{status}); + $self->assert_not_null($Response->{headers}{etag}); + + xlog $self, "GET as vCard v4"; + my $response = $CardDAV->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + my $newcard = $response->{content}; + $newcard =~ s/\r?\n[ \t]+//gs; # unfold long properties + $self->assert_matches(qr/PHOTO:data:image\/gif;base64,$image/, $newcard); + + xlog $self, "GET as vCard v3"; + $response = $CardDAV->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=3.0'); + $newcard = $response->{content}; + $newcard =~ s/\r?\n[ \t]+//gs; # unfold long properties + $self->assert_matches(qr/PHOTO;ENCODING=[bB];TYPE=GIF:$image/, $newcard); + + xlog $self, "GET without explicit version in Accept header"; + $response = $CardDAV->Request('GET', $href, '', + 'Accept' => 'text/vcard'); + $newcard = $response->{content}; + $newcard =~ s/\r?\n[ \t]+//gs; # unfold long properties + $self->assert_matches(qr/VERSION:3.0/, $newcard); + $self->assert_matches(qr/PHOTO;ENCODING=[bB];TYPE=GIF:$image/, $newcard); + + xlog $self, "GET without Accept header"; + $response = $CardDAV->Request('GET', $href, ''); + $newcard = $response->{content}; + $newcard =~ s/\r?\n[ \t]+//gs; # unfold long properties + $self->assert_matches(qr/VERSION:3.0/, $newcard); + $self->assert_matches(qr/PHOTO;ENCODING=[bB];TYPE=GIF:$image/, $newcard); +} diff --git a/cassandane/tiny-tests/FastMail/create_inherit_color b/cassandane/tiny-tests/FastMail/create_inherit_color new file mode 100644 index 0000000000..767e3b30ad --- /dev/null +++ b/cassandane/tiny-tests/FastMail/create_inherit_color @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_create_inherit_color + :min_version_3_9 :AltNameSpace :needs_component_jmap +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "Create mailbox with color"; + my $res = $self->_fmjmap_ok('Mailbox/set', + accountId => 'cassandane', + create => { + 1 => { + parentId => JSON::null, + name => 'foo', + color => "coral", + }, + }, + update => {}, + destroy => [], + ); + $self->assert_not_null($res->{created}{1}); + + my $folder = "foo.bar"; + my $entry = "/shared/vendor/cmu/cyrus-imapd/color"; + my $color = "coral"; + + xlog $self, "Create child mailbox"; + $imaptalk->create($folder); + + xlog $self, "Check the child has the same color"; + $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $entry => $color } + }, $res); +} diff --git a/cassandane/tiny-tests/FastMail/cyr_expire_delete_findpaths_legacy b/cassandane/tiny-tests/FastMail/cyr_expire_delete_findpaths_legacy new file mode 100644 index 0000000000..16bee0406a --- /dev/null +++ b/cassandane/tiny-tests/FastMail/cyr_expire_delete_findpaths_legacy @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_cyr_expire_delete_findpaths_legacy + :DelayedDelete :min_version_3_5 :MailboxLegacyDirs + :needs_component_httpd +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser"; + my $subfolder = "$inbox.foo"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "Delete $subfolder"; + $admintalk->unselect(); + $admintalk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + xlog $self, "Ensure we can't select $subfolder anymore"; + $admintalk->select($subfolder); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $admintalk->get_last_error()); + + my ($datapath) = $self->{instance}->folder_to_deleted_directories($subfolder); + $self->assert_not_null($datapath); + + xlog $self, "Run cyr_expire -D now."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0' ); + + # the folder should not exist now! + $self->assert_not_file_test($datapath, "-d"); + + # Delete the entire user! + $admintalk->delete($inbox); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/user/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "DELETED files exists"; + $self->assert(scalar grep { m{/DELETED/} } @files); + xlog $self, "no non-deleted paths"; + $self->assert(not scalar grep { not m{/DELETED/} } @files); + + xlog $self, "Run cyr_expire -D now."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0' ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/user/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no DELETED files exists"; + $self->assert(not scalar grep { m{/DELETED/} } @files); + xlog $self, "no non-deleted paths"; + $self->assert(not scalar grep { not m{/DELETED/} } @files); +} diff --git a/cassandane/tiny-tests/FastMail/cyr_expire_delete_findpaths_nolegacy b/cassandane/tiny-tests/FastMail/cyr_expire_delete_findpaths_nolegacy new file mode 100644 index 0000000000..1b6424934b --- /dev/null +++ b/cassandane/tiny-tests/FastMail/cyr_expire_delete_findpaths_nolegacy @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_cyr_expire_delete_findpaths_nolegacy + :DelayedDelete :min_version_3_5 :NoMailboxLegacyDirs + :needs_component_httpd +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser"; + my $subfolder = "$inbox.foo"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $admintalk->status($inbox, ['mailboxid']); + my $inboxid = $res->{mailboxid}[0]; + $res = $admintalk->status($subfolder, ['mailboxid']); + my $subid = $res->{mailboxid}[0]; + + xlog $self, "Delete $subfolder"; + $admintalk->unselect(); + $admintalk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + xlog $self, "Ensure we can't select $subfolder anymore"; + $admintalk->select($subfolder); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $admintalk->get_last_error()); + + my ($datapath) = $self->{instance}->folder_to_deleted_directories($subfolder); + $self->assert_not_null($datapath); + + xlog $self, "Run cyr_expire -D now."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0' ); + + # the folder should not exist now! + $self->assert_not_file_test($datapath, "-d"); + + # Delete the entire user! + $admintalk->delete($inbox); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/uuid/} } ; + close(FH); + + xlog $self, "files for the inbox still exist"; + $self->assert(scalar grep { m{$inboxid} } @files); + xlog $self, "no files left for subfolder"; + $self->assert(not scalar grep { m{$subid} } @files); + + xlog $self, "Run cyr_expire -D now."; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0' ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/uuid/} } ; + close(FH); + + use Data::Dumper; + xlog $self, "no files for the inbox still exist" . Dumper(\@files, $inboxid);; + $self->assert(not scalar grep { m{$inboxid} } @files); +} diff --git a/cassandane/tiny-tests/FastMail/imap_list_notes b/cassandane/tiny-tests/FastMail/imap_list_notes new file mode 100644 index 0000000000..19dc69b69a --- /dev/null +++ b/cassandane/tiny-tests/FastMail/imap_list_notes @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_imap_list_notes + :min_version_3_1 :needs_component_sieve + :needs_component_httpd +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.Foo") || die; + $imaptalk->create("INBOX.Foo.Hi") || die; + $imaptalk->create("INBOX.A") || die; + $imaptalk->create("INBOX.Junk", "(USE (\\Junk))"); + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))"); + $imaptalk->create("INBOX.Important", "(USE (\\Important))"); + $imaptalk->create("INBOX.Notes", "(USE (\\XNotes))"); + + my $data = $imaptalk->list('', '*'); + $self->assert_deep_equals([ + [ + [ + '\\HasChildren', + ], + '.', + 'INBOX', + ], + [ + [ + '\\HasNoChildren', + ], + '.', + 'INBOX.A', + ], + [ + [ + '\\HasChildren', + ], + '.', + 'INBOX.Foo', + ], + [ + [ + '\\HasNoChildren', + ], + '.', + 'INBOX.Foo.Hi', + ], + [ + [ + '\\HasNoChildren', + '\\Important', + ], + '.', + 'INBOX.Important', + ], + [ + [ + '\\HasNoChildren', + '\\Junk', + ], + '.', + 'INBOX.Junk', + ], + [ + [ + '\\HasNoChildren', + '\\XNotes', + ], + '.', + 'INBOX.Notes', + ], + [ + [ + '\\HasNoChildren', + '\\Trash', + ], + '.', + 'INBOX.Trash', + ], +], $data); + +} diff --git a/cassandane/tiny-tests/FastMail/issue_LP52545479 b/cassandane/tiny-tests/FastMail/issue_LP52545479 new file mode 100644 index 0000000000..bb0d026ae2 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/issue_LP52545479 @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +sub test_issue_LP52545479 + :min_version_3_1 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendar1 => { + name => 'calendar1', + color => 'coral', + sortOrder => 1, + isVisible => JSON::true, + } + }, + }, 'R1'], + ], [ $self->default_using ]); + my $calendarId = $res->[0][1]{created}{calendar1}{id}; + $self->assert_not_null($calendarId); + + $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + firstName => "firstName", + lastName => "lastName", + notes => "x" x 1024 + } + } + }, 'R1'], + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + $calendarId => JSON::true, + }, + uid => '58ADE31-custom-UID', + title => 'event1', + start => '2015-11-07T09:00:00', + duration => 'PT5M', + sequence => 42, + timeZone => 'Etc/UTC', + showWithoutTime => JSON::false, + locale => 'en', + description => 'x' x 1024, + freeBusyStatus => 'busy', + privacy => 'secret', + participants => undef, + alerts => undef, + } + }, + }, 'R2'], + ], [ $self->default_using ]); + my $contactId1 = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($contactId1); + my $eventId1 = $res->[1][1]{created}{event1}{id}; + $self->assert_not_null($eventId1); + + my $res_annot_storage = 'ANNOTATION-STORAGE'; + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj < 3 || ($maj == 3 && $min < 9)) { + $res_annot_storage = 'X-ANNOTATION-STORAGE'; + } + + $self->_set_quotaroot('user.cassandane'); + $self->_set_quotalimits(storage => 1, + $res_annot_storage => 1); # that's 1024 bytes + + $res = $jmap->CallMethods([ + ['Contact/set', { + update => { + $contactId1 => { + lastName => "updatedLastName", + } + } + }, 'R1'], + ['CalendarEvent/set', { + update => { + $eventId1 => { + description => "y" x 2048, + } + } + }, 'R2'], + ], [ $self->default_using ]); + $self->assert_str_equals('overQuota', $res->[0][1]{notUpdated}{$contactId1}{type}); + $self->assert(not exists $res->[0][1]{updated}{$contactId1}); + $self->assert_str_equals('overQuota', $res->[1][1]{notUpdated}{$eventId1}{type}); + $self->assert(not exists $res->[1][1]{updated}{$eventId1}); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_case_difference b/cassandane/tiny-tests/FastMail/mailbox_case_difference new file mode 100644 index 0000000000..b662d4ce30 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_case_difference @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_case_difference + :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # we need the mail extensions for isSeenShared + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.Foo.Hi") || die; + $imaptalk->create("INBOX.A") || die; + + xlog $self, "fetch mailboxes"; + my $res = $jmap->Call('Mailbox/get', {}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->{list}}; + + xlog $self, "move INBOX.A to INBOX.Foo.B"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{A} => { + name => "Hi", + parentId => $mboxids{Foo}, + }, + $mboxids{Hi} => { + name => "HI", + } + } + }); + + $self->assert_null($res->{notUpdated}); + $self->assert(exists $res->{updated}{$mboxids{A}}); + $self->assert(exists $res->{updated}{$mboxids{Hi}}); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_query b/cassandane/tiny-tests/FastMail/mailbox_query new file mode 100644 index 0000000000..ddb8a63567 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_query @@ -0,0 +1,73 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + 1 => { name => 'Sent', role => 'sent' }, + 2 => { name => 'Trash', role => 'junk' }, + 3 => { name => 'Foo' }, + 4 => { name => 'Bar', sortOrder => 30 }, + 5 => { name => 'Early', sortOrder => 2 }, + 6 => { name => 'Child', parentId => '#5' }, + 7 => { name => 'EarlyChild', parentId => '#5', sortOrder => 0 }, + }, + }, 'a'], + ['Mailbox/query', { + sortAsTree => $JSON::true, + sort => [{property => 'sortOrder'}, {property => 'name'}], + filterAsTree => $JSON::true, + filter => { + operator => 'OR', + conditions => [{role => 'inbox'}, {hasAnyRole => $JSON::false}], + }, + }, 'b'], + ['Mailbox/get', { + '#ids' => { + resultOf => 'b', + name => 'Mailbox/query', + path => '/ids', + }, + }, 'c'], + ]); + + # default sort orders should have been set for Sent, Trash and Foo: + + $self->assert_num_equals(5, $res->[0][1]{created}{1}{sortOrder}); + $self->assert_num_equals(6, $res->[0][1]{created}{2}{sortOrder}); + $self->assert_num_equals(10, $res->[0][1]{created}{3}{sortOrder}); + $self->assert_num_equals(10, $res->[0][1]{created}{6}{sortOrder}); + + # sortOrder shouldn't be returned where it's been set explicitly + $self->assert_null($res->[0][1]{created}{4}{sortOrder}); + $self->assert_null($res->[0][1]{created}{5}{sortOrder}); + $self->assert_null($res->[0][1]{created}{7}{sortOrder}); + + my %mailboxes = map { $_->{id} => $_ } @{$res->[2][1]{list}}; + + my $list = $res->[1][1]{ids}; + + # expected values for name and sortOrder + my @expected = ( + ['Inbox', 1], + ['Early', 2], + ['EarlyChild', 0], + ['Child', 10], + ['Foo', 10], + ['Bar', 30], + ); + $self->assert_num_equals(scalar @expected, scalar @$list); + + for (0..$#expected) { + $self->assert_str_equals($expected[$_][0], $mailboxes{$list->[$_]}{name}); + $self->assert_num_equals($expected[$_][1], $mailboxes{$list->[$_]}{sortOrder}); + } +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_inside_deep b/cassandane/tiny-tests/FastMail/mailbox_rename_inside_deep new file mode 100644 index 0000000000..a82794ce20 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_inside_deep @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_inside_deep + :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # we need the mail extensions for isSeenShared + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.A") || die; + $imaptalk->create("INBOX.A.B") || die; + $imaptalk->create("INBOX.A.B.C") || die; + + xlog $self, "fetch mailboxes"; + my $res = $jmap->Call('Mailbox/get', {}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->{list}}; + + xlog $self, "move INBOX.A to be a child of INBOX.A.B.C"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{A} => { + parentId => $mboxids{C}, + } + } + }); + + # rejected due to being a child + $self->assert_str_equals("parentId", $res->{notUpdated}{$mboxids{A}}{properties}[0]); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_long_chain b/cassandane/tiny-tests/FastMail/mailbox_rename_long_chain new file mode 100644 index 0000000000..a8b14e4eb2 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_long_chain @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_long_chain + :AllowMoves :Replication :SyncLog :min_version_3_3 + :needs_component_replication +{ + my ($self) = @_; + + my $mtalk = $self->{master_store}->get_client(); + + $mtalk->create('INBOX.Foo'); + $mtalk->create('INBOX.Bar'); + + # replicate and check initial state + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + $self->check_replication('cassandane'); + + # perform the multi-path rename + $mtalk = $self->{master_store}->get_client(); + $mtalk->rename('INBOX.Bar', 'Inbox.Old'); + $mtalk->rename('INBOX.Foo', 'Inbox.Foo2'); + $mtalk->rename('INBOX.Foo2', 'Inbox.Foo3'); + $mtalk->rename('INBOX.Foo3', 'Inbox.Foo4'); + $mtalk->rename('INBOX.Foo4', 'Inbox.Foo5'); + $mtalk->rename('INBOX.Foo5', 'Inbox.Foo6'); + $mtalk->rename('INBOX.Foo6', 'Inbox.Foo7'); + $mtalk->rename('INBOX.Foo7', 'Inbox.Foo8'); + $mtalk->rename('INBOX.Foo8', 'Inbox.Bar'); + # Create a couple of intermediates again + $mtalk->create('INBOX.Foo5'); + $mtalk->create('INBOX.Foo2'); + + # replicate and check that it syncs ok + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_sub_inbox_both b/cassandane/tiny-tests/FastMail/mailbox_rename_sub_inbox_both new file mode 100644 index 0000000000..86ee087f8f --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_sub_inbox_both @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_sub_inbox_both + :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # we need the mail extensions for isSeenShared + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.INBOX.Child") || die; + $imaptalk->create("INBOX.Example.INBOX") || die; + $imaptalk->create("INBOX.Example.Other") || die; + $imaptalk->create("INBOX.Top") || die; + + xlog $self, "fetch mailboxes"; + my $res = $jmap->Call('Mailbox/get', {}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->{list}}; + + xlog $self, "move Example.INBOX to top level and rename at same time"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{INBOX} => { + parentId => undef, + name => "INBOX1", + } + } + }); + $self->assert(exists $res->{updated}{$mboxids{INBOX}}); + $self->assert_null($res->{notUpdated}); + + # make sure we didn't create the deep tree! + $self->assert_syslog_does_not_match($self->{instance}, + qr/INBOX\.INBOX\.INBOX/); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_switch_ab b/cassandane/tiny-tests/FastMail/mailbox_rename_switch_ab new file mode 100644 index 0000000000..1df0c98a7a --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_switch_ab @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_switch_ab + :AllowMoves :Replication :SyncLog :min_version_3_3 + :needs_component_replication +{ + my ($self) = @_; + + my $mtalk = $self->{master_store}->get_client(); + + $mtalk->create('INBOX.Foo'); + $mtalk->create('INBOX.Bar'); + + # replicate and check initial state + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + $self->check_replication('cassandane'); + + # perform the three way rename + $mtalk = $self->{master_store}->get_client(); + $mtalk->rename('INBOX.Foo', 'Inbox.Foo2'); + $mtalk->rename('INBOX.Bar', 'Inbox.Foo'); + $mtalk->rename('INBOX.Foo2', 'Inbox.Bar'); + + # replicate and check that it syncs ok + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_both b/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_both new file mode 100644 index 0000000000..be85deb744 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_both @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_to_clash_both + :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # we need the mail extensions for isSeenShared + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.Foo") || die; + $imaptalk->create("INBOX.Foo.A") || die; + $imaptalk->create("INBOX.Bar") || die; + $imaptalk->create("INBOX.Bar.B") || die; + + xlog $self, "fetch mailboxes"; + my $res = $jmap->Call('Mailbox/get', {}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->{list}}; + + xlog $self, "move INBOX.Foo.A to INBOX.Bar.B"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{A} => { + parentId => $mboxids{Bar}, + name => "B", + } + } + }); + + # rejected due to name existing + $self->assert_str_equals("name", $res->{notUpdated}{$mboxids{A}}{properties}[0]); + + $res = $jmap->Call('Mailbox/get', {}); + my %mboxids2 = map { $_->{name} => $_->{id} } @{$res->{list}}; + $self->assert_deep_equals(\%mboxids, \%mboxids2); + + # there were no renames + $self->assert_syslog_does_not_match($self->{instance}, + qr/auditlog: rename/); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_name_only b/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_name_only new file mode 100644 index 0000000000..cfe98a29cb --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_name_only @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_to_clash_name_only + :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # we need the mail extensions for isSeenShared + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.A") || die; + $imaptalk->create("INBOX.B") || die; + + xlog $self, "fetch mailboxes"; + my $res = $jmap->Call('Mailbox/get', {}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->{list}}; + + xlog $self, "move INBOX.A to INBOX.B"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{A} => { + name => "B", + } + } + }); + + # rejected due to name existing + $self->assert_null($res->{updated}); + $self->assert_not_null($res->{notUpdated}{$mboxids{A}}); + + $res = $jmap->Call('Mailbox/get', {}); + my %mboxids2 = map { $_->{name} => $_->{id} } @{$res->{list}}; + $self->assert_deep_equals(\%mboxids, \%mboxids2); + + # there were no renames + $self->assert_syslog_does_not_match($self->{instance}, + qr/auditlog: rename/); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_name_only_deep b/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_name_only_deep new file mode 100644 index 0000000000..4cf5c56af9 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_name_only_deep @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_to_clash_name_only_deep + :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # we need the mail extensions for isSeenShared + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.A") || die; + $imaptalk->create("INBOX.A.B") || die; + $imaptalk->create("INBOX.C") || die; + + xlog $self, "fetch mailboxes"; + my $res = $jmap->Call('Mailbox/get', {}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->{list}}; + + $imaptalk->create("INBOX.C.B") || die; + + xlog $self, "move INBOX.A.B to INBOX.C.B"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{B} => { + parentId => $mboxids{C}, + } + } + }); + + # rejected due to name existing + $self->assert_null($res->{updated}); + $self->assert_not_null($res->{notUpdated}{$mboxids{B}}); + + # there were no renames + $self->assert_syslog_does_not_match($self->{instance}, + qr/auditlog: rename/); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_parent_only b/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_parent_only new file mode 100644 index 0000000000..26eb9a3781 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_to_clash_parent_only @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_to_clash_parent_only + :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # we need the mail extensions for isSeenShared + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.A") || die; + $imaptalk->create("INBOX.A.B") || die; + + xlog $self, "fetch mailboxes"; + my $res = $jmap->Call('Mailbox/get', {}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->{list}}; + + $imaptalk->create("INBOX.B") || die; + + xlog $self, "move INBOX.A.B to be a child of INBOX"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{B} => { + parentId => undef, + } + } + }); + + # rejected due to being a child + $self->assert_null($res->{updated}); + $self->assert_not_null($res->{notUpdated}{$mboxids{B}}); + + # there were no renames + $self->assert_syslog_does_not_match($self->{instance}, + qr/auditlog: rename/); +} diff --git a/cassandane/tiny-tests/FastMail/mailbox_rename_to_inbox_sub b/cassandane/tiny-tests/FastMail/mailbox_rename_to_inbox_sub new file mode 100644 index 0000000000..2900541320 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/mailbox_rename_to_inbox_sub @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_rename_to_inbox_sub + :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # we need the mail extensions for isSeenShared + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.INBOX.Child") || die; + $imaptalk->create("INBOX.Example.INBOX") || die; + $imaptalk->create("INBOX.Example.Other") || die; + $imaptalk->create("INBOX.Top") || die; + + xlog $self, "fetch mailboxes"; + my $res = $jmap->Call('Mailbox/get', {}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->{list}}; + + xlog $self, "fail move Example.INBOX to top level"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{INBOX} => { + parentId => undef, + } + } + }); + $self->assert_null($res->{updated}); + $self->assert_str_equals("parentId", $res->{notUpdated}{$mboxids{INBOX}}{properties}[0]); + + xlog $self, "fail move Top to inbox"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{Top} => { + name => 'inbox', + } + } + }); + $self->assert_null($res->{updated}); + $self->assert_str_equals("name", $res->{notUpdated}{$mboxids{Top}}{properties}[0]); + + xlog $self, "fail move Example.Other to InBox"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{Other} => { + name => "InBox", + parentId => undef, + } + } + }); + $self->assert_null($res->{updated}); + $self->assert_str_equals("name", $res->{notUpdated}{$mboxids{Other}}{properties}[0]); + + # no updates YET! + $res = $jmap->Call('Mailbox/get', {}); + my %mboxids2 = map { $_->{name} => $_->{id} } @{$res->{list}}; + $self->assert_deep_equals(\%mboxids, \%mboxids2); + + xlog $self, "Move Example.INBOX again to sub of Inbox (allowed)"; + $res = $jmap->Call('Mailbox/set', { + update => { + $mboxids{INBOX} => { + parentId => $mboxids{Inbox}, + isSeenShared => $JSON::true, + } + } + }); + # this will have content which is NULL, but it should exist + $self->assert(exists $res->{updated}{$mboxids{INBOX}}); + $self->assert_null($res->{notUpdated}); + + # make sure we didn't create the deep tree! + $self->assert_syslog_does_not_match($self->{instance}, + qr/INBOX\.INBOX\.INBOX/); +} diff --git a/cassandane/tiny-tests/FastMail/relocate_legacy_domain b/cassandane/tiny-tests/FastMail/relocate_legacy_domain new file mode 100644 index 0000000000..55225bd64e --- /dev/null +++ b/cassandane/tiny-tests/FastMail/relocate_legacy_domain @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_relocate_legacy_domain + :DelayedDelete :min_version_3_5 :MailboxLegacyDirs + :needs_component_httpd +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser\@example.com"; + my $subfolder = "user.magicuser.foo\@example.com"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, 'relocate_by_id', '-u' => "magicuser\@example.com" ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); +} diff --git a/cassandane/tiny-tests/FastMail/relocate_legacy_nodomain b/cassandane/tiny-tests/FastMail/relocate_legacy_nodomain new file mode 100644 index 0000000000..25e0590c93 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/relocate_legacy_nodomain @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_relocate_legacy_nodomain + :DelayedDelete :min_version_3_5 :MailboxLegacyDirs + :needs_component_httpd +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser"; + my $subfolder = "user.magicuser.foo"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, 'relocate_by_id', '-u' => "magicuser" ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); +} diff --git a/cassandane/tiny-tests/FastMail/relocate_legacy_nosearchdb b/cassandane/tiny-tests/FastMail/relocate_legacy_nosearchdb new file mode 100644 index 0000000000..5672f9a2d0 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/relocate_legacy_nosearchdb @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_relocate_legacy_nosearchdb + :DelayedDelete :min_version_3_5 :MailboxLegacyDirs + :needs_component_httpd +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser\@example.com"; + my $subfolder = "user.magicuser.foo\@example.com"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Don't create the search database! + # A user who's never been indexed should still relocate cleanly + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, 'relocate_by_id', '-u' => "magicuser\@example.com" ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); + + # Hopefully squatter still works! + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); +} diff --git a/cassandane/tiny-tests/FastMail/relocate_messages_still_exist b/cassandane/tiny-tests/FastMail/relocate_messages_still_exist new file mode 100644 index 0000000000..7872684edb --- /dev/null +++ b/cassandane/tiny-tests/FastMail/relocate_messages_still_exist @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +sub test_relocate_messages_still_exist + :DelayedDelete :min_version_3_5 :MailboxLegacyDirs + :needs_component_httpd +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $username = "magicuser\@example.com"; + + $admintalk->create("user.$username"); + $admintalk->setacl("user.$username", admin => 'lrswipkxtecdan'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + xlog $self, "Connect as the new user"; + my $svc = $self->{instance}->get_service('imap'); + $self->{store} = $svc->create_store(username => $username, folder => 'INBOX'); + $self->{store}->set_fetch_attributes('uid'); + my $imaptalk = $self->{store}->get_client(); + + $self->make_message("Email 1") or die; + $self->make_message("Email 2") or die; + $self->make_message("Email xyzzy") or die; + + $imaptalk->create("INBOX.subfolder"); + $imaptalk->create("INBOX.subfolder2"); + + $self->{store}->set_folder("INBOX.subfolder"); + $self->make_message("Email xyzzy") or die; + + $imaptalk->list('', '*', 'return', [ "status", [ "messages", "uidvalidity", "highestmodseq", "mailboxid" ] ]); + my $prestatus = $imaptalk->get_response_code('status'); + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, 'relocate_by_id', '-u' => $username ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); + + $imaptalk = $self->{store}->get_client(); + + $imaptalk->select("INBOX"); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + my $exists = $imaptalk->get_response_code('exists'); + $self->assert_num_equals(3, $exists); + my $msgs = $imaptalk->search("fuzzy", ["subject", { Quote => "xyzzy" }]) || die; + $self->assert_num_equals(1, scalar @$msgs); + + $imaptalk->select("INBOX.subfolder"); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $exists = $imaptalk->get_response_code('exists'); + $self->assert_num_equals(1, $exists); + $msgs = $imaptalk->search("fuzzy", ["subject", { Quote => "xyzzy" }]) || die; + $self->assert_num_equals(1, scalar @$msgs); + + $imaptalk->select("INBOX.subfolder2"); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $exists = $imaptalk->get_response_code('exists'); + $self->assert_num_equals(0, $exists); + $msgs = $imaptalk->search("fuzzy", ["subject", { Quote => "xyzzy" }]) || die; + $self->assert_num_equals(0, scalar @$msgs); + + $imaptalk->list('', '*', 'return', [ "status", [ "messages", "uidvalidity", "highestmodseq", "mailboxid" ] ]); + my $poststatus = $imaptalk->get_response_code('status'); + + $self->assert_deep_equals($prestatus, $poststatus); +} diff --git a/cassandane/tiny-tests/FastMail/rename_deepfolder_intermediates b/cassandane/tiny-tests/FastMail/rename_deepfolder_intermediates new file mode 100644 index 0000000000..c3cd05ed5b --- /dev/null +++ b/cassandane/tiny-tests/FastMail/rename_deepfolder_intermediates @@ -0,0 +1,138 @@ +#!perl +use Cassandane::Tiny; + +sub test_rename_deepfolder_intermediates + :AllowMoves :Replication :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->setquota('user.cassandane', ['STORAGE', 500000]); + + my $rhttp = $self->{replica}->get_service('http'); + my $rjmap = Mail::JMAPTalk->new( + user => 'cassandane', + password => 'pass', + host => $rhttp->host(), + port => $rhttp->port(), + scheme => 'http', + url => '/jmap/', + ); + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + $self->_fmjmap_ok('Calendar/set', + create => { + "1" => { name => "A calendar" }, + }, + ); + + $self->_fmjmap_ok('Contact/set', + create => { + "1" => {firstName => "first", lastName => "last"}, + "2" => {firstName => "second", lastName => "last"}, + }, + ); + + $self->_fmjmap_ok('Mailbox/set', + create => { + "1" => { name => 'Archive', parentId => undef, role => 'archive' }, + "2" => { name => 'Drafts', parentId => undef, role => 'drafts' }, + "3" => { name => 'Junk', parentId => undef, role => 'junk' }, + "4" => { name => 'Sent', parentId => undef, role => 'sent' }, + "5" => { name => 'Trash', parentId => undef, role => 'trash' }, + "6" => { name => 'bar', parentId => undef, role => undef }, + "7" => { name => 'sub', parentId => "#6", role => undef }, + }, + ); + + xlog $self, "Create a folder with intermediates"; + $admintalk->create("user.cassandane.folderA.folderB.folderC"); + + my $data = $self->_fmjmap_ok('Mailbox/get', properties => ['name']); + my %byname = map { $_->{name} => $_->{id} } @{$data->{list}}; + + xlog $self, "Test replication"; + # replicate and check initial state + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication('cassandane'); + unlink($synclogfname); + + $data = $self->_fmjmap_ok('Mailbox/get', jmap => $rjmap, properties => ['name']); + my %byname_repl = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_repl); + + # n.b. run_replication dropped all our store connections... + $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->getsyslog(); + my $res = $admintalk->rename('user.cassandane.folderA', 'user.cassandane.folderZ'); + $self->assert(not $admintalk->get_last_error()); + + xlog $self, "Make sure we didn't create intermediates in the process!"; + my $syslog = join "\n", $self->{instance}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + $data = $self->_fmjmap_ok('Mailbox/get', properties => ['name']); + my %byname_new = map { $_->{name} => $_->{id} } @{$data->{list}}; + + # we renamed a folder! + $byname{folderZ} = delete $byname{folderA}; + + $self->assert_deep_equals(\%byname, \%byname_new); + + # replicate and check the renames + $self->{replica}->getsyslog(); + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $syslog = join "\n", $self->{replica}->getsyslog(); + + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + # check replication is clean + $self->check_replication('cassandane'); + + $data = $self->_fmjmap_ok('Mailbox/get', jmap => $rjmap, properties => ['name']); + my %byname_newrepl = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_newrepl); + + # n.b. run_replication dropped all our store connections... + $admintalk = $self->{adminstore}->get_client(); + $admintalk->delete("user.cassandane"); + + xlog $self, "Make sure we didn't create intermediates in the process!"; + $syslog = join "\n", $self->{instance}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + xlog $self, "Make sure there are no files left with cassandane in the name"; + $self->assert_str_equals(q{}, join(q{ }, glob "$self->{instance}{basedir}/conf/user/c/cassandane.*")); + $self->assert_not_file_test("$self->{instance}{basedir}/data/c/user/cassandane", "-d"); + $self->assert_not_file_test("$self->{instance}{basedir}/conf/quota/c/user.cassandane", "-f"); + + # replicate and check the renames + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $syslog = join "\n", $self->{replica}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + xlog $self, "Make sure there are no files left with cassandane in the name on the replica"; + $self->assert_str_equals(q{}, join(q{ }, glob "$self->{replica}{basedir}/conf/user/c/cassandane.*")); + $self->assert_not_file_test("$self->{replica}{basedir}/data/c/user/cassandane", "-d"); + $self->assert_not_file_test("$self->{replica}{basedir}/conf/quota/c/user.cassandane", "-f"); + + xlog $self, "Now clean up all the deleted mailboxes"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0', '-a' ); +} diff --git a/cassandane/tiny-tests/FastMail/rename_deepfolder_intermediates_rightnow b/cassandane/tiny-tests/FastMail/rename_deepfolder_intermediates_rightnow new file mode 100644 index 0000000000..02bfbc84db --- /dev/null +++ b/cassandane/tiny-tests/FastMail/rename_deepfolder_intermediates_rightnow @@ -0,0 +1,133 @@ +#!perl +use Cassandane::Tiny; + +sub test_rename_deepfolder_intermediates_rightnow + :AllowMoves :Replication :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions :RightNow +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->setquota('user.cassandane', ['STORAGE', 500000]); + + my $rhttp = $self->{replica}->get_service('http'); + my $rjmap = Mail::JMAPTalk->new( + user => 'cassandane', + password => 'pass', + host => $rhttp->host(), + port => $rhttp->port(), + scheme => 'http', + url => '/jmap/', + ); + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + $self->_fmjmap_ok('Calendar/set', + create => { + "1" => { name => "A calendar" }, + }, + ); + + $self->_fmjmap_ok('Contact/set', + create => { + "1" => {firstName => "first", lastName => "last"}, + "2" => {firstName => "second", lastName => "last"}, + }, + ); + + $self->_fmjmap_ok('Mailbox/set', + create => { + "1" => { name => 'Archive', parentId => undef, role => 'archive' }, + "2" => { name => 'Drafts', parentId => undef, role => 'drafts' }, + "3" => { name => 'Junk', parentId => undef, role => 'junk' }, + "4" => { name => 'Sent', parentId => undef, role => 'sent' }, + "5" => { name => 'Trash', parentId => undef, role => 'trash' }, + "6" => { name => 'bar', parentId => undef, role => undef }, + "7" => { name => 'sub', parentId => "#6", role => undef }, + }, + ); + + xlog $self, "Create a folder with intermediates"; + $admintalk->create("user.cassandane.folderA.folderB.folderC"); + + my $data = $self->_fmjmap_ok('Mailbox/get', properties => ['name']); + my %byname = map { $_->{name} => $_->{id} } @{$data->{list}}; + + xlog $self, "Test replication"; + # replicate and check initial state + $self->check_replication('cassandane'); + + $data = $self->_fmjmap_ok('Mailbox/get', jmap => $rjmap, properties => ['name']); + my %byname_repl = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_repl); + + # n.b. run_replication dropped all our store connections... + $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->getsyslog(); + my $res = $admintalk->rename('user.cassandane.folderA', 'user.cassandane.folderZ'); + $self->assert(not $admintalk->get_last_error()); + + xlog $self, "Make sure we didn't create intermediates in the process!"; + my $syslog = join "\n", $self->{instance}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + $data = $self->_fmjmap_ok('Mailbox/get', properties => ['name']); + my %byname_new = map { $_->{name} => $_->{id} } @{$data->{list}}; + + # we renamed a folder! + $byname{folderZ} = delete $byname{folderA}; + + $self->assert_deep_equals(\%byname, \%byname_new); + + # replicate and check the renames + $syslog = join "\n", $self->{replica}->getsyslog(); + + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + # check replication is clean + $self->check_replication('cassandane'); + + $data = $self->_fmjmap_ok('Mailbox/get', jmap => $rjmap, properties => ['name']); + my %byname_newrepl = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_newrepl); + + # n.b. run_replication dropped all our store connections... + $admintalk = $self->{adminstore}->get_client(); + $admintalk->delete("user.cassandane"); + + xlog $self, "Make sure we didn't create intermediates in the process!"; + $syslog = join "\n", $self->{instance}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + xlog $self, "Make sure there are no files left with cassandane in the name"; + $self->assert_str_equals(q{}, join(q{ }, glob "$self->{instance}{basedir}/conf/user/c/cassandane.*")); + $self->assert_not_file_test("$self->{instance}{basedir}/data/c/user/cassandane", "-d"); + $self->assert_not_file_test("$self->{instance}{basedir}/conf/quota/c/user.cassandane", "-d"); + + # replicate and check the renames + $syslog = join "\n", $self->{replica}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + xlog $self, "Make sure there are no files left with cassandane in the on the replica"; + $self->assert_str_equals(q{}, join(q{ }, glob "$self->{replica}{basedir}/conf/user/c/cassandane.*")); + $self->assert_not_file_test("$self->{replica}{basedir}/data/c/user/cassandane", "-d"); + $self->assert_not_file_test("$self->{replica}{basedir}/conf/quota/c/user.cassandane", "-f"); + + xlog $self, "Now clean up all the deleted mailboxes"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0', '-a' ); +} diff --git a/cassandane/tiny-tests/FastMail/rename_deepuser_standardfolders b/cassandane/tiny-tests/FastMail/rename_deepuser_standardfolders new file mode 100644 index 0000000000..c218f589af --- /dev/null +++ b/cassandane/tiny-tests/FastMail/rename_deepuser_standardfolders @@ -0,0 +1,106 @@ +#!perl +use Cassandane::Tiny; + +sub test_rename_deepuser_standardfolders + :AllowMoves :Replication :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $rhttp = $self->{replica}->get_service('http'); + my $rjmap = Mail::JMAPTalk->new( + user => 'cassandane', + password => 'pass', + host => $rhttp->host(), + port => $rhttp->port(), + scheme => 'http', + url => '/jmap/', + ); + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + $self->_fmjmap_ok('Calendar/set', + create => { + "1" => { name => "A calendar" }, + }, + ); + + $self->_fmjmap_ok('Contact/set', + create => { + "1" => {firstName => "first", lastName => "last"}, + "2" => {firstName => "second", lastName => "last"}, + }, + ); + + $self->_fmjmap_ok('Mailbox/set', + create => { + "1" => { name => 'Archive', parentId => undef, role => 'archive' }, + "2" => { name => 'Drafts', parentId => undef, role => 'drafts' }, + "3" => { name => 'Junk', parentId => undef, role => 'junk' }, + "4" => { name => 'Sent', parentId => undef, role => 'sent' }, + "5" => { name => 'Trash', parentId => undef, role => 'trash' }, + "6" => { name => 'bar', parentId => undef, role => undef }, + "7" => { name => 'sub', parentId => "#6", role => undef }, + }, + ); + + xlog $self, "Create a folder with intermediates"; + $admintalk->create("user.cassandane.folderA.folderB.folderC"); + + my $data = $self->_fmjmap_ok('Mailbox/get', properties => ['name']); + my %byname = map { $_->{name} => $_->{id} } @{$data->{list}}; + + xlog $self, "Test user rename"; + # replicate and check initial state + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication('cassandane'); + unlink($synclogfname); + + $data = $self->_fmjmap_ok('Mailbox/get', jmap => $rjmap, properties => ['name']); + my %byname_repl = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_repl); + + # n.b. run_replication dropped all our store connections... + $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->getsyslog(); + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert(not $admintalk->get_last_error()); + + xlog $self, "Make sure we didn't create intermediates in the process!"; + my $syslog = join "\n", $self->{instance}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + $res = $admintalk->select("user.newuser.bar.sub"); + $self->assert(not $admintalk->get_last_error()); + + $self->{jmap}->{user} = 'newuser'; + $data = $self->_fmjmap_ok('Mailbox/get', properties => ['name']); + my %byname_new = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_new); + + # replicate and check the renames + $self->{replica}->getsyslog(); + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $syslog = join "\n", $self->{replica}->getsyslog(); + + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + # check replication is clean + $self->check_replication('newuser'); + + $rjmap->{user} = 'newuser'; + $data = $self->_fmjmap_ok('Mailbox/get', jmap => $rjmap, properties => ['name']); + my %byname_newrepl = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_newrepl); +} diff --git a/cassandane/tiny-tests/FastMail/rename_deepuser_standardfolders_rightnow b/cassandane/tiny-tests/FastMail/rename_deepuser_standardfolders_rightnow new file mode 100644 index 0000000000..433d2fd5ec --- /dev/null +++ b/cassandane/tiny-tests/FastMail/rename_deepuser_standardfolders_rightnow @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_rename_deepuser_standardfolders_rightnow + :AllowMoves :Replication :min_version_3_3 :needs_component_sieve + :needs_component_jmap :JMAPExtensions :RightNow +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $rhttp = $self->{replica}->get_service('http'); + my $rjmap = Mail::JMAPTalk->new( + user => 'cassandane', + password => 'pass', + host => $rhttp->host(), + port => $rhttp->port(), + scheme => 'http', + url => '/jmap/', + ); + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + $self->_fmjmap_ok('Calendar/set', + create => { + "1" => { name => "A calendar" }, + }, + ); + + $self->_fmjmap_ok('Contact/set', + create => { + "1" => {firstName => "first", lastName => "last"}, + "2" => {firstName => "second", lastName => "last"}, + }, + ); + + $self->_fmjmap_ok('Mailbox/set', + create => { + "1" => { name => 'Archive', parentId => undef, role => 'archive' }, + "2" => { name => 'Drafts', parentId => undef, role => 'drafts' }, + "3" => { name => 'Junk', parentId => undef, role => 'junk' }, + "4" => { name => 'Sent', parentId => undef, role => 'sent' }, + "5" => { name => 'Trash', parentId => undef, role => 'trash' }, + "6" => { name => 'bar', parentId => undef, role => undef }, + "7" => { name => 'sub', parentId => "#6", role => undef }, + }, + ); + + xlog $self, "Create a folder with intermediates"; + $admintalk->create("user.cassandane.folderA.folderB.folderC"); + + my $data = $self->_fmjmap_ok('Mailbox/get', properties => ['name']); + my %byname = map { $_->{name} => $_->{id} } @{$data->{list}}; + + xlog $self, "Test user rename"; + # check initial state (replication has been running rightnow!) + $self->check_replication('cassandane'); + + $data = $self->_fmjmap_ok('Mailbox/get', jmap => $rjmap, properties => ['name']); + my %byname_repl = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_repl); + + # n.b. run_replication dropped all our store connections... + $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->getsyslog(); + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert(not $admintalk->get_last_error()); + + xlog $self, "Make sure we didn't create intermediates in the process!"; + my $syslog = join "\n", $self->{instance}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + $res = $admintalk->select("user.newuser.bar.sub"); + $self->assert(not $admintalk->get_last_error()); + + $self->{jmap}->{user} = 'newuser'; + $data = $self->_fmjmap_ok('Mailbox/get', properties => ['name']); + my %byname_new = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_new); + + # check nothing got logged on the replica + $syslog = join "\n", $self->{replica}->getsyslog(); + $self->assert_does_not_match(qr/creating intermediate with children/, + $syslog); + $self->assert_does_not_match(qr/deleting intermediate with no children/, + $syslog); + + # check replication is clean + $self->check_replication('newuser'); + + $rjmap->{user} = 'newuser'; + $data = $self->_fmjmap_ok('Mailbox/get', jmap => $rjmap, properties => ['name']); + my %byname_newrepl = map { $_->{name} => $_->{id} } @{$data->{list}}; + + $self->assert_deep_equals(\%byname, \%byname_newrepl); +} diff --git a/cassandane/tiny-tests/FastMail/rename_quotaroot b/cassandane/tiny-tests/FastMail/rename_quotaroot new file mode 100644 index 0000000000..a05ac1df54 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/rename_quotaroot @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_rename_quotaroot + :AllowMoves :Replication :min_version_3_2 + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user.newuser@example.com'); + $admintalk->setacl('user.newuser@example.com', 'admin' => 'lrswipkxtecdan'); + $admintalk->setacl('user.newuser@example.com', 'newuser@example.com' => 'lrswipkxtecdan'); + $admintalk->setquota('user.newuser@example.com', [storage => 3000000]); + + my $newtalk = $self->{store}->get_client(username => 'newuser@example.com'); + $newtalk->create("INBOX.sub"); + $newtalk->create("INBOX.magic"); + + $self->{adminstore}->set_folder('user.newuser.magic@example.com'); + $self->make_message("Message foo", store => $self->{adminstore}); + + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication('newuser@example.com'); + unlink($synclogfname); + + $admintalk = $self->{adminstore}->get_client(); + $admintalk->rename('user.newuser@example.com', 'user.del@internal'); + + $self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->check_replication('del@internal'); +} diff --git a/cassandane/tiny-tests/FastMail/search_deleted_folder b/cassandane/tiny-tests/FastMail/search_deleted_folder new file mode 100644 index 0000000000..af861efa87 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/search_deleted_folder @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_search_deleted_folder + :DelayedDelete :min_version_3_5 :NoMailboxLegacyDirs :needs_component_jmap +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $res = $self->_fmjmap_ok('Mailbox/get'); + my %m = map { $_->{name} => $_ } @{$res->{list}}; + my $inboxid = $m{"Inbox"}{id}; + $self->assert_not_null($inboxid); + + xlog $self, "Create the sub folders and emails"; + $talk->create("INBOX.sub"); + $talk->create("INBOX.extra"); + $self->make_message("Email abcd xyz hello 1") or die; + $self->{store}->set_folder("INBOX.sub"); + $self->make_message("Email abcd xyz hello 2") or die; + $self->{store}->set_folder("INBOX.extra"); + $self->make_message("Email abcd xyz hello 3") or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $self->_fmjmap_ok('Email/query', + filter => { text => "abcd", inMailboxOtherThan => [$inboxid] }, + ); + $self->assert_num_equals(2, scalar @{$res->{ids}}); + + xlog $self, "Delete the sub folder"; + $talk->delete("INBOX.sub"); + + xlog $self, "check that email can't be found"; + $res = $self->_fmjmap_ok('Email/query', + filter => { text => "xyz", inMailboxOtherThan => [$inboxid] }, + ); + $self->assert_num_equals(1, scalar @{$res->{ids}}); + + xlog $self, "use cyr_expire to clean up the deleted folder"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '0', '-a' ); + + xlog $self, "check that email can't be found after folder deleted"; + $res = $self->_fmjmap_ok('Email/query', + filter => { text => "hello", inMailboxOtherThan => [$inboxid] }, + ); + $self->assert_num_equals(1, scalar @{$res->{ids}}); +} diff --git a/cassandane/tiny-tests/FastMail/sync_reset_legacy b/cassandane/tiny-tests/FastMail/sync_reset_legacy new file mode 100644 index 0000000000..27b760076c --- /dev/null +++ b/cassandane/tiny-tests/FastMail/sync_reset_legacy @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_sync_reset_legacy + :DelayedDelete :min_version_3_5 :MailboxLegacyDirs + :needs_component_replication +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser"; + my $subfolder = "$inbox.foo"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/user/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "files exist"; + $self->assert_not_equals(0, scalar @files); + + $self->{instance}->run_command({ cyrus => 1 }, 'sync_reset', '-f' => 'magicuser' ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/user/magicuser/} and not m{/conf/lock/} } ; + close(FH); + + xlog $self, "no files left for this user"; + $self->assert_equals(0, scalar @files); +} diff --git a/cassandane/tiny-tests/FastMail/sync_reset_nolegacy b/cassandane/tiny-tests/FastMail/sync_reset_nolegacy new file mode 100644 index 0000000000..bc11f48c66 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/sync_reset_nolegacy @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_sync_reset_nolegacy + :DelayedDelete :min_version_3_5 :NoMailboxLegacyDirs + :needs_component_replication +{ + my ($self) = @_; + + my $adminstore = $self->{adminstore}; + my $admintalk = $adminstore->get_client(); + + my $inbox = "user.magicuser"; + my $subfolder = "$inbox.foo"; + + $admintalk->create($inbox); + $admintalk->setacl($inbox, admin => 'lrswipkxtecdan'); + $admintalk->create($subfolder); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $adminstore->set_folder($subfolder); + $self->make_message("Email", store => $adminstore) or die; + + # Create the search database. + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $admintalk->status($inbox, ['mailboxid']); + my $inboxid = $res->{mailboxid}[0]; + $res = $admintalk->status($subfolder, ['mailboxid']); + my $subid = $res->{mailboxid}[0]; + + my $basedir = $self->{instance}{basedir}; + open(FH, "-|", "find", $basedir); + my @files = grep { m{/uuid/} } ; + close(FH); + + xlog $self, "files exists"; + $self->assert(scalar grep { m{$inboxid} } @files); + $self->assert(scalar grep { m{$subid} } @files); + + $self->{instance}->run_command({ cyrus => 1 }, 'sync_reset', '-f' => 'magicuser' ); + + open(FH, "-|", "find", $basedir); + @files = grep { m{/uuid/} } ; + close(FH); + + xlog $self, "ensure there's no files left matching either uuid!"; + $self->assert(not scalar grep { m{$inboxid} } @files); + $self->assert(not scalar grep { m{$subid} } @files); +} diff --git a/cassandane/tiny-tests/FastMail/touch_raclmodseq b/cassandane/tiny-tests/FastMail/touch_raclmodseq new file mode 100644 index 0000000000..d95bb18491 --- /dev/null +++ b/cassandane/tiny-tests/FastMail/touch_raclmodseq @@ -0,0 +1,19 @@ +#!perl +use Cassandane::Tiny; + +sub test_touch_raclmodseq +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $imaptalk = $self->{store}->get_client(); + + my $precounters = $self->{store}->get_counters(); + + $admintalk->_imap_cmd('Raclmodseq', '', 0, 'cassandane'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $postcounters = $self->{store}->get_counters(); + $self->assert_num_not_equals($precounters->{raclmodseq}, $postcounters->{raclmodseq}, "RACL modseq changed"); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_calendars_all b/cassandane/tiny-tests/JMAPBackup/restore_calendars_all new file mode 100644 index 0000000000..f44f688a68 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_calendars_all @@ -0,0 +1,199 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_calendars_all + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create calendars"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + "1" => { + name => "foo", + color => "coral", + sortOrder => 1, + isVisible => \1 + }, + "2" => { + name => "bar", + color => "aqua", + sortOrder => 2, + isVisible => \1 + } + } + }, "R1"] + ]); + my $calid = $res->[0][1]{created}{"1"}{id}; + my $calid2 = $res->[0][1]{created}{"2"}{id}; + + xlog "send invitation as organizer"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + "calendarIds" => { + $calid => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT15M", + "replyTo" => { + imip => "mailto:cassandane\@example.com", + }, + "participants" => { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + "att" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:bugs@example.com', + }, + }, + }, + }, + "2" => { + "calendarIds" => { + $calid2 => JSON::true, + }, + "title" => "bar", + "description" => "bar's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2019-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT15M", + "replyTo" => { + imip => "mailto:cassandane\@example.com", + }, + "participants" => { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + "att" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:bugs@example.com', + }, + }, + }, + } + }}, "R1"] + ]); + my $id = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($id); + $self->assert(exists $res->[0][1]{created}{'2'}); + + my $mark = time(); + sleep 2; + + xlog "update an event title and delete a calendar"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $id => { 'title' => "foo2", 'sequence' => 1 }, + }, + }, 'R2'], + ['Calendar/set', { + destroy => ["$calid2"], + onDestroyRemoveEvents => JSON::true, + }, "R2.5"], + ['CalendarEvent/get', { + properties => ['title', 'sequence'], + }, 'R3'], + ]); + $self->assert(exists $res->[0][1]{updated}{$id}); + $self->assert_str_equals($calid2, $res->[1][1]{destroyed}[0]); + $self->assert_num_equals(1, scalar(@{$res->[2][1]{list}})); + $self->assert_str_equals('foo2', $res->[2][1]{list}[0]{title}); + + # clean notification cache + $self->{instance}->getnotify(); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore calendars prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreCalendars', { + undoPeriod => $period, + undoAll => JSON::true + }, "R4"], + ['CalendarEvent/get', { + properties => ['title', 'sequence', 'calendarIds'], + }, "R5"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreCalendars', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numDestroysUndone}); + + $self->assert_str_equals('CalendarEvent/get', $res->[1][0]); + $self->assert_str_equals('R5', $res->[1][2]); + $self->assert_num_equals(2, scalar(@{$res->[1][1]{list}})); + + my @got = sort { $a->{title} cmp $b->{title} } @{$res->[1][1]{list}}; + $self->assert_str_equals('bar', $got[0]{title}); + $self->assert_str_equals('foo', $got[1]{title}); + $self->assert_num_equals(2, $got[1]{sequence}); + + xlog "check that the restored calendar has correct name and color"; + $res = $jmap->CallMethods([ + ['Calendar/get', { + ids => [(keys %{$got[0]{calendarIds}})[0]], + properties => ['name', 'color'], + }, "R5.5"] + ]); + $self->assert_str_equals('bar', $res->[0][1]{list}[0]{name}); + $self->assert_str_equals('aqua', $res->[0][1]{list}[0]{color}); + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("bugs\@example.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REQUEST"); + + xlog "try to restore calendar to before initial creation"; + $res = $jmap->CallMethods([ + ['Backup/restoreCalendars', { + undoPeriod => "P1D", + undoAll => JSON::true + }, "R6"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); + $self->assert_str_equals('R6', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_calendars_all_dryrun b/cassandane/tiny-tests/JMAPBackup/restore_calendars_all_dryrun new file mode 100644 index 0000000000..8642faa04f --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_calendars_all_dryrun @@ -0,0 +1,168 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_calendars_all_dryrun + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create calendars"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + "1" => { + name => "foo", + color => "coral", + sortOrder => 1, + isVisible => \1 + }, + "2" => { + name => "bar", + color => "aqua", + sortOrder => 2, + isVisible => \1 + } + } + }, "R1"] + ]); + my $calid = $res->[0][1]{created}{"1"}{id}; + my $calid2 = $res->[0][1]{created}{"2"}{id}; + + xlog "send invitation as organizer"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + "calendarIds" => { + $calid => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT15M", + "replyTo" => { + imip => "mailto:cassandane\@example.com", + }, + "participants" => { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + "att" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:bugs@example.com', + }, + }, + }, + }, + "2" => { + "calendarIds" => { + $calid2 => JSON::true, + }, + "title" => "bar", + "description" => "bar's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2019-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT15M", + "replyTo" => { + imip => "mailto:cassandane\@example.com", + }, + "participants" => { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + "att" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:bugs@example.com', + }, + }, + }, + } + }}, "R1"] + ]); + my $id = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($id); + $self->assert(exists $res->[0][1]{created}{'2'}); + + my $mark = time(); + sleep 2; + + xlog "update an event title and delete a calendar"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $id => { 'title' => "foo2", 'sequence' => 1 }, + }, + }, 'R2'], + ['Calendar/set', { + destroy => ["$calid2"], + onDestroyRemoveEvents => JSON::true, + }, "R2.5"], + ['CalendarEvent/get', { + properties => ['title', 'sequence'], + }, 'R3'], + ]); + $self->assert(exists $res->[0][1]{updated}{$id}); + $self->assert_str_equals($calid2, $res->[1][1]{destroyed}[0]); + $self->assert_num_equals(1, scalar(@{$res->[2][1]{list}})); + $self->assert_str_equals('foo2', $res->[2][1]{list}[0]{title}); + + # clean notification cache + $self->{instance}->getnotify(); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore calendars prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreCalendars', { + performDryRun => JSON::true, + undoPeriod => $period, + undoAll => JSON::true + }, "R4"], + ['CalendarEvent/get', { + properties => ['title', 'sequence'], + }, "R5"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreCalendars', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numDestroysUndone}); + + $self->assert_str_equals('CalendarEvent/get', $res->[1][0]); + $self->assert_str_equals('R5', $res->[1][2]); + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals('foo2', $res->[1][1]{list}[0]{title}); + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($imip); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_calendars_batch_size_bug1 b/cassandane/tiny-tests/JMAPBackup/restore_calendars_batch_size_bug1 new file mode 100644 index 0000000000..f35aafff27 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_calendars_batch_size_bug1 @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_calendars_batch_size_bug1 + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + "1" => { + name => "foo" + } + } + }, "R1"] + ]); + my $calid = $res->[0][1]{created}{"1"}{id}; + + xlog "create a bunch of events"; + # two more than current Cyrus batch size (512) + my %events = (); + foreach my $n (1..514) { + $events{"$n"} = { + "calendarIds" => { + $calid => JSON::true, + }, + "title" => "foo", + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT15M" + } + } + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + %events + }}, "R1"] + ]); + + my $mark = time(); + sleep 2; + + xlog "fetch the id of event 513"; + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'] + ]); + my $id513 = $res->[0][1]{ids}[512]; + $self->assert_not_null($id513); + + xlog "delete event 513"; + # leaving first 512 events for batch 1 and #514 for batch 2 + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [$id513] + }, 'R2'] + ]); + $self->assert_str_equals($id513, $res->[0][1]{destroyed}[0]); + + xlog $self, "expire 513 from disk"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-X' => '0d' ); + + xlog "delete calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + destroy => ["$calid"], + onDestroyRemoveEvents => JSON::true, + }, "R2."] + ]); + $self->assert_str_equals($calid, $res->[0][1]{destroyed}[0]); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore calendar"; + $res = $jmap->CallMethods([ + ['Backup/restoreCalendars', { + performDryRun => JSON::true, + undoPeriod => $period, + undoAll => JSON::true + }, "R4"] + ]); + + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreCalendars', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(513, $res->[0][1]{numDestroysUndone}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_calendars_batch_size_bug2 b/cassandane/tiny-tests/JMAPBackup/restore_calendars_batch_size_bug2 new file mode 100644 index 0000000000..eed7849d78 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_calendars_batch_size_bug2 @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_calendars_batch_size_bug2 + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + "1" => { + name => "foo" + } + } + }, "R1"] + ]); + my $calid = $res->[0][1]{created}{"1"}{id}; + + xlog "create a bunch of events"; + # one more than current Cyrus batch size (512) + my %events = (); + foreach my $n (1..513) { + $events{"$n"} = { + "calendarIds" => { + $calid => JSON::true, + }, + "title" => "foo", + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT15M" + } + } + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + %events + }}, "R1"] + ]); + + my $mark = time(); + sleep 2; + + xlog "fetch the id of event 513"; + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'] + ]); + my $id513 = $res->[0][1]{ids}[512]; + $self->assert_not_null($id513); + + xlog "delete event 513"; + # leaving first 512 events for batch 1 and none for batch 2 + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [$id513] + }, 'R2'] + ]); + $self->assert_str_equals($id513, $res->[0][1]{destroyed}[0]); + + xlog $self, "expire 513 from disk"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-X' => '0d' ); + + xlog "delete calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + destroy => ["$calid"], + onDestroyRemoveEvents => JSON::true, + }, "R2."] + ]); + $self->assert_str_equals($calid, $res->[0][1]{destroyed}[0]); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore calendar"; + $res = $jmap->CallMethods([ + ['Backup/restoreCalendars', { + performDryRun => JSON::true, + undoPeriod => $period, + undoAll => JSON::true + }, "R4"] + ]); + + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreCalendars', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(512, $res->[0][1]{numDestroysUndone}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_contacts b/cassandane/tiny-tests/JMAPBackup/restore_contacts new file mode 100644 index 0000000000..4864e1e88c --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_contacts @@ -0,0 +1,161 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_contacts + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "a" => {firstName => "a", lastName => "a"}, + "b" => {firstName => "b", lastName => "b"}, + "c" => {firstName => "c", lastName => "c"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $contactA = $res->[0][1]{created}{"a"}{id}; + my $contactB = $res->[0][1]{created}{"b"}{id}; + my $contactC = $res->[0][1]{created}{"c"}{id}; + + xlog "destroy contact C"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$contactC] + }, "R1.5"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1.5', $res->[0][2]); + + xlog "dry-run restore contacts prior to most recent changes"; + $res = $jmap->CallMethods([['Backup/restoreContacts', { + undoPeriod => "P1D", + performDryRun => JSON::true, + undoAll => JSON::false + }, "R1.7"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreContacts', $res->[0][0]); + $self->assert_str_equals('R1.7', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numDestroysUndone}); + + my $mark = time(); + sleep 2; + + xlog "destroy contact A, update contact B, create contact D"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$contactA], + update => {$contactB => {firstName => "B"}}, + create => {"d" => {firstName => "d", lastName => "d"}} + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + my $contactD = $res->[0][1]{created}{"d"}{id}; + + xlog "destroy contact D, create contact E"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$contactD], + create => { + "e" => {firstName => "e", lastName => "e"} + } + }, "R4"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + my $contactE = $res->[0][1]{created}{"e"}{id}; + my $state = $res->[0][1]{newState}; + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore contacts prior to most recent changes"; + $res = $jmap->CallMethods([['Backup/restoreContacts', { + undoPeriod => $period, + undoAll => JSON::false + }, "R5"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreContacts', $res->[0][0]); + $self->assert_str_equals('R5', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(2, $res->[0][1]{numDestroysUndone}); + + xlog "get restored contacts"; + $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['firstName', 'lastName'], + }, "R6"], + ['ContactGroup/get', {}, "R6.1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/get', $res->[0][0]); + $self->assert_str_equals('R6', $res->[0][2]); + + my @got = sort { $a->{firstName} cmp $b->{firstName} } @{$res->[0][1]{list}}; + $self->assert_num_equals(4, scalar @got); + $self->assert_str_equals('B', $got[0]{firstName}); + $self->assert_str_equals('a', $got[1]{firstName}); + $self->assert_str_equals('d', $got[2]{firstName}); + $self->assert_str_equals('e', $got[3]{firstName}); + + $self->assert_str_equals('ContactGroup/get', $res->[1][0]); + $self->assert_str_equals('R6.1', $res->[1][2]); + $self->assert_num_equals(2, scalar @{$res->[1][1]{list}[0]{contactIds}}); + + my %contactIds = map { $_ => 1 } @{$res->[1][1]{list}[0]{contactIds}}; + $self->assert_not_null($contactIds{$contactA}); + $self->assert_not_null($contactIds{$contactD}); + + xlog "get contact updates"; + $res = $jmap->CallMethods([ + ['Contact/changes', { + sinceState => $state + }, "R6.5"], + ['ContactGroup/changes', { + sinceState => $state + }, "R6.6"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/changes', $res->[0][0]); + $self->assert_str_equals('R6.5', $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + %contactIds = map { $_ => 1 } @{$res->[0][1]{created}}; + $self->assert_not_null($contactIds{$contactA}); + $self->assert_not_null($contactIds{$contactD}); + + $self->assert_str_equals('ContactGroup/changes', $res->[1][0]); + $self->assert_str_equals('R6.6', $res->[1][2]); + $self->assert_str_equals($state, $res->[1][1]{oldState}); + $self->assert_str_not_equals($state, $res->[1][1]{newState}); + $self->assert_equals(JSON::false, $res->[1][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{destroyed}}); + $state = $res->[1][1]{newState}; + + $diff = time() - $mark; + $period = "PT" . $diff . "S"; + + xlog "try to re-restore contacts prior to most recent changes"; + $res = $jmap->CallMethods([['Backup/restoreContacts', { + undoPeriod => $period, + performDryRun => JSON::true, + undoAll => JSON::false + }, "R7"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreContacts', $res->[0][0]); + $self->assert_str_equals('R7', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numDestroysUndone}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_contacts_all b/cassandane/tiny-tests/JMAPBackup/restore_contacts_all new file mode 100644 index 0000000000..95694a2d36 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_contacts_all @@ -0,0 +1,193 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_contacts_all + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $start = time(); + sleep 2; + + xlog "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "a" => {firstName => "a", lastName => "a"}, + "b" => {firstName => "b", lastName => "b"}, + "c" => {firstName => "c", lastName => "c"}, + "d" => {firstName => "d", lastName => "d"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $contactA = $res->[0][1]{created}{"a"}{id}; + my $contactB = $res->[0][1]{created}{"b"}{id}; + my $contactC = $res->[0][1]{created}{"c"}{id}; + my $contactD = $res->[0][1]{created}{"d"}{id}; + + xlog "destroy contact A, update contact B"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$contactA], + update => {$contactB => {firstName => "B"}} + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + + xlog "get contacts"; + $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['firstName', 'lastName'], + }, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/get', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + + my @expect = sort { $a->{firstName} cmp $b->{firstName} } @{$res->[0][1]{list}}; + + my $mark = time(); + sleep 2; + + xlog "destroy contact C, update contacts B and D, create contact E"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$contactC], + update => { + $contactB => {lastName => "B"}, + $contactD => {lastName => "D"}, + }, + create => { + "e" => {firstName => "e", lastName => "e"} + } + }, "R4"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + my $contactE = $res->[0][1]{created}{"e"}{id}; + my $state = $res->[0][1]{newState}; + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore contacts prior to most recent changes"; + $res = $jmap->CallMethods([['Backup/restoreContacts', { + undoPeriod => $period, + undoAll => JSON::true + }, "R5"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreContacts', $res->[0][0]); + $self->assert_str_equals('R5', $res->[0][2]); + $self->assert_num_equals(1, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(2, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numDestroysUndone}); + + xlog "get restored contacts"; + $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['firstName', 'lastName'], + }, "R6"], + ['ContactGroup/get', {}, "R6.1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/get', $res->[0][0]); + $self->assert_str_equals('R6', $res->[0][2]); + + my @got = sort { $a->{firstName} cmp $b->{firstName} } @{$res->[0][1]{list}}; + $self->assert_num_equals(scalar @expect, scalar @got); + $self->assert_deep_equals(\@expect, \@got); + + $self->assert_str_equals('ContactGroup/get', $res->[1][0]); + $self->assert_str_equals('R6.1', $res->[1][2]); + $self->assert_str_equals($contactC, $res->[1][1]{list}[0]{contactIds}[0]); + + xlog "get contact updates"; + $res = $jmap->CallMethods([ + ['Contact/changes', { + sinceState => $state + }, "R6.5"], + ['ContactGroup/changes', { + sinceState => $state + }, "R6.6"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/changes', $res->[0][0]); + $self->assert_str_equals('R6.5', $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals($contactC, $res->[0][1]{created}[0]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($contactE, $res->[0][1]{destroyed}[0]); + + $self->assert_str_equals('ContactGroup/changes', $res->[1][0]); + $self->assert_str_equals('R6.6', $res->[1][2]); + $self->assert_str_equals($state, $res->[1][1]{oldState}); + $self->assert_str_not_equals($state, $res->[1][1]{newState}); + $self->assert_equals(JSON::false, $res->[1][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{destroyed}}); + $state = $res->[1][1]{newState}; + + $diff = time() - $start; + $period = "PT" . $diff . "S"; + + xlog "restore contacts to before initial creation"; + $res = $jmap->CallMethods([['Backup/restoreContacts', { + undoPeriod => $period, + undoAll => JSON::true + }, "R7"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreContacts', $res->[0][0]); + $self->assert_str_equals('R7', $res->[0][2]); + $self->assert_num_equals(4, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numDestroysUndone}); + + xlog "get restored contacts"; + $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['firstName', 'lastName'], + }, "R8"], + ['ContactGroup/get', {}, "R8.1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/get', $res->[0][0]); + $self->assert_str_equals('R8', $res->[0][2]); + $self->assert_deep_equals([], $res->[0][1]{list}); + + $self->assert_str_equals('ContactGroup/get', $res->[1][0]); + $self->assert_str_equals('R8.1', $res->[1][2]); + $self->assert_deep_equals([], $res->[1][1]{list}); + + xlog "get contact updates"; + $res = $jmap->CallMethods([ + ['Contact/changes', { + sinceState => $state + }, "R8.5"], + ['ContactGroup/changes', { + sinceState => $state + }, "R8.6"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/changes', $res->[0][0]); + $self->assert_str_equals('R8.5', $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(3, scalar @{$res->[0][1]{destroyed}}); + + $self->assert_str_equals('ContactGroup/changes', $res->[1][0]); + $self->assert_str_equals('R8.6', $res->[1][2]); + $self->assert_str_equals($state, $res->[1][1]{oldState}); + $self->assert_str_not_equals($state, $res->[1][1]{newState}); + $self->assert_equals(JSON::false, $res->[1][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{updated}}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{destroyed}}); + $state = $res->[1][1]{newState}; +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_contacts_all_dryrun b/cassandane/tiny-tests/JMAPBackup/restore_contacts_all_dryrun new file mode 100644 index 0000000000..d60a03049f --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_contacts_all_dryrun @@ -0,0 +1,109 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_contacts_all_dryrun + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "a" => {firstName => "a", lastName => "a"}, + "b" => {firstName => "b", lastName => "b"}, + "c" => {firstName => "c", lastName => "c"}, + "d" => {firstName => "d", lastName => "d"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $contactA = $res->[0][1]{created}{"a"}{id}; + my $contactB = $res->[0][1]{created}{"b"}{id}; + my $contactC = $res->[0][1]{created}{"c"}{id}; + my $contactD = $res->[0][1]{created}{"d"}{id}; + + xlog "destroy contact A, update contact B"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$contactA], + update => {$contactB => {firstName => "B"}} + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + + xlog "get contacts"; + $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['firstName', 'lastName'], + }, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/get', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + + my @expect = sort { $a->{firstName} cmp $b->{firstName} } @{$res->[0][1]{list}}; + + my $mark = time(); + sleep 2; + + xlog "destroy contact C, update contacts B and D, create contact E"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$contactC], + update => { + $contactB => {lastName => "B"}, + $contactD => {lastName => "D"}, + }, + create => { + "e" => {firstName => "e", lastName => "e"} + } + }, "R4"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + my $contactE = $res->[0][1]{created}{"e"}{id}; + my $state = $res->[0][1]{newState}; + + $diff = time() - $mark; + $period = "PT" . $diff . "S"; + + xlog "restore contacts prior to most recent changes"; + $res = $jmap->CallMethods([['Backup/restoreContacts', { + performDryRun => JSON::true, + undoPeriod => $period, + undoAll => JSON::true + }, "R5"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreContacts', $res->[0][0]); + $self->assert_str_equals('R5', $res->[0][2]); + $self->assert_num_equals(1, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(2, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numDestroysUndone}); + + xlog "get contact updates"; + $res = $jmap->CallMethods([ + ['Contact/changes', { + sinceState => $state + }, "R6.5"], + ['ContactGroup/changes', { + sinceState => $state + }, "R6.6"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/changes', $res->[0][0]); + $self->assert_str_equals('R6.5', $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + $self->assert_str_equals('ContactGroup/changes', $res->[1][0]); + $self->assert_str_equals('R6.6', $res->[1][2]); + $self->assert_str_equals($state, $res->[1][1]{oldState}); + $self->assert_str_equals($state, $res->[1][1]{newState}); + $self->assert_equals(JSON::false, $res->[1][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{destroyed}}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_mail_draft_reply b/cassandane/tiny-tests/JMAPBackup/restore_mail_draft_reply new file mode 100644 index 0000000000..f7c80623f2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_mail_draft_reply @@ -0,0 +1,97 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_mail_draft_reply + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my %exp; + + xlog "create mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + '1' => { name => 'Drafts', parentId => undef } + } + }, "R1"] + ]); + + my $draftsId = $res->[0][1]{created}{1}{id}; + + xlog $self, "generating email A"; + my $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -1)); + $exp{A} = $self->make_message("Email A", + date => $dt, body => "a"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "generating email B referencing A"; + $dt = DateTime->now(); + $exp{B} = $self->make_message("Re: Email A", + references => [ $exp{A} ], + date => $dt, body => "b"); + $exp{B}->set_attributes(uid => 2, cid => $exp{A}->get_attribute('cid')); + + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { property => "receivedAt", + "isAscending" => $JSON::true }, + ], + }, "R1"]]); + my @ids = @{$res->[0][1]->{ids}}; + $self->assert_num_equals(2, scalar @ids); + my $idA = $ids[0]; + my $idB = $ids[1]; + + xlog $self, "update email B to be a draft"; + $res = $jmap->CallMethods([['Email/set', { + update => { $idB => { + 'keywords/$draft' => JSON::true } + }, + }, "R1"]]); + + $self->assert(exists $res->[0][1]->{updated}{$idB}); + + my $mark = time(); + sleep 2; + + xlog "destroy 'draft' email"; + $res = $jmap->CallMethods([ + ['Email/set', { + destroy => ["$idB"] + }, "R6"], + ]); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{destroyed}})); + $self->assert_str_equals($idB, $res->[0][1]{destroyed}[0]); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + verboseLogging => JSON::true, + restoreDrafts => JSON::true, + restoreNonDrafts => JSON::false, + undoPeriod => $period + }, "R7"], + ['Email/get', { + ids => ["$idB"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R8"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R7', $res->[0][2]); + $self->assert_num_equals(1, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(0, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_str_equals('Email/get', $res->[1][0]); + $self->assert_str_equals('R8', $res->[1][2]); + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals("$idB", $res->[1][1]{list}[0]{id}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{keywords}->{'$draft'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{keywords}->{'$restored'}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_mail_draft_sent b/cassandane/tiny-tests/JMAPBackup/restore_mail_draft_sent new file mode 100644 index 0000000000..5bff0d021a --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_mail_draft_sent @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_mail_draft_sent + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + '1' => { name => 'Drafts', parentId => undef }, + '2' => { name => 'Sent', parentId => undef } + } + }, "R1"] + ]); + + my $draftsId = $res->[0][1]{created}{1}{id}; + my $sentId = $res->[0][1]{created}{2}{id}; + + xlog "create draft email"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + $draftsId => JSON::true + }, + keywords => { + '$draft' => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "email1" + } + }, + }, 'R2'] + ]); + + my $emailId = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId); + + xlog "move email from Drafts to Sent"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "mailboxIds/$draftsId" => undef, + "mailboxIds/$sentId" => JSON::true, + 'keywords/$draft' => undef + } } + }, "R5"] + ]); + + my $mark = time(); + sleep 2; + + xlog "destroy 'Sent' email"; + $res = $jmap->CallMethods([ + ['Email/set', { + destroy => ["$emailId"] + }, "R6"], + ]); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{destroyed}})); + $self->assert_str_equals($emailId, $res->[0][1]{destroyed}[0]); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + restoreDrafts => JSON::true, + restoreNonDrafts => JSON::true, + undoPeriod => $period + }, "R7"], + ['Email/get', { + ids => ["$emailId"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R8"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R7', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(1, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_str_equals('Email/get', $res->[1][0]); + $self->assert_str_equals('R8', $res->[1][2]); + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals("$emailId", $res->[1][1]{list}[0]{id}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_null($res->[1][1]{list}[0]{keywords}->{'$draft'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$sentId}); + $self->assert_null($res->[1][1]{list}[0]{mailboxIds}->{$draftsId}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_mail_exists b/cassandane/tiny-tests/JMAPBackup/restore_mail_exists new file mode 100644 index 0000000000..76faffa43f --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_mail_exists @@ -0,0 +1,96 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_mail_exists + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create email in Inbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + }, "R1"] + ]); + + $self->assert_num_equals(1, scalar(@{$res->[0][1]{list}})); + my $inboxId = $res->[0][1]{list}[0]{id}; + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + $inboxId => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "email1" + } + }, + }, 'R2'], + ['Email/get', { + ids => [ '#email1' ], + properties => ['receivedAt'] + }, "R3"] + ]); + my $emailId1 = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId1); + my $emailAt1 = $res->[1][1]{list}[0]{receivedAt}; + + my $mark = time(); + sleep 2; + + xlog "create new mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "1" => { + name => "foo" + } + } + }, "R4"], + ]); + $self->assert_not_null($res); + my $fooId = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($fooId); + + xlog "move email from Inbox to foo"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId1 => { + "mailboxIds/$inboxId" => undef, + "mailboxIds/$fooId" => JSON::true + } } + }, "R5"] + ]); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "actually restore mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + undoPeriod => $period + }, "R7"], + ['Email/get', { + ids => ["$emailId1"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R9"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R7', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(0, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_str_equals('Email/get', $res->[1][0]); + $self->assert_str_equals('R9', $res->[1][2]); + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals("$emailId1", $res->[1][1]{list}[0]{id}); + $self->assert_str_equals("$emailAt1", $res->[1][1]{list}[0]{receivedAt}); + $self->assert_null($res->[1][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$fooId}); + $self->assert_null($res->[1][1]{list}[0]{mailboxIds}->{$inboxId}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_mail_full b/cassandane/tiny-tests/JMAPBackup/restore_mail_full new file mode 100644 index 0000000000..bdf1df0617 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_mail_full @@ -0,0 +1,415 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_mail_full + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "1" => { + name => "foo" + }, + "3" => { + name => "bar" + }, + "2" => { + name => "Drafts", + role => "Drafts" + } + } + }, "R1"], + ['Mailbox/get', { + }, "R2"] + ]); + $self->assert_not_null($res); + my $fooId = $res->[0][1]{created}{"1"}{id}; + my $barId = $res->[0][1]{created}{"3"}{id}; + my $draftsId = $res->[0][1]{created}{"2"}{id}; + $self->assert_not_null($fooId); + $self->assert_not_null($barId); + $self->assert_not_null($draftsId); + + $self->assert_num_equals(4, scalar(@{$res->[1][1]{list}})); + my %m = map { $_->{name} => $_ } @{$res->[1][1]{list}}; + my $inboxId = $m{"Inbox"}->{id}; + $self->assert_not_null($inboxId); + + xlog "create emails in Inbox"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + $inboxId => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "email1" + }, + email2 => { + mailboxIds => { + $inboxId => JSON::true, + $fooId => JSON::true + }, + from => [{ email => q{foo2@bar} }], + to => [{ email => q{bar2@foo} }], + subject => "email2" + }, + email3 => { + mailboxIds => { + $inboxId => JSON::true + }, + # explicity set this keyword to make sure it gets removed + keywords => { '$restored' => JSON::true }, + from => [{ email => q{foo3@bar} }], + to => [{ email => q{bar3@foo} }], + subject => "email3" + }, + email4 => { + mailboxIds => { + $fooId => JSON::true + }, + from => [{ email => q{foo4@bar} }], + to => [{ email => q{bar4@foo} }], + subject => "email4" + }, + email5 => { + mailboxIds => { + $fooId => JSON::true + }, + from => [{ email => q{foo5@bar} }], + to => [{ email => q{bar5@foo} }], + subject => "email5" + }, + email6 => { + mailboxIds => { + $inboxId => JSON::true + }, + from => [{ email => q{foo6@bar} }], + to => [{ email => q{bar6@foo} }], + subject => "email6" + } + }, + }, 'R3'], + ['Email/get', { + ids => [ '#email1', '#email2', '#email3', '#email4', '#email5', '#email6' ], + properties => ['receivedAt'] + }, "R3.2"] + ]); + my $emailId1 = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId1); + my $emailId2 = $res->[0][1]{created}{email2}{id}; + $self->assert_not_null($emailId2); + my $emailId3 = $res->[0][1]{created}{email3}{id}; + $self->assert_not_null($emailId3); + my $emailId4 = $res->[0][1]{created}{email4}{id}; + $self->assert_not_null($emailId4); + my $emailId5 = $res->[0][1]{created}{email5}{id}; + $self->assert_not_null($emailId5); + my $emailId6 = $res->[0][1]{created}{email6}{id}; + $self->assert_not_null($emailId6); + + my $emailAt1 = $res->[1][1]{list}[0]{receivedAt}; + my $emailAt2 = $res->[1][1]{list}[1]{receivedAt}; + my $emailAt3 = $res->[1][1]{list}[2]{receivedAt}; + my $emailAt4 = $res->[1][1]{list}[3]{receivedAt}; + my $emailAt5 = $res->[1][1]{list}[4]{receivedAt}; + my $emailAt6 = $res->[1][1]{list}[5]{receivedAt}; + + xlog "create emails in Drafts"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + draft1 => { + mailboxIds => { + $draftsId => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "draft1", + keywords => { '$draft' => JSON::true }, + messageId => ['fake.123456789@local'], + }, + draft2 => { + mailboxIds => { + $draftsId => JSON::true + }, + from => [{ email => q{foo2@bar} }], + to => [{ email => q{bar2@foo} }], + subject => "draft2 (biggest)", + keywords => { '$draft' => JSON::true }, + messageId => ['fake.123456789@local'], + }, + draft3 => { + mailboxIds => { + $draftsId => JSON::true + }, + from => [{ email => q{foo3@bar} }], + to => [{ email => q{bar3@foo} }], + subject => "draft3 (bigger)", + keywords => { '$draft' => JSON::true }, + messageId => ['fake.123456789@local'], + }, + }, + }, 'R3.5'], + ['Email/get', { + ids => [ '#draft1', '#draft2', '#draft3' ], + properties => ['receivedAt'] + }, "R3.7"] + ]); + my $draftId1 = $res->[0][1]{created}{draft1}{id}; + $self->assert_not_null($emailId1); + my $draftId2 = $res->[0][1]{created}{draft2}{id}; + $self->assert_not_null($emailId2); + my $draftId3 = $res->[0][1]{created}{draft3}{id}; + $self->assert_not_null($emailId3); + + my $draftAt1 = $res->[1][1]{list}[0]{receivedAt}; + my $draftAt2 = $res->[1][1]{list}[1]{receivedAt}; + my $draftAt3 = $res->[1][1]{list}[2]{receivedAt}; + + xlog "move email6 from Inbox to bar, delete email1 and email5"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId6 => { + "mailboxIds/$inboxId" => undef, + "mailboxIds/$barId" => JSON::true + } }, + destroy => ["$emailId1", "$emailId5"] + }, "R4"] + ]); + $self->assert_str_equals($emailId1, $res->[0][1]{destroyed}[0]); + + xlog "remove email2 from Inbox"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId2 => { "mailboxIds/$inboxId" => undef }} + }, "R4.5"] + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId2}); + + my $mark = time(); + sleep 2; + + xlog "destroy email2, all drafts, 'foo' and 'bar' mailboxes"; + $res = $jmap->CallMethods([ + ['Email/set', { + destroy => ["$emailId2", "$draftId1", "$draftId2", "$draftId3"] + }, "R5"], + ['Mailbox/set', { + destroy => ["$fooId", "$barId"], + onDestroyRemoveEmails => JSON::true + }, "R5.5"], + ]); + $self->assert_num_equals(4, scalar(@{$res->[0][1]{destroyed}})); + my @expect = sort ($emailId2, $draftId1, $draftId2, $draftId3); + my @got = sort @{$res->[0][1]{destroyed}}; + $self->assert_deep_equals(\@expect, \@got); + + $self->assert_num_equals(2, scalar @{$res->[1][1]{destroyed}}); + @expect = sort ($fooId, $barId); + @got = sort @{$res->[1][1]{destroyed}}; + $self->assert_deep_equals(\@expect, \@got); + + xlog "create a new 'bar' mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "1" => { + name => "bar" + } + } + }, "R5.7"], + ['Mailbox/get', { + }, "R5.8"], + ['Email/get', { + ids => ["$emailId1", "$emailId2", "$emailId3", "$emailId4", "$emailId5", "$emailId6", + "$draftId1", "$draftId2", "$draftId3"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R5.9"] + ]); + $self->assert_not_null($res); + my $newBarId = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($newBarId); + + @expect = sort ($emailId1, $emailId2, $emailId4, $emailId5, $emailId6, $draftId1, $draftId2, $draftId3); + @got = sort @{$res->[2][1]{notFound}}; + $self->assert_deep_equals(\@expect, \@got); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "perform a dry-run restoration of mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + performDryRun => JSON::true, + undoPeriod => $period + }, "R5.9.4"], + ['Email/get', { + ids => ["$emailId1", "$emailId2", "$emailId3", "$emailId4", "$emailId5", "$emailId6", + "$draftId1", "$draftId2", "$draftId3"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R5.9.5"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R5.9.4', $res->[0][2]); + $self->assert_num_equals(1, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(3, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals("$emailId3", $res->[1][1]{list}[0]{id}); + $self->assert_str_equals("$emailAt3", $res->[1][1]{list}[0]{receivedAt}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$inboxId}); + + @expect = sort ($emailId1, $emailId2, $emailId4, $emailId5, $emailId6, $draftId1, $draftId2, $draftId3); + @got = sort @{$res->[1][1]{notFound}}; + $self->assert_deep_equals(\@expect, \@got); + + $diff = time() - $mark; + $period = "PT" . $diff . "S"; + + xlog "restore mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + restoreNonDrafts => JSON::false, + undoPeriod => $period + }, "R6"], + ['Email/get', { + ids => ["$emailId1", "$emailId2", "$emailId3", "$emailId4", "$emailId5", "$emailId6", + "$draftId1", "$draftId2", "$draftId3"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R6.2"], + ['Backup/restoreMail', { + restoreDrafts => JSON::false, + undoPeriod => $period + }, "R6.5"], + ['Mailbox/get', { + }, "R7"], + ['Email/get', { + ids => ["$emailId1", "$emailId2", "$emailId3", "$emailId4", "$emailId5", "$emailId6", + "$draftId1", "$draftId2", "$draftId3"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R8"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R6', $res->[0][2]); + $self->assert_num_equals(1, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(0, $res->[0][1]{numNonDraftsRestored}); + + # - email3 should have $restored flag removed + # - draft1 should NOT be restored (smaller than draft2) + # - draft2 should be the only draft restored to mailbox 'Drafts' + # because it was the largest of those having the same Message-ID + # - draft3 should NOT be restored (smaller than draft2) + $self->assert_str_equals('Email/get', $res->[1][0]); + $self->assert_str_equals('R6.2', $res->[1][2]); + $self->assert_num_equals(2, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals("$emailId3", $res->[1][1]{list}[0]{id}); + $self->assert_str_equals("$emailAt3", $res->[1][1]{list}[0]{receivedAt}); + $self->assert_null($res->[1][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$inboxId}); + + $self->assert_str_equals("$draftId2", $res->[1][1]{list}[1]{id}); + $self->assert_str_equals("$draftAt2", $res->[1][1]{list}[1]{receivedAt}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[1]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[1]{mailboxIds}{$draftsId}); + + $self->assert_num_equals(7, scalar(@{$res->[1][1]{notFound}})); + @expect = sort ($emailId1, $emailId2, $emailId4, $emailId5, $emailId6, $draftId1, $draftId3); + @got = sort @{$res->[1][1]{notFound}}; + $self->assert_deep_equals(\@expect, \@got); + + $self->assert_str_equals('R6.5', $res->[2][2]); + $self->assert_num_equals(0, $res->[2][1]{numDraftsRestored}); + $self->assert_num_equals(3, $res->[2][1]{numNonDraftsRestored}); + + # - mailbox 'foo' should be recreated (will have a new id) + # - email1 should NOT be restored (destroyed prior to cutoff) + # - email2 should be restored to the server-recreated 'foo' mailbox ONLY + # (it was destroyed most recently) + # - email4 should be restored to the server-recreated 'foo' mailbox + # - email5 should NOT be restored (destroyed prior to cutoff) + # - email6 should be restored to the user-recreated 'bar' mailbox ONLY + # (it was destroyed most recently) + # - draft2 should have $restored flag removed + $self->assert_str_equals('Mailbox/get', $res->[3][0]); + $self->assert_str_equals('R7', $res->[3][2]); + $self->assert_num_equals(4, scalar(@{$res->[3][1]{list}})); + $self->assert_str_equals("bar", $res->[3][1]{list}[2]{name}); + $self->assert_str_equals($newBarId, $res->[3][1]{list}[2]{id}); + $self->assert_str_equals("foo", $res->[3][1]{list}[3]{name}); + my $newFooId = $res->[3][1]{list}[3]{id}; + + $self->assert_str_equals('Email/get', $res->[4][0]); + $self->assert_str_equals('R8', $res->[4][2]); + $self->assert_num_equals(5, scalar(@{$res->[4][1]{list}})); + $self->assert_str_equals("$emailId2", $res->[4][1]{list}[0]{id}); + $self->assert_str_equals("$emailAt2", $res->[4][1]{list}[0]{receivedAt}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[0]{mailboxIds}{$newFooId}); + $self->assert_null($res->[4][1]{list}[0]{mailboxIds}->{$inboxId}); + + $self->assert_str_equals("$emailId3", $res->[4][1]{list}[1]{id}); + $self->assert_str_equals("$emailAt3", $res->[4][1]{list}[1]{receivedAt}); + $self->assert_null($res->[4][1]{list}[1]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[1]{mailboxIds}{$inboxId}); + + $self->assert_str_equals("$emailId4", $res->[4][1]{list}[2]{id}); + $self->assert_str_equals("$emailAt4", $res->[4][1]{list}[2]{receivedAt}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[2]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[2]{mailboxIds}{$newFooId}); + + $self->assert_str_equals("$emailId6", $res->[4][1]{list}[3]{id}); + $self->assert_str_equals("$emailAt6", $res->[4][1]{list}[3]{receivedAt}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[3]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[3]{mailboxIds}{$newBarId}); + $self->assert_null($res->[4][1]{list}[3]{mailboxIds}->{$inboxId}); + + $self->assert_str_equals("$draftId2", $res->[4][1]{list}[4]{id}); + $self->assert_str_equals("$draftAt2", $res->[4][1]{list}[4]{receivedAt}); + $self->assert_null($res->[4][1]{list}[4]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[4]{mailboxIds}{$draftsId}); + + $self->assert_num_equals(4, scalar(@{$res->[4][1]{notFound}})); + @expect = sort ($emailId1, $emailId5, $draftId1, $draftId3); + @got = sort @{$res->[4][1]{notFound}}; + $self->assert_deep_equals(\@expect, \@got); + + $diff = time() - $mark; + $period = "PT" . $diff . "S"; + + xlog "re-restore mailbox back to same point in time"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + undoPeriod => $period + }, "R9"], + ['Email/get', { + ids => ["$emailId1", "$emailId2", "$emailId3", "$emailId4", "$emailId5", "$emailId6", + "$draftId1", "$draftId2", "$draftId3"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R10"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R9', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(0, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_str_equals('Email/get', $res->[1][0]); + $self->assert_str_equals('R10', $res->[1][2]); + $self->assert_num_equals(5, scalar(@{$res->[1][1]{list}})); + + $self->assert_str_equals("$draftId2", $res->[1][1]{list}[4]{id}); + $self->assert_str_equals("$draftAt2", $res->[1][1]{list}[4]{receivedAt}); + $self->assert_null($res->[4][1]{list}[4]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[4]{mailboxIds}{$draftsId}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_mail_simple b/cassandane/tiny-tests/JMAPBackup/restore_mail_simple new file mode 100644 index 0000000000..6827e8eeff --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_mail_simple @@ -0,0 +1,180 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_mail_simple + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create email in Inbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + }, "R1"] + ]); + + $self->assert_num_equals(1, scalar(@{$res->[0][1]{list}})); + my $inboxId = $res->[0][1]{list}[0]{id}; + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + $inboxId => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "email1" + } + }, + }, 'R2'], + ['Email/get', { + ids => [ '#email1' ], + properties => ['receivedAt'] + }, "R3"] + ]); + my $emailId1 = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId1); + my $emailAt1 = $res->[1][1]{list}[0]{receivedAt}; + + xlog "create new mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "1" => { + name => "foo" + } + } + }, "R4"], + ]); + $self->assert_not_null($res); + my $fooId = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($fooId); + + xlog "move email from Inbox to foo"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId1 => { + "mailboxIds/$inboxId" => undef, + "mailboxIds/$fooId" => JSON::true + } } + }, "R5"] + ]); + + my $mark = time(); + sleep 2; + + xlog "destroy 'foo' mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => ["$fooId"], + onDestroyRemoveEmails => JSON::true + }, "R6"], + ]); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{destroyed}})); + $self->assert_str_equals($fooId, $res->[0][1]{destroyed}[0]); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "perform a dry-run restoration of mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + performDryRun => JSON::true, + restoreDrafts => JSON::false, + restoreNonDrafts => JSON::true, + undoPeriod => "PT1H" + }, "R7.1"], + ['Mailbox/get', { + }, "R8.1"], + ['Email/get', { + ids => ["$emailId1"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R9.1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R7.1', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(1, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_str_equals('Mailbox/get', $res->[1][0]); + $self->assert_str_equals('R8.1', $res->[1][2]); + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals("Inbox", $res->[1][1]{list}[0]{name}); + + $self->assert_str_equals('Email/get', $res->[2][0]); + $self->assert_str_equals('R9.1', $res->[2][2]); + $self->assert_num_equals(0, scalar(@{$res->[2][1]{list}})); + $self->assert_str_equals("$emailId1", $res->[2][1]{notFound}[0]); + + $diff = time() - $mark; + $period = "PT" . $diff . "S"; + + xlog "actually restore mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + restoreDrafts => JSON::false, + restoreNonDrafts => JSON::true, + undoPeriod => $period + }, "R7"], + ['Mailbox/get', { + }, "R8"], + ['Email/get', { + ids => ["$emailId1"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R9"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R7', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(1, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_str_equals('Mailbox/get', $res->[1][0]); + $self->assert_str_equals('R8', $res->[1][2]); + $self->assert_num_equals(2, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals("foo", $res->[1][1]{list}[1]{name}); + my $newFooId = $res->[1][1]{list}[1]{id}; + + $self->assert_str_equals('Email/get', $res->[2][0]); + $self->assert_str_equals('R9', $res->[2][2]); + $self->assert_num_equals(1, scalar(@{$res->[2][1]{list}})); + $self->assert_str_equals("$emailId1", $res->[2][1]{list}[0]{id}); + $self->assert_str_equals("$emailAt1", $res->[2][1]{list}[0]{receivedAt}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{mailboxIds}{$newFooId}); + $self->assert_null($res->[2][1]{list}[0]{mailboxIds}->{$inboxId}); + + $diff = time() - $mark; + $period = "PT" . $diff . "S"; + + xlog "attempt to re-restore mailbox back to same point in time"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + restoreDrafts => JSON::false, + restoreNonDrafts => JSON::true, + undoPeriod => $period + }, "R10"], + ['Email/get', { + ids => ["$emailId1"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R11"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R10', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(0, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_str_equals('Email/get', $res->[1][0]); + $self->assert_str_equals('R11', $res->[1][2]); + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals("$emailId1", $res->[1][1]{list}[0]{id}); + $self->assert_str_equals("$emailAt1", $res->[1][1]{list}[0]{receivedAt}); + $self->assert_null($res->[1][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$newFooId}); + $self->assert_null($res->[1][1]{list}[0]{mailboxIds}->{$inboxId}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_mail_submailbox b/cassandane/tiny-tests/JMAPBackup/restore_mail_submailbox new file mode 100644 index 0000000000..1cfbe2bf17 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_mail_submailbox @@ -0,0 +1,107 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_mail_submailbox + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $start = time(); + sleep 2; + + xlog "create mailbox tree"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + 'A' => { name => 'A', parentId => undef }, + 'B' => { name => 'B', parentId => '#A' }, + 'C' => { name => 'C', parentId => '#B' } + } + }, "R1"] + ]); + + my $aId = $res->[0][1]{created}{A}{id}; + my $bId = $res->[0][1]{created}{B}{id}; + my $cId = $res->[0][1]{created}{C}{id}; + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + $cId => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "email1" + } + }, + }, 'R2'], + ['Email/get', { + ids => [ '#email1' ], + properties => ['receivedAt'] + }, "R3"] + ]); + my $emailId1 = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId1); + my $emailAt1 = $res->[1][1]{list}[0]{receivedAt}; + + xlog "destroy 'C' mailbox and its ancestors"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => ["$cId", "$bId", "$aId"], + onDestroyRemoveEmails => JSON::true + }, "R6"], + ]); + $self->assert_num_equals(3, scalar(@{$res->[0][1]{destroyed}})); + $self->assert_str_equals($cId, $res->[0][1]{destroyed}[0]); + $self->assert_str_equals($bId, $res->[0][1]{destroyed}[1]); + $self->assert_str_equals($aId, $res->[0][1]{destroyed}[2]); + + my $diff = time() - $start; + my $period = "PT" . $diff . "S"; + + xlog "restore mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + undoPeriod => $period + }, "R7"], + ['Mailbox/get', { + }, "R8"], + ['Email/get', { + ids => ["$emailId1"], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R9"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreMail', $res->[0][0]); + $self->assert_str_equals('R7', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(1, $res->[0][1]{numNonDraftsRestored}); + + # Make sure that the proper mailbox tree was reconstructed + $self->assert_str_equals('Mailbox/get', $res->[1][0]); + $self->assert_str_equals('R8', $res->[1][2]); + $self->assert_num_equals(4, scalar(@{$res->[1][1]{list}})); + + $self->assert_str_equals("A", $res->[1][1]{list}[1]{name}); + my $newAId = $res->[1][1]{list}[1]{id}; + + $self->assert_str_equals("B", $res->[1][1]{list}[2]{name}); + my $newBId = $res->[1][1]{list}[2]{id}; + $self->assert_str_equals("$newAId", $res->[1][1]{list}[2]{parentId}); + + $self->assert_str_equals("C", $res->[1][1]{list}[3]{name}); + my $newCId = $res->[1][1]{list}[3]{id}; + $self->assert_str_equals("$newBId", $res->[1][1]{list}[3]{parentId}); + + $self->assert_str_equals('Email/get', $res->[2][0]); + $self->assert_str_equals('R9', $res->[2][2]); + $self->assert_num_equals(1, scalar(@{$res->[2][1]{list}})); + $self->assert_str_equals("$emailId1", $res->[2][1]{list}[0]{id}); + $self->assert_str_equals("$emailAt1", $res->[2][1]{list}[0]{receivedAt}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{mailboxIds}{$newCId}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_mail_twice b/cassandane/tiny-tests/JMAPBackup/restore_mail_twice new file mode 100644 index 0000000000..77180f4ba7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_mail_twice @@ -0,0 +1,145 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_mail_twice + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))") || die; + $imaptalk->create("INBOX.Notes") || die; + + xlog $self, "get mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $inbox = $m{"Inbox"}; + my $trash = $m{"Trash"}; + my $notes = $m{"Notes"}; + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + $inbox->{id} => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "email1" + } + }, + }, 'R2'], + ['Email/get', { + ids => [ '#email1' ], + properties => ['receivedAt'] + }, "R3"] + ]); + my $emailId1 = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId1); + my $emailAt1 = $res->[1][1]{list}[0]{receivedAt}; + + xlog "move email from Inbox to Trash"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId1 => { + "mailboxIds/$inbox->{id}" => undef, + "mailboxIds/$trash->{id}" => JSON::true + } } + }, "R5"] + ]); + + # need a gap between move and destroy otherwise we will restore both copies + my $mark = time(); + sleep 2; + + xlog "destroy email1"; + $res = $jmap->CallMethods([ + ['Email/set', { + destroy => [$emailId1] + }, "R5"], + ]); + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + undoPeriod => $period + }, "R7"], + ['Email/get', { + ids => [$emailId1], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R9"] + ]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(1, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals($emailId1, $res->[1][1]{list}[0]{id}); + $self->assert_str_equals($emailAt1, $res->[1][1]{list}[0]{receivedAt}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$trash->{id}}); + $self->assert_null($res->[1][1]{list}[0]{mailboxIds}->{$inbox->{id}}); + + $diff = time() - $mark; + $period = "PT" . $diff . "S"; + + xlog "try restoring mail again"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + undoPeriod => $period + }, "R7"], + ['Email/get', { + ids => [$emailId1], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R9"] + ]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(0, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals($emailId1, $res->[1][1]{list}[0]{id}); + $self->assert_str_equals($emailAt1, $res->[1][1]{list}[0]{receivedAt}); + $self->assert_null($res->[1][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$trash->{id}}); + $self->assert_null($res->[1][1]{list}[0]{mailboxIds}->{$inbox->{id}}); + + # need a gap between destroys otherwise we will restore both copies + my $mark = time(); + sleep 2; + + xlog "destroy email1 again"; + $res = $jmap->CallMethods([ + ['Email/set', { + destroy => [$emailId1] + }, "R5"], + ]); + + $diff = time() - $mark; + $period = "PT" . $diff . "S"; + + xlog "restore mail prior to most recent changes"; + $res = $jmap->CallMethods([ + ['Backup/restoreMail', { + undoPeriod => $period + }, "R7"], + ['Email/get', { + ids => [$emailId1], + properties => ['subject', 'keywords', 'mailboxIds', 'receivedAt'] + }, "R9"] + ]); + $self->assert_num_equals(0, $res->[0][1]{numDraftsRestored}); + $self->assert_num_equals(1, $res->[0][1]{numNonDraftsRestored}); + + $self->assert_num_equals(1, scalar(@{$res->[1][1]{list}})); + $self->assert_str_equals($emailId1, $res->[1][1]{list}[0]{id}); + $self->assert_str_equals($emailAt1, $res->[1][1]{list}[0]{receivedAt}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{keywords}->{'$restored'}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$trash->{id}}); + $self->assert_null($res->[1][1]{list}[0]{mailboxIds}->{$inbox->{id}}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_notes b/cassandane/tiny-tests/JMAPBackup/restore_notes new file mode 100644 index 0000000000..0d78f50d4f --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_notes @@ -0,0 +1,116 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_notes + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # force creation of notes mailbox prior to creating notes + my $res = $jmap->CallMethods([ + ['Note/set', { + }, "R0"] + ]); + + xlog "create notes"; + $res = $jmap->CallMethods([['Note/set', {create => { + "a" => {title => "a"}, + "b" => {title => "b"}, + "c" => {title => "c"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $noteA = $res->[0][1]{created}{"a"}{id}; + my $noteB = $res->[0][1]{created}{"b"}{id}; + my $noteC = $res->[0][1]{created}{"c"}{id}; + + xlog "destroy note C"; + $res = $jmap->CallMethods([['Note/set', { + destroy => [$noteC] + }, "R1.5"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R1.5', $res->[0][2]); + + my $mark = time(); + sleep 2; + + xlog "destroy note A, update note B, create note D"; + $res = $jmap->CallMethods([['Note/set', { + destroy => [$noteA], + update => {$noteB => {title => "B"}}, + create => {"d" => {title => "d"}} + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + my $noteD = $res->[0][1]{created}{"d"}{id}; + + xlog "destroy note D, create note E"; + $res = $jmap->CallMethods([['Note/set', { + destroy => [$noteD], + create => { + "e" => {title => "e"} + } + }, "R4"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + my $noteE = $res->[0][1]{created}{"e"}{id}; + my $state = $res->[0][1]{newState}; + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore notes prior to most recent changes"; + $res = $jmap->CallMethods([['Backup/restoreNotes', { + undoPeriod => $period, + undoAll => JSON::false + }, "R5"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreNotes', $res->[0][0]); + $self->assert_str_equals('R5', $res->[0][2]); + $self->assert_num_equals(0, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(2, $res->[0][1]{numDestroysUndone}); + + xlog "get restored notes"; + $res = $jmap->CallMethods([ + ['Note/get', { + properties => ['title', 'isFlagged'], + }, "R6"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/get', $res->[0][0]); + $self->assert_str_equals('R6', $res->[0][2]); + + my @got = sort { $a->{title} cmp $b->{title} } @{$res->[0][1]{list}}; + $self->assert_num_equals(4, scalar @got); + $self->assert_str_equals('B', $got[0]{title}); + $self->assert_str_equals('a', $got[1]{title}); + $self->assert_str_equals('d', $got[2]{title}); + $self->assert_str_equals('e', $got[3]{title}); + + xlog "get note updates"; + $res = $jmap->CallMethods([ + ['Note/changes', { + sinceState => $state + }, "R8.5"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/changes', $res->[0][0]); + $self->assert_str_equals('R8.5', $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + my %noteIds = map { $_ => 1 } @{$res->[0][1]{created}}; + $self->assert_not_null($noteIds{$noteA}); + $self->assert_not_null($noteIds{$noteD}); +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_notes_all b/cassandane/tiny-tests/JMAPBackup/restore_notes_all new file mode 100644 index 0000000000..e6bb5d7b3c --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_notes_all @@ -0,0 +1,165 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_notes_all + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # force creation of notes mailbox prior to creating notes + my $res = $jmap->CallMethods([ + ['Note/set', { + }, "R0"] + ]); + + my $start = time(); + sleep 2; + + xlog "create notes"; + $res = $jmap->CallMethods([['Note/set', {create => { + "a" => {title => "a"}, + "b" => {title => "b"}, + "c" => {title => "c"}, + "d" => {title => "d"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $noteA = $res->[0][1]{created}{"a"}{id}; + my $noteB = $res->[0][1]{created}{"b"}{id}; + my $noteC = $res->[0][1]{created}{"c"}{id}; + my $noteD = $res->[0][1]{created}{"d"}{id}; + + xlog "destroy note A, update note B"; + $res = $jmap->CallMethods([['Note/set', { + destroy => [$noteA], + update => {$noteB => {title => "B"}} + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + + xlog "get notes"; + $res = $jmap->CallMethods([ + ['Note/get', { + properties => ['title', 'isFlagged'], + }, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/get', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + + my @expect = sort { $a->{title} cmp $b->{title} } @{$res->[0][1]{list}}; + + my $mark = time(); + sleep 2; + + xlog "destroy note C, update notes B and D, create note E"; + $res = $jmap->CallMethods([['Note/set', { + destroy => [$noteC], + update => { + $noteB => {isFlagged => JSON::true}, + $noteD => {isFlagged => JSON::true}, + }, + create => { + "e" => {title => "e"} + } + }, "R4"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + my $noteE = $res->[0][1]{created}{"e"}{id}; + my $state = $res->[0][1]{newState}; + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore notes prior to most recent changes"; + $res = $jmap->CallMethods([['Backup/restoreNotes', { + undoPeriod => $period, + undoAll => JSON::true + }, "R5"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreNotes', $res->[0][0]); + $self->assert_str_equals('R5', $res->[0][2]); + $self->assert_num_equals(1, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(2, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numDestroysUndone}); + + xlog "get restored notes"; + $res = $jmap->CallMethods([ + ['Note/get', { + properties => ['title', 'isFlagged'], + }, "R6"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/get', $res->[0][0]); + $self->assert_str_equals('R6', $res->[0][2]); + + my @got = sort { $a->{title} cmp $b->{title} } @{$res->[0][1]{list}}; + $self->assert_num_equals(scalar @expect, scalar @got); + $self->assert_deep_equals(\@expect, \@got); + + xlog "get note updates"; + $res = $jmap->CallMethods([ + ['Note/changes', { + sinceState => $state + }, "R6.5"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/changes', $res->[0][0]); + $self->assert_str_equals('R6.5', $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals($noteC, $res->[0][1]{created}[0]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($noteE, $res->[0][1]{destroyed}[0]); + $state = $res->[0][1]{newState}; + + $diff = time() - $start; + $period = "PT" . $diff . "S"; + + xlog "restore notes to before initial creation"; + $res = $jmap->CallMethods([['Backup/restoreNotes', { + undoPeriod => $period, + undoAll => JSON::true + }, "R7"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreNotes', $res->[0][0]); + $self->assert_str_equals('R7', $res->[0][2]); + $self->assert_num_equals(3, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(0, $res->[0][1]{numDestroysUndone}); + + xlog "get restored notes"; + $res = $jmap->CallMethods([ + ['Note/get', { + properties => ['title', 'isFlagged'], + }, "R8"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/get', $res->[0][0]); + $self->assert_str_equals('R8', $res->[0][2]); + $self->assert_deep_equals([], $res->[0][1]{list}); + + xlog "get note updates"; + $res = $jmap->CallMethods([ + ['Note/changes', { + sinceState => $state + }, "R8.5"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/changes', $res->[0][0]); + $self->assert_str_equals('R8.5', $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(3, scalar @{$res->[0][1]{destroyed}}); + $state = $res->[0][1]{newState}; +} diff --git a/cassandane/tiny-tests/JMAPBackup/restore_notes_all_dryrun b/cassandane/tiny-tests/JMAPBackup/restore_notes_all_dryrun new file mode 100644 index 0000000000..a2eef7163f --- /dev/null +++ b/cassandane/tiny-tests/JMAPBackup/restore_notes_all_dryrun @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_restore_notes_all_dryrun + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # force creation of notes mailbox prior to creating notes + my $res = $jmap->CallMethods([ + ['Note/set', { + }, "R0"] + ]); + + xlog "create notes"; + $res = $jmap->CallMethods([['Note/set', {create => { + "a" => {title => "a"}, + "b" => {title => "b"}, + "c" => {title => "c"}, + "d" => {title => "d"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $noteA = $res->[0][1]{created}{"a"}{id}; + my $noteB = $res->[0][1]{created}{"b"}{id}; + my $noteC = $res->[0][1]{created}{"c"}{id}; + my $noteD = $res->[0][1]{created}{"d"}{id}; + + xlog "destroy note A, update note B"; + $res = $jmap->CallMethods([['Note/set', { + destroy => [$noteA], + update => {$noteB => {title => "B"}} + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + + xlog "get notes"; + $res = $jmap->CallMethods([ + ['Note/get', { + properties => ['title', 'isFlagged'], + }, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/get', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + + my @expect = sort { $a->{title} cmp $b->{title} } @{$res->[0][1]{list}}; + + my $mark = time(); + sleep 2; + + xlog "destroy note C, update notes B and D, create note E"; + $res = $jmap->CallMethods([['Note/set', { + destroy => [$noteC], + update => { + $noteB => {isFlagged => JSON::true}, + $noteD => {isFlagged => JSON::true}, + }, + create => { + "e" => {title => "e"} + } + }, "R4"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/set', $res->[0][0]); + $self->assert_str_equals('R4', $res->[0][2]); + my $noteE = $res->[0][1]{created}{"e"}{id}; + my $state = $res->[0][1]{newState}; + + my $diff = time() - $mark; + my $period = "PT" . $diff . "S"; + + xlog "restore notes prior to most recent changes"; + $res = $jmap->CallMethods([['Backup/restoreNotes', { + performDryRun => JSON::true, + undoPeriod => $period, + undoAll => JSON::true + }, "R5"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Backup/restoreNotes', $res->[0][0]); + $self->assert_str_equals('R5', $res->[0][2]); + $self->assert_num_equals(1, $res->[0][1]{numCreatesUndone}); + $self->assert_num_equals(2, $res->[0][1]{numUpdatesUndone}); + $self->assert_num_equals(1, $res->[0][1]{numDestroysUndone}); + + xlog "get note updates"; + $res = $jmap->CallMethods([ + ['Note/changes', { + sinceState => $state + }, "R6.5"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Note/changes', $res->[0][0]); + $self->assert_str_equals('R6.5', $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); +} diff --git a/cassandane/tiny-tests/JMAPBlob/blob-get b/cassandane/tiny-tests/JMAPBlob/blob-get new file mode 100644 index 0000000000..3912953b18 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBlob/blob-get @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_get + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my $self = shift; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body = "A plain text email."; + $exp_sub{A} = $self->make_message("foo", + body => $body + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get emails"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $blobId = $msg->{textBody}[0]{blobId}; + $self->assert_not_null($blobId); + + xlog "Test without capability"; + $res = $jmap->CallMethods([['Blob/get', { ids => [$blobId], properties => [ 'data:asText', 'size' ] }, 'R1']]); + $self->assert_str_equals($res->[0][0], 'error'); + + # XXX: this will be replaced with the upstream one + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/blob'); + + xlog "Regular Blob/get works and returns a blobId"; + $res = $jmap->CallMethods([['Blob/get', { ids => [$blobId], properties => [ 'data:asText', 'data:asBase64', 'size' ] }, 'R1']]); + $self->assert_str_equals($res->[0][0], 'Blob/get'); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals($blobId, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals($body, $res->[0][1]{list}[0]{'data:asText'}); + $self->assert_str_equals(encode_base64($body, ''), $res->[0][1]{list}[0]{'data:asBase64'}); + $self->assert_num_equals(length($body), $res->[0][1]{list}[0]{'size'}); +} diff --git a/cassandane/tiny-tests/JMAPBlob/blob-lookup b/cassandane/tiny-tests/JMAPBlob/blob-lookup new file mode 100644 index 0000000000..028af55b3d --- /dev/null +++ b/cassandane/tiny-tests/JMAPBlob/blob-lookup @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_lookup + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my $self = shift; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body = "A plain text email."; + $exp_sub{A} = $self->make_message("foo", + body => $body + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get emails"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $blobId = $msg->{textBody}[0]{blobId}; + $self->assert_not_null($blobId); + my $emailId = $msg->{id}; + $self->assert_not_null($emailId); + my $threadId = $msg->{threadId}; + $self->assert_not_null($threadId); + my $mailboxIds = $msg->{mailboxIds}; + my ($mailboxId) = keys %$mailboxIds; + $self->assert_not_null($mailboxId); + + xlog "Test without capability"; + $res = $jmap->CallMethods([['Blob/lookup', { ids => [$blobId, 'unknown'], typeNames => ['Mailbox', 'Thread', 'Email'] }, 'R1']]); + $self->assert_str_equals($res->[0][0], 'error'); + + # XXX: this will be replaced with the upstream one + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/blob'); + + xlog "Regular Blob/lookup works"; + $res = $jmap->CallMethods([['Blob/lookup', { ids => [$blobId, 'unknown'], typeNames => ['Mailbox', 'Thread', 'Email'] }, 'R1']]); + $self->assert_str_equals($res->[0][0], 'Blob/lookup'); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals($blobId, $res->[0][1]{list}[0]{id}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}[0]{matchedIds}{Mailbox}}); + $self->assert_str_equals($mailboxId, $res->[0][1]{list}[0]{matchedIds}{Mailbox}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}[0]{matchedIds}{Thread}}); + $self->assert_str_equals($threadId, $res->[0][1]{list}[0]{matchedIds}{Thread}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}[0]{matchedIds}{Email}}); + $self->assert_str_equals($emailId, $res->[0][1]{list}[0]{matchedIds}{Email}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{notFound}}); + $self->assert_str_equals('unknown', $res->[0][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPBlob/blob-upload-basic b/cassandane/tiny-tests/JMAPBlob/blob-upload-basic new file mode 100644 index 0000000000..bab0c301d0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPBlob/blob-upload-basic @@ -0,0 +1,21 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_upload_basic + :needs_component_jmap +{ + my $self = shift; + my $instance = $self->{instance}; + + xlog "Test without capability"; + my $jmap = $self->{jmap}; + my $res = $jmap->CallMethods([['Blob/upload', { create => { b1 => { data => [{'data:asText' => 'hello world'}] } } }, 'R1']]); + $self->assert_str_equals($res->[0][0], 'error'); + + $jmap->AddUsing('urn:ietf:params:jmap:blob'); + + xlog "Regular Blob/upload works and returns a blobId"; + $res = $jmap->CallMethods([['Blob/upload', { create => { b1 => { data => [{'data:asText' => 'hello world'}] } } }, 'R1']]); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_not_null($res->[0][1]{created}{b1}{id}); +} diff --git a/cassandane/tiny-tests/JMAPBlob/blob-upload-basic-legacy b/cassandane/tiny-tests/JMAPBlob/blob-upload-basic-legacy new file mode 100644 index 0000000000..64908c283a --- /dev/null +++ b/cassandane/tiny-tests/JMAPBlob/blob-upload-basic-legacy @@ -0,0 +1,21 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_upload_basic_legacy + :needs_component_jmap :JMAPExtensions +{ + my $self = shift; + my $instance = $self->{instance}; + + xlog "Test without capability"; + my $jmap = $self->{jmap}; + my $res = $jmap->CallMethods([['Blob/upload', { create => { b1 => { data => [{'data:asText' => 'hello world'}] } } }, 'R1']]); + $self->assert_str_equals($res->[0][0], 'error'); + + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/blob'); + + xlog "Regular Blob/upload works and returns a blobId"; + $res = $jmap->CallMethods([['Blob/upload', { create => { b1 => { data => [{'data:asText' => 'hello world'}] } } }, 'R1']]); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_not_null($res->[0][1]{created}{b1}{id}); +} diff --git a/cassandane/tiny-tests/JMAPBlob/blob-upload-complex b/cassandane/tiny-tests/JMAPBlob/blob-upload-complex new file mode 100644 index 0000000000..7f3300e04f --- /dev/null +++ b/cassandane/tiny-tests/JMAPBlob/blob-upload-complex @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_upload_complex + :needs_component_jmap +{ + my $self = shift; + my $jmap = $self->{jmap}; + + # GET supported digest algorithms from session object + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + my $session = eval { decode_json($RawResponse->{content}) }; + my %algs = map { $_ => 1 } @{$session->{capabilities}->{'https://cyrusimap.org/ns/jmap/blob'}->{supportedDigestAlgorithms}}; + + $jmap->AddUsing('urn:ietf:params:jmap:blob'); + + my $data = "The quick brown fox jumped over the lazy dog."; + my $bdata = encode_base64($data, ''); + + my $res; + + xlog "Regular Blob/upload works and returns the right data"; + $res = $jmap->CallMethods([ + ['Blob/upload', { create => { b1 => { data => [{'data:asText' => $data}] } } }, 'S1'], + ['Blob/get', { ids => ['#b1'], properties => [ 'data:asText', 'size' ] }, 'G1'], + ]); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_str_equals('Blob/get', $res->[1][0]); + $self->assert_str_equals($data, $res->[1][1]{list}[0]{'data:asText'}); + $self->assert_num_equals(length $data, $res->[1][1]{list}[0]{size}); + + xlog "Base64 Blob/upload works and returns the right data"; + my $props = [ 'data:asText', 'size' ]; + if ($algs{'md5'}) { + push @{$props}, 'digest:md5'; + } + if ($algs{'sha'}) { + push @{$props}, 'digest:sha'; + } + if ($algs{'sha-256'}) { + push @{$props}, 'digest:sha-256'; + } + + $res = $jmap->CallMethods([ + ['Blob/upload', { create => { b2 => { data => [{'data:asBase64' => $bdata}] } } }, 'S2'], + ['Blob/get', { ids => ['#b2'], properties => $props }, 'G2'], + ['Blob/get', { ids => ['#b2'], offset => 4, length => 9, properties => $props }, 'G2'], + ]); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_str_equals('Blob/get', $res->[1][0]); + $self->assert_str_equals($data, $res->[1][1]{list}[0]{'data:asText'}); + $self->assert_num_equals(length $data, $res->[1][1]{list}[0]{size}); + $self->assert_str_equals("quick bro", $res->[2][1]{list}[0]{'data:asText'}); + if ($algs{'md5'}) { + $self->assert_str_equals("tTNHgg3iNoIdHFn81iQD9A==", $res->[2][1]{list}[0]{'digest:md5'}); + } + if ($algs{'sha'}) { + $self->assert_str_equals("QiRAPtfyX8K6tm1iOAtZ87Xj3Ww=", $res->[2][1]{list}[0]{'digest:sha'}); + } + if ($algs{'sha-256'}) { + $self->assert_str_equals("gdg9INW7lwHK6OQ9u0dwDz2ZY/gubi0En0xlFpKt0OA=", $res->[2][1]{list}[0]{'digest:sha-256'}); + } + + xlog "Complex expression works and returns the right data"; + my $target = "How quick was that?"; + $res = $jmap->CallMethods([ + ['Blob/upload', { create => { b4 => { data => [{'data:asText' => $data}] } } }, 'S4'], + ['Blob/upload', { create => { mult => { data => [ + { 'data:asText' => 'How' }, # 'How' + { 'blobId' => '#b4', offset => 3, length => 7 }, # ' quick ' + { 'data:asText' => "was t" }, # 'was t' + { 'blobId' => '#b4', offset => 1, length => 1 }, # 'h' + { 'data:asBase64' => encode_base64('at?', '') }, # 'at?' + ] } } }, 'CAT'], + ['Blob/get', { ids => ['#mult'], properties => [ 'data:asText', 'size' ] }, 'G4'], + ]); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_str_equals('Blob/upload', $res->[1][0]); + $self->assert_str_equals('Blob/get', $res->[2][0]); + $self->assert_str_equals($target, $res->[2][1]{list}[0]{'data:asText'}); + $self->assert_num_equals(length $target, $res->[2][1]{list}[0]{size}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/account_get_capabilities b/cassandane/tiny-tests/JMAPCalendars/account_get_capabilities new file mode 100644 index 0000000000..634d2e2486 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/account_get_capabilities @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_account_get_capabilities + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $http = $self->{instance}->get_service("http"); + my $admintalk = $self->{adminstore}->get_client(); + + xlog "Get session object"; + + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + my $session = eval { decode_json($RawResponse->{content}) }; + $self->assert_not_null($session); + + my $capas = $session->{accounts}{cassandane}{accountCapabilities}{'urn:ietf:params:jmap:calendars'}; + $self->assert_not_null($capas); + + $self->assert_not_null($capas->{minDateTime}); + $self->assert_not_null($capas->{maxDateTime}); + $self->assert_not_null($capas->{maxExpandedQueryDuration}); + $self->assert(exists $capas->{maxParticipantsPerEvent}); + $self->assert_equals(JSON::true, $capas->{mayCreateCalendar}); + $self->assert_num_equals(1, $capas->{maxCalendarsPerEvent}); + + $capas = $session->{accounts}{cassandane}{accountCapabilities}{'urn:ietf:params:jmap:principals'}; + $self->assert_not_null($capas); + $self->assert_str_equals('cassandane', $capas->{currentUserPrincipalId}); + $self->assert_str_equals('cassandane', + $capas->{'urn:ietf:params:jmap:calendars'}{accountId}); + $self->assert_equals(JSON::true, + $capas->{'urn:ietf:params:jmap:calendars'}{mayGetAvailability}); + $self->assert_not_null($capas->{'urn:ietf:params:jmap:calendars'}{sendTo}); + + $capas = $session->{accounts}{cassandane}{accountCapabilities}{'urn:ietf:params:jmap:principals:owner'}; + $self->assert_not_null($capas); + $self->assert_str_equals('cassandane', $capas->{accountIdForPrincipal}); + $self->assert_str_equals('cassandane', $capas->{principalId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/account_get_shareesactas b/cassandane/tiny-tests/JMAPCalendars/account_get_shareesactas new file mode 100644 index 0000000000..e53927217a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/account_get_shareesactas @@ -0,0 +1,67 @@ +#!perl +use Cassandane::Tiny; + +sub test_account_get_shareesactas + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $http = $self->{instance}->get_service("http"); + my $admintalk = $self->{adminstore}->get_client(); + + my $getCapas = sub { + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + my $session = eval { decode_json($RawResponse->{content}) }; + $self->assert_not_null($session); + return $session->{accounts}{cassandane}{accountCapabilities}{'urn:ietf:params:jmap:calendars'}; + }; + + xlog "Sharees act as self"; + my $capas = $getCapas->(); + $self->assert_str_equals('self', $capas->{shareesActAs}); + + xlog "Sharees act as secretary"; + + my $xml = < + + + + secretary + + + +EOF + $caldav->Request('PROPPATCH', "/dav/calendars/user/cassandane", $xml, + 'Content-Type' => 'text/xml'); + + $capas = $getCapas->(); + $self->assert_str_equals('secretary', $capas->{shareesActAs}); + + $xml = < + + + + self + + + +EOF + $caldav->Request('PROPPATCH', "/dav/calendars/user/cassandane", $xml, + 'Content-Type' => 'text/xml'); + + $capas = $getCapas->(); + $self->assert_str_equals('self', $capas->{shareesActAs}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/admin_migrate39_defaultalerts b/cassandane/tiny-tests/JMAPCalendars/admin_migrate39_defaultalerts new file mode 100644 index 0000000000..2c1b024550 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/admin_migrate39_defaultalerts @@ -0,0 +1,496 @@ +#!perl +use Cassandane::Tiny; + +use Data::ICal; + +sub test_admin_migrate39_defaultalerts + :needs_component_jmap :min_version_3_9 :ReverseACLs :MagicPlus +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my @using = qw( + urn:ietf:params:jmap:core + urn:ietf:params:jmap:calendars + https://cyrusimap.org/ns/jmap/admin + https://cyrusimap.org/ns/jmap/calendars + https://cyrusimap.org/ns/jmap/debug + https://cyrusimap.org/ns/jmap/performance + ); + + my $http = $self->{instance}->get_service("http"); + my $adminJmap = Mail::JMAPTalk->new( + user => 'admin', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $adminJmap->DefaultUsing(\@using); + + xlog $self, "Make sure regular user can't call Admin method"; + my $res = $jmap->CallMethods([ + ['Admin/migrateCalendarDefaultAlarms', {}, 'R1'], + ], \@using); + $self->assert_str_equals('accountNotSupportedByMethod', + $res->[0][1]{type}); + + my $state = { + did_migrate => 0 + }; + + $self->assert_calendars($state); + $self->assert_events($state); + $self->assert_shared_calendars($state); + + xlog $self, "Migrate default alarms"; + $res = $adminJmap->CallMethods([ + ['Admin/migrateCalendarDefaultAlarms', { }, 'R1'], + ]); + + $state->{did_migrate} = 1; + $state->{migrateResponse} = $res->[0][1]; + + $self->assert_calendars($state); + $self->assert_events($state); + $self->assert_shared_calendars($state); +} + +sub assert_calendars +{ + my ($self, $state) = @_; + + my $caldav = $self->{caldav}; + my $imap = $self->{store}->get_client(); + my $jmap = $self->{jmap}; + + if (not $state->{did_migrate}) { + xlog $self, "Create calendars with legacy CalDAV alarms"; + $state->{calendarA} = { + id => $self->create_legacy_calendar('A'), + }; + $state->{calendarB} = { + id => $self->create_legacy_calendar('B'), + }; + + xlog $self, "Set CalDAV alarms on calendar A and calendar home"; + # That's not a representative example but allows to assert + # that migration adds a UID and removes the X-APPLE property. + my $valarms = <set_caldav_datetime_alarms($state->{calendarA}{id}, $valarms); + $self->set_caldav_datetime_alarms(undef, $valarms); + $state->{calendarA}{caldavAlarms} = $valarms; + } + + xlog $self, "Assert calendar default alerts"; + + $res = $jmap->CallMethods([ + ['Calendar/get', { + properties => [ + 'defaultAlertsWithTime', + 'defaultAlertsWithoutTime', + ], + }, 'R1'], + ]); + my %calendars = map { $_->{id} => $_ } @{$res->[0][1]{list}}; + + $self->assert_null($calendars{ + Default}{defaultAlertsWithTime}); + $self->assert_null($calendars{ + Default}{defaultAlertsWithoutTime}); + + my $calendarADefaultAlerts = $calendars{$state->{ + calendarA}{id}}{defaultAlertsWithTime}; + $self->assert_num_equals(1, scalar keys %$calendarADefaultAlerts); + $self->assert_null($calendars{$state->{ + calendarA}{id}}{defaultAlertsWithoutTime}); + + $self->assert_null($calendars{$state->{ + calendarB}{id}}{defaultAlertsWithTime}); + $self->assert_null($calendars{$state->{ + calendarB}{id}}{defaultAlertsWithoutTime}); + + if (not $state->{did_migrate}) { + $state->{calendarA}{defaultAlert} = (values %$calendarADefaultAlerts)[0]; + } else { + # Did migrate calendars + $self->assert_deep_equals({ + $state->{calendarA}{id} => undef, + $state->{calendarB}{id} => undef, + Default => undef, + }, $state->{migrateResponse}{migrated}{cassandane}); + + # Migration rewrites default alert UID, if none was set + $self->assert_matches(qr/UID:[0-9A-Za-z-]+/, + $self->get_jmap_defaultalerts_annotation($state->{calendarA}{id})); + + # JMAP annotation is set on both calendars + $self->assert_not_null($self->get_jmap_defaultalerts_annotation( + $state->{calendarA}{id})); + + $self->assert_not_null($self->get_jmap_defaultalerts_annotation( + $state->{calendarB}{id})); + + # CalDAV default alarms annotation got removed + $self->assert_null($self->get_caldav_datetime_annotation( + $state->{calendarA}{id})); + + $self->assert_null($self->get_caldav_datetime_annotation( + $state->{calendarB}{id})); + + # CalDAV default alarms annotation is kept on calendar home + $self->assert_not_null($self->get_caldav_datetime_annotation(undef)); + } +} + +sub assert_events +{ + my ($self, $state) = @_; + + my $caldav = $self->{caldav}; + my $jmap = $self->{jmap}; + + if (not $state->{did_migrate}) { + # First, create events + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventA => { + calendarIds => { + $state->{calendarA}{id} => JSON::true, + }, + title => "eventA", + start => "2023-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + eventB1 => { + calendarIds => { + $state->{calendarB}{id} => JSON::true, + }, + title => "eventB1", + start => "2023-01-20T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + eventB2 => { + calendarIds => { + $state->{calendarB}{id} => JSON::true, + }, + title => "eventB2", + start => "2023-01-21T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::false, + }, + }, + }, 'R1'], + ]); + + $state->{eventA} = { + xhref => $res->[0][1]{created}{eventA}{'x-href'} + }; + $self->assert_not_null($state->{eventA}{xhref}); + + $state->{eventB1} = { + xhref => $res->[0][1]{created}{eventB1}{'x-href'} + }; + $self->assert_not_null($state->{eventB1}{xhref}); + + $state->{eventB2} = { + xhref => $res->[0][1]{created}{eventB2}{'x-href'} + }; + $self->assert_not_null($state->{eventB2}{xhref}); + } + + my ($veventA, $etagA) = $self->get_vevent($caldav, $state->{eventA}{xhref}); + my @valarmsA = grep { $_->ical_entry_type() eq 'VALARM' } @{$veventA->entries()}; + $self->assert_num_equals(1, scalar @valarmsA); + + my ($veventB1, $etagB1) = $self->get_vevent($caldav, $state->{eventB1}{xhref}); + my @valarmsB1 = grep { $_->ical_entry_type() eq 'VALARM' } @{$veventB1->entries()}; + $self->assert_num_equals(0, scalar @valarmsB1); + + my ($veventB2, $etagB2) = $self->get_vevent($caldav, $state->{eventB2}{xhref}); + my @valarmsB2 = grep { $_->ical_entry_type() eq 'VALARM' } @{$veventB2->entries()}; + $self->assert_num_equals(0, scalar @valarmsB2); + + if (not $state->{did_migrate}) { + $state->{eventA}{etag} = $etagA; + $state->{eventB1}{etag} = $etagB1; + $state->{eventB2}{etag} = $etagB2; + } else { + # Event A ETag must have changed + $self->assert_str_not_equals($state->{eventA}{etag}, $etagA); + + # Event B1 ETag must have changed + $self->assert_str_not_equals($state->{eventB1}{etag}, $etagB1); + + # Event B@ ETag must not have changed + $self->assert_str_equals($state->{eventB2}{etag}, $etagB2); + } +} + +sub assert_shared_calendars +{ + my ($self, $state) = @_; + + my $eventUidA1 = '40d6fe3c-6a51-489e-823e-3ea22f427a3e'; + my $eventUidA2 = '00a90af9-8398-4074-94bf-6251a1ab9e70'; + my $eventUidB1 = '93c7831d-e246-4dda-ab3f-52acba6b9e3b'; + my $eventUidB2 = '379e7061-d20f-45e4-8366-52d45836a7fd'; + + if (not $state->{did_migrate}) { + xlog $self, "Create sharee"; + ($self->{shareeJmap}, $self->{shareeCaldav}) = $self->create_user('sharee'); + + xlog $self, "Share calendars"; + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->setacl("user.cassandane.#calendars.$state->{calendarA}{id}", + sharee => 'lrswipkxtecdn') or die; + $admintalk->setacl("user.cassandane.#calendars.$state->{calendarB}{id}", + sharee => 'lrswipkxtecdn') or die; + + xlog $self, "Create sharee useDefaultAlerts=true in calendar A"; + my $ical = <{shareeCaldav}->Request('PUT', + "cassandane.$state->{calendarA}{id}/$eventUidA1.ics", + $ical, + 'Content-Type' => 'text/calendar', + 'X-Cyrus-rewrite-usedefaultalerts' => 'false', + ); + + xlog $self, "Create sharee per-user prop in calendar A"; + my $ical = <{shareeCaldav}->Request('PUT', + "cassandane.$state->{calendarA}{id}/$eventUidA2.ics", + $ical, + 'Content-Type' => 'text/calendar', + 'X-Cyrus-rewrite-usedefaultalerts' => 'false', + ); + + xlog $self, "Create sharee useDefaultAlerts=true in calendar B"; + $ical = <{shareeCaldav}->Request('PUT', + "cassandane.$state->{calendarB}{id}/$eventUidB1.ics", + $ical, + 'Content-Type' => 'text/calendar', + 'X-Cyrus-rewrite-usedefaultalerts' => 'false', + ); + + xlog $self, "Create sharee per-user prop in calendar B"; + $ical = <{shareeCaldav}->Request('PUT', + "cassandane.$state->{calendarB}{id}/$eventUidB2.ics", + $ical, + 'Content-Type' => 'text/calendar', + 'X-Cyrus-rewrite-usedefaultalerts' => 'false', + ); + } + + my $res = $self->{shareeJmap}->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + properties => ['useDefaultAlerts', 'alerts', 'color', 'uid'], + }, 'R1'], + ]); + my %eventsByUid = map { $_->{uid} => $_ } @{$res->[0][1]{list}}; + + my $eventA1 = $eventsByUid{$eventUidA1}; + $self->assert_not_null($eventA1); + $self->assert_str_equals('blue', $eventA1->{color}); + + my $eventA2 = $eventsByUid{$eventUidA2}; + $self->assert_not_null($eventA2); + $self->assert_str_equals('red', $eventA2->{color}); + $self->assert_equals(JSON::false, $eventA2->{useDefaultAlerts}); + $self->assert_null($eventA2->{alerts}); + + my $eventB1 = $eventsByUid{$eventUidB1}; + $self->assert_not_null($eventB1); + $self->assert_null($eventB1->{color}); + + my $eventB2 = $eventsByUid{$eventUidB2}; + $self->assert_not_null($eventB2); + $self->assert_str_equals('brown', $eventB2->{color}); + $self->assert_equals(JSON::false, $eventB2->{useDefaultAlerts}); + $self->assert_null($eventB2->{alerts}); + + if (not $state->{did_migrate}) { + $self->assert_equals(JSON::true, $eventA1->{useDefaultAlerts}); + $self->assert_null($eventA1->{alerts}); + + $self->assert_equals(JSON::true, $eventB1->{useDefaultAlerts}); + $self->assert_null($eventB1->{alerts}); + } + else { + $self->assert_deep_equals({ + Default => undef, + }, $state->{migrateResponse}{migrated}{sharee}); + + $self->assert_equals(JSON::false, $eventA1->{useDefaultAlerts}); + $self->assert_not_null($eventA1->{alerts}); + + $self->assert_equals(JSON::false, $eventB1->{useDefaultAlerts}); + $self->assert_null($eventB1->{alerts}); + } +} + +# All the remaining functions just make the test code less gnarly + +sub get_vevent +{ + my ($self, $caldav, $eventHref) = @_; + + xlog $self, "GET event"; + my %headers = ( + 'Content-Type' => 'text/calendar', + 'Authorization' => $caldav->auth_header(), + ); + my $res = $caldav->{ua}->request('GET', + $caldav->request_url($eventHref), { + headers => \%headers, + }); + $self->assert_str_equals('200', $res->{status}); + + my $vcalendar = Data::ICal->new(data => $res->{content}); + my @vevents = grep { $_->ical_entry_type() eq 'VEVENT' } @{$vcalendar->entries()}; + my $vevent = $vevents[0]; + $self->assert_not_null($vevent); + return ($vevent, $res->{headers}{etag}); +} + +sub get_jmap_defaultalerts_annotation +{ + my ($self, $calendarId) = @_; + my $imap = $self->{store}->get_client(); + + my $mboxname = '#calendars'; + $mboxname .= ".$calendarId" if $calendarId; + + my $res = $imap->getmetadata($mboxname, + '/private/vendor/cmu/cyrus-jmap/defaultalerts'); + return $res->{$mboxname}{ + '/private/vendor/cmu/cyrus-jmap/defaultalerts'}; +} + +sub get_caldav_datetime_annotation +{ + my ($self, $calendarId) = @_; + my $imap = $self->{store}->get_client(); + + my $mboxname = '#calendars'; + $mboxname .= ".$calendarId" if $calendarId; + + my $res = $imap->getmetadata($mboxname, + '/shared/vendor/cmu/cyrus-httpd/default-alarm-vevent-datetime'); + return $res->{$mboxname}{ + '/shared/vendor/cmu/cyrus-httpd/default-alarm-vevent-datetime'}; +} + + +sub create_legacy_calendar +{ + my ($self, $name) = @_; + my $caldav = $self->{caldav}; + my $plusstore = $self->{instance}->get_service('imap' + )->create_store(username => 'cassandane+dav'); + my $imap = $plusstore->get_client(); + + xlog $self, "Create calendar named $name"; + my $calendarId = $caldav->NewCalendar({name => $name}); + $self->assert_not_null($calendarId); + + xlog $self, "Remove JMAP default alert annotation"; + $imap->setmetadata("#calendars.$calendarId", + '/private/vendor/cmu/cyrus-jmap/defaultalerts', ''); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); + + return $calendarId; +} + +sub set_caldav_datetime_alarms +{ + my ($self, $calendarId, $valarms) = @_; + my $plusstore = $self->{instance}->get_service('imap' + )->create_store(username => 'cassandane+dav'); + my $imap = $plusstore->get_client(); + + my $mboxname = '#calendars'; + $mboxname .= ".$calendarId" if $calendarId; + + $imap->setmetadata($mboxname, + '/shared/vendor/cmu/cyrus-httpd/default-alarm-vevent-datetime', $valarms); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/admin_rewrite_calendarevent_privacy b/cassandane/tiny-tests/JMAPCalendars/admin_rewrite_calendarevent_privacy new file mode 100644 index 0000000000..a4cc4c6a00 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/admin_rewrite_calendarevent_privacy @@ -0,0 +1,166 @@ +#!perl +use Cassandane::Tiny; + +use DBI; + +sub test_admin_rewrite_calendarevent_privacy + :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my @using = qw( + urn:ietf:params:jmap:core + urn:ietf:params:jmap:calendars + https://cyrusimap.org/ns/jmap/admin + https://cyrusimap.org/ns/jmap/calendars + https://cyrusimap.org/ns/jmap/debug + https://cyrusimap.org/ns/jmap/performance +); + + xlog $self, "Make sure regular user can't call Admin method"; + $res = $jmap->CallMethods([ + ['Admin/rewriteCalendarEventPrivacy', {}, 'R1'], + ], \@using); + $self->assert_str_equals('accountNotSupportedByMethod', + $res->[0][1]{type}); + + my $event1Uid = '40d11f36-245b-4a03-8034-df25f38f9f61'; + + xlog $self, "create two calendar events"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => $event1Uid, + title => 'event1', + start => '2020-01-01T09:00:00', + timeZone => 'Europe/Vienna', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + attendee1 => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee1@example.com', + }, + }, + }, + }, + event2 => { + calendarIds => { + Default => JSON::true, + }, + uid => '6b18a778-5827-49b9-a2f1-4f67d7be2b6b', + title => 'event2', + start => '2020-02-02T09:00:00', + timeZone => 'Europe/Vienna', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + attendee1 => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee1@example.com', + }, + }, + }, + }, + + }, + }, 'R1'], + ], \@using); + $self->assert_not_null($res->[0][1]{created}{event1}); + $self->assert_not_null($res->[0][1]{created}{event2}); + my $state = $res->[0][1]{newState}; + $self->assert_not_null($state); + + my $http = $self->{instance}->get_service("http"); + my $adminJmap = Mail::JMAPTalk->new( + user => 'admin', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $adminJmap->DefaultUsing(\@using); + + + my $dirs = ($self->{instance}->run_mbpath(-u => 'cassandane')); + my $db = DBI->connect("dbi:SQLite:dbname=$dirs->{user}{dav}","",""); + + xlog $self, "Make sure no calendar event is marked private"; + my $selectStmt = $db->prepare( + "SELECT rowid FROM ical_objs WHERE comp_flags >= 1024"); + $selectStmt->execute(); + $self->assert_null($selectStmt->fetch()); + + xlog $self, "Manually mark one calendar event private"; + my $updateStmt = $db->prepare( + "UPDATE ical_objs SET comp_flags = 1024 WHERE ical_uid = '$event1Uid'"); + $res = $updateStmt->execute(); + $self->assert_num_equals(1, $res); + + xlog $self, "Clear notifications"; + $self->{instance}->getnotify(); + + xlog $self, "Rewrite calendar event privacy as admin"; + $res = $adminJmap->CallMethods([ + ['Admin/rewriteCalendarEventPrivacy', {}, 'R1'], + ]); + $self->assert_num_equals(1, + scalar keys %{$res->[0][1]{rewritten}{cassandane}}); + $self->assert_null($res->[0][1]{notRewritten}); + + xlog $self, "Make sure no calendar event is marked private"; + $selectStmt->execute(); + $self->assert_null($selectStmt->fetch()); + + xlog $self, "Assert no iMIP notifications are sent"; + my $data = $self->{instance}->getnotify(); + $self->assert_num_equals(0, scalar grep { $_->{METHOD} eq 'imip' } @$data); + + xlog $self, "Assert new event message is sent to pusher"; + my @newevent = grep { + $_->{METHOD} eq 'pusher' and $_->{MESSAGE} =~ '{"event":"MessageNew"' + } @$data; + $self->assert_num_equals(1, scalar @newevent); + + xlog $self, "Assert CalendarEvent state changed"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [], + }, 'R1'] + ], \@using); + $self->assert_str_not_equals($state, $res->[0][1]{state}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_changes b/cassandane/tiny-tests/JMAPCalendars/calendar_changes new file mode 100644 index 0000000000..619879b2e8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_changes @@ -0,0 +1,111 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_changes + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { + "1" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1 + }, + "2" => { + name => "bar", + color => "aqua", + sortOrder => 3, + isVisible => \1 + } + }}, "R1"] + ]); + $self->assert_not_null($res); + + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + my $state = $res->[0][1]{newState}; + + xlog $self, "get calendar updates without changes"; + $res = $jmap->CallMethods([['Calendar/changes', { + "sinceState" => $state + }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "update name of calendar $id1, destroy calendar $id2"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + ifInState => $state, + update => {"$id1" => {name => "foo (upd)"}}, + destroy => [$id2] + }, "R1"] + ]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + + xlog $self, "get calendar updates"; + $res = $jmap->CallMethods([['Calendar/changes', { + "sinceState" => $state + }, "R1"]]); + $self->assert_str_equals("Calendar/changes", $res->[0][0]); + $self->assert_str_equals("R1", $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id1, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{destroyed}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "update color of calendar $id1"; + $res = $jmap->CallMethods([ + ['Calendar/set', { update => { $id1 => { color => "aqua" }}}, "R1" ] + ]); + $self->assert(exists $res->[0][1]{updated}{$id1}); + + xlog $self, "get calendar updates"; + $res = $jmap->CallMethods([['Calendar/changes', { + "sinceState" => $state + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id1, $res->[0][1]{updated}[0]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $state = $res->[0][1]{newState}; + + xlog $self, "update sortOrder of calendar $id1"; + $res = $jmap->CallMethods([ + ['Calendar/set', { update => { $id1 => { sortOrder => 5 }}}, "R1" ] + ]); + $self->assert(exists $res->[0][1]{updated}{$id1}); + + xlog $self, "get calendar updates"; + $res = $jmap->CallMethods([['Calendar/changes', { + "sinceState" => $state, + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id1, $res->[0][1]{updated}[0]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $state = $res->[0][1]{newState}; + + xlog $self, "get empty calendar updates"; + $res = $jmap->CallMethods([['Calendar/changes', { + "sinceState" => $state + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_changes_shared b/cassandane/tiny-tests/JMAPCalendars/calendar_changes_shared new file mode 100644 index 0000000000..e2e9c1a109 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_changes_shared @@ -0,0 +1,227 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_changes_shared + :min_version_3_9 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + my $assert_changes = sub + { + my ($sinceState, $changes) = @_; + + my $res = $jmap->CallMethods([ + ['Calendar/changes', { + accountId => 'sharer', + sinceState => $sinceState, + }, 'R1'] + ]); + + $self->assert_deep_equals($changes->{created}, $res->[0][1]{created}); + $self->assert_deep_equals($changes->{updated}, $res->[0][1]{updated}); + $self->assert_deep_equals($changes->{destroyed}, $res->[0][1]{destroyed}); + $self->assert_str_equals($sinceState, $res->[0][1]{oldState}); + + return $res->[0][1]{newState}; + }; + + my $assert_calendars = sub + { + my ($calendars) = @_; + + my $res = $jmap->CallMethods([ + ['Calendar/get', { + accountId => 'sharer', + properties => ['id'], + }, 'R1'] + ]); + + my @wantCalendars = sort @{$calendars}; + my @haveCalendars = sort map { $_->{id} } @{$res->[0][1]{list}}; + $self->assert_deep_equals(\@wantCalendars, \@haveCalendars); + }; + + xlog $self, "Create sharer and share default calendar"; + my ($sharerJmap) = $self->create_user('sharer'); + $admin->setacl("user.sharer.#calendars.Default", cassandane => 'lrs'); + + xlog $self, "Sharee gets initial calendar state"; + my $res = $jmap->CallMethods([ + ['Calendar/get', { + accountId => 'sharer', + }, 'R1'] + ]); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + $assert_calendars->(['Default']); + + xlog $self, "Sharer creates unshared calendars A and B"; + $res = $sharerJmap->CallMethods([ + ['Calendar/set', { + create => { + calA => { + name => 'A', + }, + calB => { + name => 'B', + }, + }, + }, 'R1'], + ]); + my $calendarA = $res->[0][1]{created}{calA}{id}; + $self->assert_not_null($calendarA); + my $calendarB = $res->[0][1]{created}{calB}{id}; + $self->assert_not_null($calendarB); + + $state = $assert_changes->($state, { + created => [], + updated => [], + destroyed => [] + }); + $assert_calendars->(['Default']); + + xlog $self, "Sharer creates and shares calendar C"; + $res = $sharerJmap->CallMethods([ + ['Calendar/set', { + create => { + calC => { + name => 'C', + shareWith => { + cassandane => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayRSVP => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + my $calendarC = $res->[0][1]{created}{calC}{id}; + $self->assert_not_null($calendarC); + + $state = $assert_changes->($state, { + created => [$calendarC], + updated => [], + destroyed => [] + }); + $assert_calendars->(['Default', $calendarC]); + + xlog $self, "Sharer shares calendar A"; + $res = $sharerJmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarA => { + shareWith => { + cassandane => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarA}); + + $state = $assert_changes->($state, { + created => [], + updated => [$calendarA], # XXX this might better be in 'created' + destroyed => [] + }); + $assert_calendars->(['Default', $calendarA, $calendarC]); + + xlog $self, "Sharer shares calendar B with anyone"; + $admin->setacl("user.sharer.#calendars.$calendarB", anyone => 'lrs'); + + $state = $assert_changes->($state, { + created => [], + updated => [$calendarB], # XXX this might better be in 'created' + destroyed => [] + }); + $assert_calendars->(['Default', $calendarA, $calendarB, $calendarC]); + + xlog $self, "Sharee gets write rights on calendar C"; + $res = $sharerJmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarC => { + 'shareWith/cassandane/mayWriteAll' => JSON::true, + }, + }, + }, 'R1'], + ]); + + $state = $assert_changes->($state, { + created => [], + updated => [$calendarC], + destroyed => [] + }); + $assert_calendars->(['Default', $calendarA, $calendarB, $calendarC]); + + xlog $self, "Sharee looses write rights on calendar C"; + $res = $sharerJmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarC => { + 'shareWith/cassandane/mayWriteAll' => JSON::false, + }, + }, + }, 'R1'], + ]); + + $state = $assert_changes->($state, { + created => [], + updated => [$calendarC], + destroyed => [] + }); + $assert_calendars->(['Default', $calendarA, $calendarB, $calendarC]); + + xlog $self, "Sharer unshares calendar C"; + $res = $sharerJmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarC => { + 'shareWith/cassandane' => undef, + }, + }, + }, 'R1'], + ]); + $assert_calendars->(['Default', $calendarA, $calendarB]); + + $state = $assert_changes->($state, { + created => [], + updated => [], + destroyed => [$calendarC] + }); + + xlog $self, "Sharer unshares calendar B for anyone"; + $admin->setacl("user.sharer.#calendars.$calendarB", anyone => ''); + + $state = $assert_changes->($state, { + created => [], + updated => [], + destroyed => [$calendarB] + }); + $assert_calendars->(['Default', $calendarA]); + + xlog $self, "Sharer destroys calendar A"; + $res = $sharerJmap->CallMethods([ + ['Calendar/set', { + destroy => [$calendarA], + }, 'R1'], + ]); + $self->assert_deep_equals([$calendarA], $res->[0][1]{destroyed}); + + $state = $assert_changes->($state, { + created => [], + updated => [], + destroyed => [$calendarA] + }); + $assert_calendars->(['Default']); +} + + diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_copy_defaultalerts_mkcalendar b/cassandane/tiny-tests/JMAPCalendars/calendar_copy_defaultalerts_mkcalendar new file mode 100644 index 0000000000..03783c1bcf --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_copy_defaultalerts_mkcalendar @@ -0,0 +1,135 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_copy_defaultalerts_mkcalendar + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "No default alerts are set on default calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/get', { + ids => ['Default'], + properties => [ + 'defaultAlertsWithTime', + 'defaultAlertsWithoutTime', + ], + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_null($res->[0][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_null($res->[0][1]{list}[0]{defaultAlertsWithoutTime}); + + xlog $self, "Create calendar test1 over CalDAV"; + $res = $caldav->Request('MKCALENDAR', "/dav/calendars/user/cassandane/test1"); + + xlog $self, "New calendar does not have default alerts"; + $res = $jmap->CallMethods([ + ['Calendar/get', { + ids => ['test1'], + properties => [ + 'defaultAlertsWithTime', + 'defaultAlertsWithoutTime', + ], + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_null($res->[0][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_null($res->[0][1]{list}[0]{defaultAlertsWithoutTime}); + + xlog $self, "Set default alarms on test1"; + my $defaultAlertsWithTime1 = { + 'e905cd3a-fdb7-413a-b7fa-1cd9daad501d' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT15M', + }, + action => 'display', + } + }; + my $defaultAlertsWithoutTime1 = { + '04c2bcfa-c35c-410c-83f5-27fba35257b3' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + } + }; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + test1 => { + defaultAlertsWithTime => $defaultAlertsWithTime1, + defaultAlertsWithoutTime => $defaultAlertsWithoutTime1, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{test1}); + + xlog $self, "Create calendar test2 over CalDAV"; + $res = $caldav->Request('MKCALENDAR', "/dav/calendars/user/cassandane/test2"); + + xlog $self, "New calendar inherits default alerts from test1"; + $res = $jmap->CallMethods([ + ['Calendar/get', { + ids => ['test2'], + properties => [ + 'defaultAlertsWithTime', + 'defaultAlertsWithoutTime', + ], + }, 'R1'], + ]); + $self->assert_deep_equals($defaultAlertsWithTime1, + $res->[0][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_deep_equals($defaultAlertsWithoutTime1, + $res->[0][1]{list}[0]{defaultAlertsWithoutTime}); + + xlog $self, "Set default alarms with time on Default alert"; + my $defaultAlertsWithTime2 = { + 'e905cd3a-fdb7-413a-b7fa-1cd9daad501d' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT30M', + }, + action => 'display', + } + }; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => $defaultAlertsWithTime2, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog $self, "Create calendar test3 over CalDAV"; + $res = $caldav->Request('MKCALENDAR', "/dav/calendars/user/cassandane/test3"); + + xlog $self, "New calendar inherits default alerts from Default"; + $res = $jmap->CallMethods([ + ['Calendar/get', { + ids => ['test3'], + properties => [ + 'defaultAlertsWithTime', + 'defaultAlertsWithoutTime', + ], + }, 'R1'], + ]); + $self->assert_deep_equals($defaultAlertsWithTime2, + $res->[0][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_null($res->[0][1]{list}[0]{defaultAlertsWithoutTime}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_defaultalerts_synctoken b/cassandane/tiny-tests/JMAPCalendars/calendar_defaultalerts_synctoken new file mode 100644 index 0000000000..c1d7027c86 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_defaultalerts_synctoken @@ -0,0 +1,115 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_defaultalerts_synctoken + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + xlog "Set default alerts on calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Create events with and without default alerts"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + offset => "-PT10M", + }, + }, + }, + }, + 2 => { + uid => 'eventuid2local', + calendarIds => { + Default => JSON::true, + }, + title => "event2", + start => "2020-01-21T13:00:00", + duration => "PT1H", + timeZone => "Europe/Vienna", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ]); + my $event1Uid = $res->[0][1]{created}{1}{uid}; + $self->assert_not_null($event1Uid); + my $event2Uid = $res->[0][1]{created}{2}{uid}; + $self->assert_not_null($event2Uid); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + ]; + + xlog "Fetch sync token"; + my $Cal = $CalDAV->GetCalendar('Default'); + my $syncToken = $Cal->{syncToken}; + $self->assert_not_null($syncToken); + + xlog "Update default alerts on calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert2 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT15M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Sync CalDAV changes"; + my ($adds, $removes, $errors) = $CalDAV->SyncEvents('Default', syncToken => $syncToken); + + $self->assert_num_equals(1, scalar @{$adds}); + $self->assert_str_equals($adds->[0]{uid}, $event2Uid); + $self->assert_deep_equals($removes, []); + $self->assert_deep_equals($errors, []); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_defaultalerts_synctoken_shared b/cassandane/tiny-tests/JMAPCalendars/calendar_defaultalerts_synctoken_shared new file mode 100644 index 0000000000..678378a8c0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_defaultalerts_synctoken_shared @@ -0,0 +1,156 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_defaultalerts_synctoken_shared + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + xlog "Create other user and share calendar"; + my $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->create_user("other"); + $admintalk->setacl("user.cassandane.#calendars.Default", "other", "lrsiwntex") or die; + my $service = $self->{instance}->get_service("http"); + my $otherJMAP = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + + xlog "Set default alerts on calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Create events without default alerts"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + offset => "-PT10M", + }, + }, + }, + }, + 2 => { + uid => 'eventuid2local', + calendarIds => { + Default => JSON::true, + }, + title => "event2", + start => "2020-01-21T13:00:00", + duration => "PT1H", + timeZone => "Europe/Vienna", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ]); + my $event1Uid = $res->[0][1]{created}{1}{uid}; + $self->assert_not_null($event1Uid); + my $event2Uid = $res->[0][1]{created}{2}{uid}; + $self->assert_not_null($event2Uid); + my $event2Id = $res->[0][1]{created}{2}{id}; + $self->assert_not_null($event2Id); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + ]; + + xlog "Set useDefaultAlerts to force per-user data split"; + $res = $otherJMAP->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $event2Id => { + color => 'green', + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ], $using); + $self->assert(exists $res->[0][1]{updated}{$event2Id}); + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $event2Id => { + color => 'blue', + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ], $using); + $self->assert(exists $res->[0][1]{updated}{$event2Id}); + + xlog "Fetch sync token"; + my $Cal = $CalDAV->GetCalendar('Default'); + my $syncToken = $Cal->{syncToken}; + $self->assert_not_null($syncToken); + + xlog "Update default alerts on calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert2 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT15M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Sync CalDAV changes"; + my ($adds, $removes, $errors) = $CalDAV->SyncEvents('Default', syncToken => $syncToken); + + $self->assert_num_equals(1, scalar @{$adds}); + $self->assert_str_equals($adds->[0]{uid}, $event2Uid); + $self->assert_deep_equals($removes, []); + $self->assert_deep_equals($errors, []); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_get b/cassandane/tiny-tests/JMAPCalendars/calendar_get new file mode 100644 index 0000000000..e74df730f4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_get @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_get + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $id = $caldav->NewCalendar({ name => "calname", color => "aqua"}); + my $unknownId = "foo"; + + xlog $self, "get existing calendar"; + my $res = $jmap->CallMethods([['Calendar/get', {ids => [$id]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Calendar/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{list}})); + $self->assert_str_equals($id, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals('aqua', $res->[0][1]{list}[0]{color}); + + xlog $self, "get existing calendar with select properties"; + $res = $jmap->CallMethods([['Calendar/get', { ids => [$id], properties => ["name"] }, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{list}})); + $self->assert_str_equals($id, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals("calname", $res->[0][1]{list}[0]{name}); + $self->assert_null($res->[0][1]{list}[0]{color}); + + xlog $self, "get unknown calendar"; + $res = $jmap->CallMethods([['Calendar/get', {ids => [$unknownId]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(0, scalar(@{$res->[0][1]{list}})); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{notFound}})); + $self->assert_str_equals($unknownId, $res->[0][1]{notFound}[0]); + + xlog $self, "get all calendars"; + $res = $jmap->CallMethods([['Calendar/get', {ids => undef}, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(2, scalar(@{$res->[0][1]{list}})); + $res = $jmap->CallMethods([['Calendar/get', {}, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(2, scalar(@{$res->[0][1]{list}})); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_get_default b/cassandane/tiny-tests/JMAPCalendars/calendar_get_default new file mode 100644 index 0000000000..b99456f4a9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_get_default @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_get_default + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # XXX - A previous CalDAV test might have created the default + # calendar already. To make this test self-sufficient, we need + # to create a test user just for this test. How? + xlog $self, "get default calendar"; + my $res = $jmap->CallMethods([['Calendar/get', {ids => ["Default"]}, "R1"]]); + $self->assert_str_equals("Default", $res->[0][1]{list}[0]{id}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_get_freebusy_only b/cassandane/tiny-tests/JMAPCalendars/calendar_get_freebusy_only new file mode 100644 index 0000000000..7b7dfdf938 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_get_freebusy_only @@ -0,0 +1,56 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_get_freebusy_only + :min_version_3_5 :needs_component_jmap :JMAPExtensions :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create other user"; + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user.other'); + $admintalk->setacl('user.other', admin => 'lrswipkxtecdan') or die; + $admintalk->setacl('user.other', other => 'lrswipkxtecdn') or die; + + my $service = $self->{instance}->get_service("http"); + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $otherJmap->CallMethods([ + ['Calendar/get', { + properties => ['id'], + }, 'R1'], + ]); + $admintalk->setacl('user.other.#calendars.Default', cassandane => 'l9') or die; + + $res = $jmap->ua->get($jmap->uri(), { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }); + $self->assert_str_equals('200', $res->{status}); + my $session = eval { decode_json($res->{content}) }; + my $capabilities = $session->{accounts}{other}{accountCapabilities}; + $self->assert_not_null($capabilities->{'https://cyrusimap.org/ns/jmap/calendars'}); + + $res = $jmap->CallMethods([ + ['Calendar/get', { + accountId => 'other', + properties => ['id'], + }, 'R1'], + ]); + $self->assert_deep_equals([], $res->[0][1]{list}); + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_get_shared b/cassandane/tiny-tests/JMAPCalendars/calendar_get_shared new file mode 100644 index 0000000000..abd127a4d1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_get_shared @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_get_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admintalk = $self->{adminstore}->get_client(); + + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + xlog $self, "create calendar"; + my $CalendarId = $mantalk->NewCalendar({name => 'Manifold Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId", "cassandane" => 'lr') or die; + + xlog $self, "get calendar"; + my $res = $jmap->CallMethods([['Calendar/get', {accountId => 'manifold'}, "R1"]]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert_str_equals("Manifold Calendar", $res->[0][1]{list}[0]->{name}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]->{myRights}{mayWriteAll}); + my $id = $res->[0][1]{list}[0]->{id}; + + xlog $self, "refetch calendar"; + $res = $jmap->CallMethods([['Calendar/get', {accountId => 'manifold', ids => [$id]}, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{list}[0]->{id}); + + xlog $self, "create another shared calendar"; + my $CalendarId2 = $mantalk->NewCalendar({name => 'Manifold Calendar 2'}); + $self->assert_not_null($CalendarId2); + $admintalk->setacl("user.manifold.#calendars.$CalendarId2", "cassandane" => 'lr') or die; + + xlog $self, "remove access rights to calendar"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId", "cassandane" => '') or die; + + xlog $self, "refetch calendar (should fail)"; + $res = $jmap->CallMethods([['Calendar/get', {accountId => 'manifold', ids => [$id]}, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{notFound}[0]); + + xlog $self, "remove access rights to all shared calendars"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId2", "cassandane" => '') or die; + + xlog $self, "refetch calendar (should fail)"; + $res = $jmap->CallMethods([['Calendar/get', {accountId => 'manifold', ids => [$id]}, "R1"]]); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("accountNotFound", $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set b/cassandane/tiny-tests/JMAPCalendars/calendar_set new file mode 100644 index 0000000000..37666cb725 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1 + }}}, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Calendar/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{created}); + + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get calendar $id"; + $res = $jmap->CallMethods([['Calendar/get', {ids => [$id]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{list}})); + $self->assert_str_equals($id, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals('foo', $res->[0][1]{list}[0]{name}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{isVisible}); + + xlog $self, "update calendar $id"; + $res = $jmap->CallMethods([ + ['Calendar/set', {update => {"$id" => { + name => "bar", + isVisible => \0 + }}}, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "get calendar $id"; + $res = $jmap->CallMethods([['Calendar/get', {ids => [$id]}, "R1"]]); + $self->assert_str_equals('bar', $res->[0][1]{list}[0]{name}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]{isVisible}); + + xlog $self, "destroy calendar $id"; + $res = $jmap->CallMethods([['Calendar/set', {destroy => ["$id"]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{destroyed}); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); + + xlog $self, "get calendar $id"; + $res = $jmap->CallMethods([['Calendar/get', {ids => [$id]}, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_badname b/cassandane/tiny-tests/JMAPCalendars/calendar_set_badname new file mode 100644 index 0000000000..7472b56bad --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_badname @@ -0,0 +1,26 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_badname + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create calendar with excessively long name"; + # Exceed the maximum allowed 256 byte length by 1. + my $badname = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum tincidunt risus quis urna aliquam sollicitudin. Pellentesque aliquet nisl ut neque viverra pellentesque. Donec tincidunt eros at ante malesuada porta. Nam sapien arcu, vehicula non posuere."; + + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => $badname, color => "aqua", + sortOrder => 1, isVisible => \1 + }}}, "R1"] + ]); + $self->assert_not_null($res); + my $errType = $res->[0][1]{notCreated}{"1"}{type}; + my $errProp = $res->[0][1]{notCreated}{"1"}{properties}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals(["name"], $errProp); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_defaultalerts b/cassandane/tiny-tests/JMAPCalendars/calendar_set_defaultalerts new file mode 100644 index 0000000000..3782730c7c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_defaultalerts @@ -0,0 +1,163 @@ +#!perl +use Cassandane::Tiny; +use Data::UUID; + +sub test_calendar_set_defaultalerts + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + my $alert1Id = '589c1b45-ca59-4072-90fb-93c41491e484'; + my $alert2Id = '899fd3e7-c0a0-442d-a04f-725c58728afb'; + + my $defaultAlertsWithTime = { + $alert1Id => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT1H', + }, + action => 'email', + }, + $alert2Id => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }, + }; + + my $alert3Id = '2905eb80-48af-4e0f-85cc-de58155a2152'; + + my $defaultAlertsWithoutTime = { + $alert3Id => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }, + }; + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + 1 => { + name => 'test', + color => 'blue', + defaultAlertsWithTime => $defaultAlertsWithTime, + defaultAlertsWithoutTime => $defaultAlertsWithoutTime, + } + } + }, 'R1'], + ['Calendar/get', { + ids => ['#1'], + properties => ['defaultAlertsWithTime', 'defaultAlertsWithoutTime'], + }, 'R2'] + ]); + my $calendarId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($calendarId); + $self->assert_deep_equals($defaultAlertsWithTime, + $res->[1][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_deep_equals($defaultAlertsWithoutTime, + $res->[1][1]{list}[0]{defaultAlertsWithoutTime}); + + my $alert4Id = '5e7b49d3-fcef-484f-8d31-f9fb178ebc65'; + my $alert4 = { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT30M', + }, + action => 'display', + }; + + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + "defaultAlertsWithTime/$alert1Id" => undef, + "defaultAlertsWithTime/$alert4Id" => $alert4, + } + } + }, 'R1'], + ['Calendar/get', { + ids => [$calendarId], + properties => ['defaultAlertsWithTime', 'defaultAlertsWithoutTime'], + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarId}); + + delete $defaultAlertsWithTime->{$alert1Id}; + $defaultAlertsWithTime->{$alert4Id} = $alert4; + $self->assert_deep_equals($defaultAlertsWithTime, + $res->[1][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_deep_equals($defaultAlertsWithoutTime, + $res->[1][1]{list}[0]{defaultAlertsWithoutTime}); + + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + "defaultAlertsWithoutTime/$alert3Id/trigger/offset" => '-PT5M', + } + } + }, 'R1'], + ['Calendar/get', { + ids => [$calendarId], + properties => ['defaultAlertsWithTime', 'defaultAlertsWithoutTime'], + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarId}); + + $defaultAlertsWithoutTime->{$alert3Id}{trigger}{offset} = '-PT5M'; + $self->assert_deep_equals($defaultAlertsWithTime, + $res->[1][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_deep_equals($defaultAlertsWithoutTime, + $res->[1][1]{list}[0]{defaultAlertsWithoutTime}); + + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + defaultAlertsWithTime => undef, + } + } + }, 'R1'], + ['Calendar/get', { + ids => [$calendarId], + properties => ['defaultAlertsWithTime', 'defaultAlertsWithoutTime'], + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarId}); + $self->assert_null($res->[1][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_deep_equals($defaultAlertsWithoutTime, + $res->[1][1]{list}[0]{defaultAlertsWithoutTime}); + + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + defaultAlertsWithoutTime => undef, + } + } + }, 'R1'], + ['Calendar/get', { + ids => [$calendarId], + properties => ['defaultAlertsWithTime', 'defaultAlertsWithoutTime'], + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarId}); + $self->assert_null($res->[1][1]{list}[0]{defaultAlertsWithTime}); + $self->assert_null($res->[1][1]{list}[0]{defaultAlertsWithoutTime}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_defaultalerts_shared b/cassandane/tiny-tests/JMAPCalendars/calendar_set_defaultalerts_shared new file mode 100644 index 0000000000..6d87b839a0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_defaultalerts_shared @@ -0,0 +1,376 @@ +#!perl +use Cassandane::Tiny; +use Data::UUID; + +sub test_calendar_set_defaultalerts_shared + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $t = $self->create_test; + + my $ownerJmap = $t->{owner}{jmap}; + my $shareeJmap = $t->{sharee}{jmap}; + + $self->assert_shared_defaultalerts($t); + + xlog $self, "Owner sets default alarms"; + + my $alertWithTimeOwnerId = '4c08cb1d-60e0-46e0-9cc1-9622b7a820ed'; + $t->{owner}->{defaultAlertsWithTime} = { + $alertWithTimeOwnerId => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'email', + }, + }; + + my $alertWithoutTimeOwnerId = '3f8c29c3-d305-4c19-adb6-57cc3308918c'; + $t->{owner}->{defaultAlertsWithoutTime} = { + $alertWithoutTimeOwnerId => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }, + }; + $ownerJmap->CallMethods([ + ['Calendar/set', { + accountId => 'owner', + update => { + Default => { + defaultAlertsWithTime => + $t->{owner}->{defaultAlertsWithTime}, + defaultAlertsWithoutTime => + $t->{owner}->{defaultAlertsWithoutTime}, + }, + }, + }, 'R1'], + ]); + + $self->assert_shared_defaultalerts($t); + + xlog $self, 'Sharee sets default alarms'; + my $alertWithTimeShareeId = 'b61e5b53-8ea2-46f4-949d-7b49734ba4d3'; + $t->{sharee}->{defaultAlertsWithTime} = { + $alertWithTimeShareeId => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'email', + }, + }; + my $alertWithoutTimeShareeId = '97d7c889-272f-4ce3-8d21-4a32b17ecece'; + $t->{sharee}->{defaultAlertsWithoutTime} = { + $alertWithoutTimeShareeId => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }, + }; + $shareeJmap->CallMethods([ + ['Calendar/set', { + accountId => 'owner', + update => { + Default => { + defaultAlertsWithTime => + $t->{sharee}->{defaultAlertsWithTime}, + defaultAlertsWithoutTime => + $t->{sharee}->{defaultAlertsWithoutTime}, + }, + }, + }, 'R1'], + ]); + + $self->assert_shared_defaultalerts($t); + + xlog $self, 'Owner removes default alarms'; + $t->{owner}->{defaultAlertsWithTime} = undef; + $t->{owner}->{defaultAlertsWithoutTime} = undef; + $ownerJmap->CallMethods([ + ['Calendar/set', { + accountId => 'owner', + update => { + Default => { + defaultAlertsWithTime => + $t->{owner}->{defaultAlertsWithTime}, + defaultAlertsWithoutTime => + $t->{owner}->{defaultAlertsWithoutTime}, + }, + }, + }, 'R1'], + ]); + + $self->assert_shared_defaultalerts($t); + + xlog $self, 'Sharee removes default alarms'; + $t->{sharee}->{defaultAlertsWithTime} = undef; + $t->{sharee}->{defaultAlertsWithoutTime} = undef; + $shareeJmap->CallMethods([ + ['Calendar/set', { + accountId => 'owner', + update => { + Default => { + defaultAlertsWithTime => + $t->{sharee}->{defaultAlertsWithTime}, + defaultAlertsWithoutTime => + $t->{sharee}->{defaultAlertsWithoutTime}, + }, + }, + }, 'R1'], + ]); + + $self->assert_shared_defaultalerts($t); +} + +sub _can_match { + my $event = shift; + my $want = shift; + + # I wrote a really good one of these for Caldav, but this will do for now + foreach my $key (keys %$want) { + return 0 if not exists $event->{$key}; + return 0 if $event->{$key} ne $want->{$key}; + } + + return 1; +} + +sub assert_alarm_notifs { + my $self = shift; + my @want = @_; + # pick first calendar alarm from notifications + my $data = $self->{instance}->getnotify(); + if ($self->{replica}) { + my $more = $self->{replica}->getnotify(); + push @$data, @$more; + } + my @events; + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @events, $e; + } + } + } + + my @left; + while (my $event = shift @events) { + my $found = 0; + my @newwant; + foreach my $data (@want) { + if (not $found and _can_match($event, $data)) { + $found = 1; + } + else { + push @newwant, $data; + } + } + if (not $found) { + push @left, $event; + } + @want = @newwant; + } + + if (@want or @left) { + my $dump = Data::Dumper->Dump([\@want, \@left], [qw(want left)]); + $self->assert_equals(0, scalar @want, + "expected events were not received:\n$dump"); + $self->assert_equals(0, scalar @left, + "unexpected extra events were received:\n$dump"); + } +} + +sub assert_shared_defaultalerts +{ + my ($self, $t) = @_; + + for my $who (qw/owner sharee/) { + my $jmap = $t->{$who}->{jmap}; + + xlog $self, "Assert alarms for $who"; + my $res = $jmap->CallMethods([ + ['Calendar/get', { + accountId => 'owner', + properties => [ + 'defaultAlertsWithTime', + 'defaultAlertsWithoutTime', + ], + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{list}[0]); + + my $defaultAlertsWithTime = + $t->{$who}->{defaultAlertsWithTime}; + if ($defaultAlertsWithTime) { + $self->assert_deep_equals($defaultAlertsWithTime, + $res->[0][1]{list}[0]{defaultAlertsWithTime}); + } else { + $self->assert_null( + $res->[0][1]{list}[0]{defaultAlertsWithTime}); + } + + my $defaultAlertsWithoutTime = + $t->{$who}->{defaultAlertsWithoutTime}; + if ($defaultAlertsWithoutTime) { + $self->assert_deep_equals($defaultAlertsWithoutTime, + $res->[0][1]{list}[0]{defaultAlertsWithoutTime}); + } else { + $self->assert_null( + $res->[0][1]{list}[0]{defaultAlertsWithoutTime}); + } + + xlog 'Assert default alarms in CalDAV GET'; + my $caldav = $t->{$who}->{caldav}; + my $xhref = $t->{$who}->{xhref}; + + $res = $caldav->Request('GET', $xhref); + if ($defaultAlertsWithTime) { + $self->assert_matches(qr/BEGIN:VALARM/, $res->{content}); + my $uid = (values %{$defaultAlertsWithTime})->{uid}; + $self->assert_matches(qr/UID:$uid/, $res->{content}); + } else { + $self->assert(not $res->{content} =~ qr/BEGIN:VALARM/); + } + } + + xlog 'Assert calalarmd alarms'; + my @alarms; + for my $who (qw/owner sharee/) { + my $defaultAlertsWithTime = + $t->{$who}->{defaultAlertsWithTime}; + + if ($defaultAlertsWithTime) { + my $alertid = (keys %{$defaultAlertsWithTime})[0]; + my $event_start = $t->{start}->strftime('%Y%m%dT%H%M%S'); + push (@alarms, { + start => $event_start, alertId => $alertid, userId => $who + }); + } + } + $self->{instance}->getnotify(); + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $t->{now}->epoch() ); + $self->assert_alarm_notifs(@alarms); + + xlog 'Move clock on week forward'; + $t->{now}->add(DateTime::Duration->new(days =>7)); + $t->{start}->add(DateTime::Duration->new(days =>7)); +} + +sub create_test +{ + my ($self) = @_; + + my ($ownerJmap, $ownerCaldav) = $self->create_user('owner'); + my ($shareeJmap, $shareeCaldav) = $self->create_user('sharee'); + + my $now = DateTime->now(); + $now->set_time_zone('Etc/UTC'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $start = $now->clone(); + $start->add(DateTime::Duration->new(seconds => 2)); + + xlog $self, 'Create event and share calendar with sharee'; + my $res = $ownerJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'owner', + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'ed326178-43dc-474d-a496-6cba057c9afe', + title => 'test', + start => $start->strftime('%Y-%m-%dT%H:%M:%S'), + timeZone => 'Europe/Vienna', + duration => 'PT15M', + recurrenceRules => [{ + '@type' => 'RecurrenceRule', + frequency => 'weekly', + }], + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ['Calendar/set', { + accountId => 'owner', + update => { + Default => { + shareWith => { + 'sharee' => { + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayRSVP => JSON::true, + }, + }, + }, + }, + }, 'R2'], + ]); + + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + my $ownerHref = $res->[0][1]{created}{event1}{'x-href'}; + $self->assert_not_null($ownerHref); + $self->assert(exists $res->[1][1]{updated}{Default}); + + xlog $self, 'Sharee sets useDefaultAlerts=true'; + my $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'owner', + update => { + $eventId => { + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + accountId => 'owner', + ids => [$eventId], + properties => ['x-href'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + my $shareeHref = $res->[1][1]{list}[0]{'x-href'}; + $self->assert_not_null($shareeHref); + + return { + now => $now, + start => $start, + owner => { + jmap => $ownerJmap, + caldav => $ownerCaldav, + defaultAlertsWithTime => undef, + defaultAlertsWithoutTime => undef, + xhref => $ownerHref, + }, + sharee => { + jmap => $shareeJmap, + caldav => $shareeCaldav, + defaultAlertsWithTime => undef, + defaultAlertsWithoutTime => undef, + xhref => $shareeHref, + }, + }; +} + diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_destroy_events b/cassandane/tiny-tests/JMAPCalendars/calendar_set_destroy_events new file mode 100644 index 0000000000..43ef2701ee --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_destroy_events @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_destroy_events + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + xlog "Create calendar and event"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + 1 => { + name => 'test', + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + create => { + 2 => { + uid => 'eventuid1local', + calendarIds => { + '#1' => JSON::true, + }, + title => "event1", + start => "2020-03-30T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + }, + }, + }, 'R2'], + ]); + my $calendarId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($calendarId); + my $eventId = $res->[1][1]{created}{2}{id}; + $self->assert_not_null($eventId); + + xlog "Destroy calendar (with and without onDestroyEvents)"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + destroy => [$calendarId], + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['id'], + }, 'R2'], + ['Calendar/set', { + destroy => [$calendarId], + onDestroyRemoveEvents => JSON::true, + }, 'R3'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['id'], + }, 'R2'], + ]); + $self->assert_str_equals('calendarHasEvents', + $res->[0][1]{notDestroyed}{$calendarId}{type}); + $self->assert_str_equals($eventId, $res->[1][1]{list}[0]{id}); + $self->assert_deep_equals([$calendarId], $res->[2][1]{destroyed}); + $self->assert_deep_equals([$eventId], $res->[3][1]{notFound}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_destroyspecials b/cassandane/tiny-tests/JMAPCalendars/calendar_set_destroyspecials new file mode 100644 index 0000000000..3cf46c0669 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_destroyspecials @@ -0,0 +1,38 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_destroyspecials + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my @specialIds = ["Inbox", "Outbox", "Default", "Attachments"]; + + xlog $self, "destroy special calendars"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { destroy => @specialIds }, "R1"] + ]); + $self->assert_not_null($res); + + my $errType; + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min >= 5)) { + # Default calendar may be destroyed from 3.5+ + $self->assert_deep_equals(['Default'], $res->[0][1]{destroyed}); + } + else { + # but previously, this was forbidden + $errType = $res->[0][1]{notDestroyed}{"Default"}{type}; + $self->assert_str_equals("isDefault", $errType); + } + + $errType = $res->[0][1]{notDestroyed}{"Inbox"}{type}; + $self->assert_str_equals("notFound", $errType); + $errType = $res->[0][1]{notDestroyed}{"Outbox"}{type}; + $self->assert_str_equals("notFound", $errType); + $errType = $res->[0][1]{notDestroyed}{"Attachments"}{type}; + $self->assert_str_equals("notFound", $errType); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_error b/cassandane/tiny-tests/JMAPCalendars/calendar_set_error new file mode 100644 index 0000000000..89f22d16cb --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_error @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_error + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create calendar with missing mandatory attributes"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => {}}}, "R1"] + ]); + $self->assert_not_null($res); + my $errType = $res->[0][1]{notCreated}{"1"}{type}; + my $errProp = $res->[0][1]{notCreated}{"1"}{properties}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals([ "name" ], $errProp); + + xlog $self, "create calendar with invalid optional attributes"; + $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", color => "coral", + sortOrder => 2, isVisible => \1, + myRights => { + mayReadFreeBusy => \0, mayReadItems => \0, + mayAddItems => \0, mayModifyItems => \0, + mayRemoveItems => \0, mayRename => \0, + mayDelete => \0 + } + }}}, "R1"] + ]); + $errType = $res->[0][1]{notCreated}{"1"}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals(['myRights'], $res->[0][1]{notCreated}{"1"}{properties}); + + xlog $self, "update unknown calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { update => { "unknown" => { + name => "foo" + }}}, "R1"] + ]); + $errType = $res->[0][1]{notUpdated}{"unknown"}{type}; + $self->assert_str_equals("notFound", $errType); + + xlog $self, "create calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", + sortOrder => 2, + isVisible => \1 + }}}, "R1"] + ]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "update calendar with immutable optional attributes"; + $res = $jmap->CallMethods([ + ['Calendar/set', { update => { $id => { + myRights => { + mayReadFreeBusy => \0, mayReadItems => \0, + mayAddItems => \0, mayModifyItems => \0, + mayRemoveItems => \0, mayRename => \0, + mayDelete => \0 + } + }}}, "R1"] + ]); + $errType = $res->[0][1]{notUpdated}{$id}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals(['myRights'], $res->[0][1]{notUpdated}{$id}{properties}); + + xlog $self, "destroy unknown calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', {destroy => ["unknown"]}, "R1"] + ]); + $errType = $res->[0][1]{notDestroyed}{"unknown"}{type}; + $self->assert_str_equals("notFound", $errType); + + xlog $self, "destroy calendar $id"; + $res = $jmap->CallMethods([['Calendar/set', {destroy => ["$id"]}, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_inherit_defaultalerts b/cassandane/tiny-tests/JMAPCalendars/calendar_set_inherit_defaultalerts new file mode 100644 index 0000000000..2321664597 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_inherit_defaultalerts @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; +use Data::UUID; + +sub test_calendar_set_inherit_defaultalerts + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $alert1Id = '589c1b45-ca59-4072-90fb-93c41491e484'; + + my $defaultAlertsWithTime = { + $alert1Id => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT1H', + }, + action => 'email', + }, + }; + + my $alert2Id = '899fd3e7-c0a0-442d-a04f-725c58728afb'; + + my $defaultAlertsWithoutTime = { + $alert2Id => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }, + }; + + xlog $self, "Create calendar1 with default alerts with time"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendar1 => { + name => 'calendar1', + defaultAlertsWithTime => $defaultAlertsWithTime, + } + } + }, 'R1'], + ['Calendar/get', { + ids => ['#calendar1'], + properties => ['defaultAlertsWithTime', 'defaultAlertsWithoutTime'], + }, 'R2'] + ]); + + $self->assert_deep_equals($defaultAlertsWithTime, + $res->[1][1]{list}[0]{defaultAlertsWithTime}); + + xlog $self, "Assert no default alerts without time were inherited"; + $self->assert_null($res->[1][1]{list}[0]{defaultAlertsWithoutTime}); + + xlog $self, "Create calendar2 with default alerts without time"; + + $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendar2 => { + name => 'calendar2', + defaultAlertsWithoutTime => $defaultAlertsWithoutTime, + } + } + }, 'R1'], + ['Calendar/get', { + ids => ['#calendar2'], + properties => ['defaultAlertsWithTime', 'defaultAlertsWithoutTime'], + }, 'R2'] + ]); + + $self->assert_deep_equals($defaultAlertsWithoutTime, + $res->[1][1]{list}[0]{defaultAlertsWithoutTime}); + + xlog $self, "Assert no default alerts with time were inherited"; + $self->assert_null($res->[1][1]{list}[0]{defaultAlertsWithTime}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_issubscribed b/cassandane/tiny-tests/JMAPCalendars/calendar_set_issubscribed new file mode 100644 index 0000000000..1898b8b77c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_issubscribed @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_issubscribed + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Create calendar + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + '1' => { + name => 'A', + color => 'blue', + } + }, + }, 'R1'], + ['Calendar/get', { + ids => ['#1'], + properties => ['isSubscribed'] + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{created}{1}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{isSubscribed}); + my $id = $res->[0][1]{created}{"1"}{id}; + + # Can't unsubscribe own calendars + $res = $jmap->CallMethods([ + ['Calendar/set', + { update => { + $id => { + isSubscribed => JSON::false, + } + } + }, "R1"], + ['Calendar/get', { + ids => [$id], + properties => ['isSubscribed'] + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{notUpdated}{$id}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{isSubscribed}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_issubscribed_shared b/cassandane/tiny-tests/JMAPCalendars/calendar_set_issubscribed_shared new file mode 100644 index 0000000000..ba15b16ce0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_issubscribed_shared @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_issubscribed_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.other"); + + $admintalk->setacl("user.other", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.other", other => 'lrswipkxtecdn'); + + xlog $self, "create and share default calendar"; + my $othertalk = Net::CalDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + $admintalk->setacl('user.other.#calendars.Default', "cassandane" => 'lr') or die; + + # Get calendar + my $res = $jmap->CallMethods([ + ['Calendar/get', { + accountId => 'other', + properties => ['isSubscribed'] + }, 'R!'], + ]); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]{isSubscribed}); + my $id = $res->[0][1]{list}[0]{id}; + + # Toggle isSubscribed on read-only shared calendar + $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'other', + update => { + $id => { + isSubscribed => JSON::true, + } + } + }, "R1"], + ['Calendar/get', { + accountId => 'other', + ids => [$id], + properties => ['isSubscribed'] + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$id}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{isSubscribed}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_shared b/cassandane/tiny-tests/JMAPCalendars/calendar_set_shared new file mode 100644 index 0000000000..4eedda8ee0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_shared @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + + my $service = $self->{instance}->get_service("http"); + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + # Call CalDAV once to create manifold's calendar home #calendars + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "share calendar home read-only to user"; + $admintalk->setacl("user.manifold.#calendars", cassandane => 'lr') or die; + + xlog $self, "create calendar (should fail)"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'manifold', + create => { "1" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1 + }}}, "R1"] + ]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert_str_equals("accountReadOnly", $res->[0][1]{notCreated}{1}{type}); + + xlog $self, "share calendar home read-writable to user"; + $admintalk->setacl("user.manifold.#calendars", cassandane => 'lrswipkxtecdn') or die; + + xlog $self, "create calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'manifold', + create => { "1" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1 + }}}, "R1"] + ]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + my $CalendarId = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($CalendarId); + + xlog $self, "share calendar read-only to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId", "cassandane" => 'lr') or die; + + xlog $self, "update calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'manifold', + update => {$CalendarId => { + name => "bar", + isVisible => \0 + }}}, "R1"] + ]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert(exists $res->[0][1]{updated}{$CalendarId}); + + xlog $self, "destroy calendar $CalendarId (should fail)"; + $res = $jmap->CallMethods([['Calendar/set', {accountId => 'manifold', destroy => [$CalendarId]}, "R1"]]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert_str_equals("accountReadOnly", $res->[0][1]{notDestroyed}{$CalendarId}{type}); + + xlog $self, "share read-writable to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "destroy calendar $CalendarId"; + $res = $jmap->CallMethods([['Calendar/set', {accountId => 'manifold', destroy => [$CalendarId]}, "R1"]]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert_str_equals($CalendarId, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_shared_sort_order b/cassandane/tiny-tests/JMAPCalendars/calendar_set_shared_sort_order new file mode 100644 index 0000000000..673092f208 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_shared_sort_order @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_shared_sort_order + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + my ($maj, $min) = Cassandane::Instance->get_version(); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + xlog $self, "create calendars"; + my $CalendarId1 = $mantalk->NewCalendar({name => 'Manifold Calendar1'}); + $self->assert_not_null($CalendarId1); + my $CalendarId2 = $mantalk->NewCalendar({name => 'Manifold Calendar2'}); + $self->assert_not_null($CalendarId2); + + xlog $self, "share $CalendarId1 and $CalendarId2 read-only to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId1", "cassandane" => 'lrw') or die; + $admintalk->setacl("user.manifold.#calendars.$CalendarId2", "cassandane" => 'lrw') or die; + + xlog $self, "Verify shared account is NOT isReadOnly"; + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + $self->assert_str_equals('200', $RawResponse->{status}); + my $session = eval { decode_json($RawResponse->{content}) }; + $self->assert_equals(JSON::false, $session->{accounts}{manifold}{isReadOnly}); + + xlog $self, "Set sortOrder on a calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'manifold', + update => { + $CalendarId1 => { + sortOrder => 2 + } + } + }, "R1"] + ]); + $self->assert(exists $res->[0][1]{updated}{$CalendarId1}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_sharewith b/cassandane/tiny-tests/JMAPCalendars/calendar_set_sharewith new file mode 100644 index 0000000000..148e157909 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_sharewith @@ -0,0 +1,213 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_sharewith + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + # need to version-gate jmap features that aren't in 3.5... + my ($maj, $min) = Cassandane::Instance->get_version(); + + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.master"); + + my $mastalk = Net::CalDAVTalk->new( + user => "master", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.master", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.master", master => 'lrswipkxtecdn'); + + xlog $self, "create calendar"; + my $CalendarId = $mastalk->NewCalendar({name => 'Shared Calendar'}); + $self->assert_not_null($CalendarId); + + xlog $self, "share to user with permission to share"; + $admintalk->setacl("user.master.#calendars.$CalendarId", "cassandane" => 'lrswipkxtecdan9') or die; + + xlog $self, "create third account"; + $admintalk->create("user.manifold"); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + xlog $self, "and a forth"; + $admintalk->create("user.paraphrase"); + + $admintalk->setacl("user.paraphrase", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.paraphrase", paraphrase => 'lrswipkxtecdn'); + + # Call CalDAV once to create manifold's calendar home #calendars + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + # Call CalDAV once to create paraphrase's calendar home #calendars + my $partalk = Net::CalDAVTalk->new( + user => "paraphrase", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "sharee gives third user access to shared calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'master', + update => { "$CalendarId" => { + "shareWith/manifold" => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + }, + "shareWith/paraphrase" => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayWriteAll => JSON::true, + }, + }}}, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Calendar/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "fetch invites"; + my ($adds) = $mantalk->SyncEventLinks("/dav/notifications/user/manifold"); + $self->assert_equals(1, scalar %$adds); + ($adds) = $partalk->SyncEventLinks("/dav/notifications/user/paraphrase"); + $self->assert_equals(1, scalar %$adds); + + xlog $self, "check ACL"; + my $acl = $admintalk->getacl("user.master.#calendars.$CalendarId"); + my %map = @$acl; + $self->assert_str_equals('lrswipkxtecdan9', $map{cassandane}); + $self->assert_str_equals('lrw59', $map{manifold}); + $self->assert_str_equals('lrswitedn79', $map{paraphrase}); + + xlog $self, "check Outbox ACL"; + $acl = $admintalk->getacl("user.master.#calendars.Outbox"); + %map = @$acl; + $self->assert_null($map{manifold}); # we don't create Outbox ACLs for read-only + $self->assert_str_equals('78', $map{paraphrase}); + + xlog $self, "check Principal ACL"; + $acl = $admintalk->getacl("user.master.#calendars"); + %map = @$acl; + # both users get ACLs on the Inbox + $self->assert_str_equals('lr', $map{manifold}); + $self->assert_str_equals('lr', $map{paraphrase}); + + my $Name = $mantalk->GetProps('/dav/principals/user/master', 'D:displayname'); + $self->assert_str_equals('master', $Name); + $Name = $partalk->GetProps('/dav/principals/user/master', 'D:displayname'); + $self->assert_str_equals('master', $Name); + + if ($maj > 3 || ($maj == 3 && $min >= 4)) { + xlog $self, "check ACL on JMAP upload folder"; + $acl = $admintalk->getacl("user.master.#jmap"); + %map = @$acl; + $self->assert_str_equals('lrswitedn', $map{cassandane}); + $self->assert_str_equals('lrw', $map{manifold}); + $self->assert_str_equals('lrswitedn', $map{paraphrase}); + } + + xlog $self, "Clear initial syslog"; + $self->{instance}->getsyslog(); + + xlog $self, "Update sharewith just for manifold"; + $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'master', + update => { "$CalendarId" => { + "shareWith/manifold/mayWriteAll" => JSON::true, + }}}, "R1"] + ]); + + if ($self->{instance}->{have_syslog_replacement}) { + my @lines = $self->{instance}->getsyslog(); + $self->assert_matches(qr/manifold\.\#notifications/, "@lines"); + $self->assert((not grep { /paraphrase\.\#notifications/ } @lines), Data::Dumper::Dumper(\@lines)); + } + + if ($maj > 3 || ($maj == 3 && $min >= 4)) { + xlog $self, "check ACL on JMAP upload folder"; + $acl = $admintalk->getacl("user.master.#jmap"); + %map = @$acl; + $self->assert_str_equals('lrswitedn', $map{cassandane}); + $self->assert_str_equals('lrswitedn', $map{manifold}); + $self->assert_str_equals('lrswitedn', $map{paraphrase}); + } + + xlog $self, "Remove the access for paraphrase"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'master', + update => { "$CalendarId" => { + "shareWith/paraphrase" => undef, + }}}, "R1"] + ]); + + $self->assert_not_null($res); + $self->assert_str_equals('Calendar/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "check ACL"; + $acl = $admintalk->getacl("user.master.#calendars.$CalendarId"); + %map = @$acl; + $self->assert_str_equals('lrswipkxtecdan9', $map{cassandane}); + $self->assert_str_equals('lrswitedn579', $map{manifold}); + $self->assert_null($map{paraphrase}); + + xlog $self, "check Outbox ACL"; + $acl = $admintalk->getacl("user.master.#calendars.Outbox"); + %map = @$acl; + $self->assert_str_equals('78', $map{manifold}); + $self->assert_null($map{paraphrase}); + + xlog $self, "check Principal ACL"; + $acl = $admintalk->getacl("user.master.#calendars"); + %map = @$acl; + # both users get ACLs on the Inbox + $self->assert_str_equals('lr', $map{manifold}); + $self->assert_null($map{paraphrase}); + + xlog $self, "Check propfind"; + $Name = eval { $partalk->GetProps('/dav/principals/user/master', 'D:displayname') }; + my $error = $@; + $self->assert_null($Name); + $self->assert_matches(qr/403 Forbidden/, $error); + + if ($maj > 3 || ($maj == 3 && $min >= 4)) { + xlog $self, "check ACL on JMAP upload folder"; + $acl = $admintalk->getacl("user.master.#jmap"); + %map = @$acl; + $self->assert_str_equals('lrswitedn', $map{cassandane}); + $self->assert_str_equals('lrswitedn', $map{manifold}); + $self->assert_null($map{paraphrase}); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_sharewith_acl b/cassandane/tiny-tests/JMAPCalendars/calendar_set_sharewith_acl new file mode 100644 index 0000000000..d54b5a1b97 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_sharewith_acl @@ -0,0 +1,116 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_sharewith_acl + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.aatester"); + $admin->create("user.zztester"); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + '1' => { + name => 'A', + } + }, + }, 'R1'], + ]); + my $calendarId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($calendarId); + + my @testCases = ({ + rights => { + mayReadFreeBusy => JSON::true, + }, + acl => '9', + }, { + rights => { + mayReadItems => JSON::true, + }, + acl => 'lrw', + }, { + rights => { + mayWriteAll => JSON::true, + }, + acl => 'switedn7', + wantRights => { + mayWriteAll => JSON::true, + mayWriteOwn => JSON::true, + mayUpdatePrivate => JSON::true, + mayRSVP => JSON::true, + }, + }, { + rights => { + mayWriteOwn => JSON::true, + }, + acl => 'w6', + }, { + rights => { + mayUpdatePrivate => JSON::true, + }, + acl => 'w5', + }, { + rights => { + mayRSVP => JSON::true, + }, + acl => 'w7', + }, { + rights => { + mayAdmin => JSON::true, + }, + acl => 'wa', + }, { + rights => { + mayDelete => JSON::true, + }, + acl => 'wxc', + }); + + foreach(@testCases) { + + xlog "Run test for acl $_->{acl}"; + + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + shareWith => { + aatester => $_->{rights}, + zztester => $_->{rights}, + }, + }, + }, + }, 'R1'], + ['Calendar/get', { + ids => [$calendarId], + properties => ['shareWith'], + }, 'R2'], + ]); + + $_->{wantRights} ||= $_->{rights}; + + my %mergedrights = (( + mayReadFreeBusy => JSON::false, + mayReadItems => JSON::false, + mayWriteAll => JSON::false, + mayWriteOwn => JSON::false, + mayUpdatePrivate => JSON::false, + mayRSVP => JSON::false, + mayAdmin => JSON::false, + mayDelete => JSON::false, + ), %{$_->{wantRights}}); + + $self->assert_deep_equals(\%mergedrights, + $res->[1][1]{list}[0]{shareWith}{aatester}); + $self->assert_deep_equals(\%mergedrights, + $res->[1][1]{list}[0]{shareWith}{zztester}); + my %acl = @{$admin->getacl("user.cassandane.#calendars.$calendarId")}; + $self->assert_str_equals($_->{acl}, $acl{aatester}); + $self->assert_str_equals($_->{acl}, $acl{zztester}); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_state b/cassandane/tiny-tests/JMAPCalendars/calendar_set_state new file mode 100644 index 0000000000..17dc7f7345 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_state @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_state + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with invalid state token"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + ifInState => "badstate", + create => { "1" => { name => "foo" }} + }, "R1"] + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "create with wrong state token"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + ifInState => "987654321", + create => { "1" => { name => "foo" }} + }, "R1"] + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "create calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1 + }}}, "R1"] + ]); + $self->assert_not_null($res); + + my $id = $res->[0][1]{created}{"1"}{id}; + my $state = $res->[0][1]{newState}; + + xlog $self, "update calendar $id with current state"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + ifInState => $state, + update => {"$id" => {name => "bar"}} + }, "R1"] + ]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "setCalendar noops must keep state"; + $res = $jmap->CallMethods([ + ['Calendar/set', {}, "R1"], + ['Calendar/set', {}, "R2"], + ['Calendar/set', {}, "R3"] + ]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + + xlog $self, "update calendar $id with expired state"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + ifInState => $oldState, + update => {"$id" => {name => "baz"}} + }, "R1"] + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals("stateMismatch", $res->[0][1]{type}); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get calendar $id to make sure state didn't change"; + $res = $jmap->CallMethods([['Calendar/get', {ids => [$id]}, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]{state}); + $self->assert_str_equals('bar', $res->[0][1]{list}[0]{name}); + + xlog $self, "destroy calendar $id with expired state"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + ifInState => $oldState, + destroy => [$id] + }, "R1"] + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals("stateMismatch", $res->[0][1]{type}); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "destroy calendar $id with current state"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + ifInState => $state, + destroy => [$id] + }, "R1"] + ]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_set_unknown_calendarright b/cassandane/tiny-tests/JMAPCalendars/calendar_set_unknown_calendarright new file mode 100644 index 0000000000..d43d337899 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_set_unknown_calendarright @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_set_unknown_calendarright + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + $self->create_user('sharee'); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + sharee => { + unknownCalendarRight => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{Default}{type}); + + $self->assert_deep_equals(['shareWith/sharee/unknownCalendarRight'], + $res->[0][1]{notUpdated}{Default}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendar_treat_as_mailbox b/cassandane/tiny-tests/JMAPCalendars/calendar_treat_as_mailbox new file mode 100644 index 0000000000..6b7abd3580 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendar_treat_as_mailbox @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendar_treat_as_mailbox + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1 + }}}, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('Calendar/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{created}); + + my $id = $res->[0][1]{created}{"1"}{id}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + 'urn:ietf:params:jmap:mail', + ]; + + xlog $self, "rename as mailbox $id"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { update => { $id => { name => "foobar" } } }, "R1"] + ], $using); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_null($res->[0][1]{updated}); + $self->assert_not_null($res->[0][1]{notUpdated}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification b/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification new file mode 100644 index 0000000000..75c0409906 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification @@ -0,0 +1,139 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendaralert_notification + :min_version_3_7 :needs_component_calalarmd :needs_component_jmap +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + my $jmap = $self->{jmap}; + + my $calendarId = $caldav->NewCalendar({name => 'foo'}); + $self->assert_not_null($calendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$calendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog "Get calendar event alert ids"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['alerts'], + }, 'R1'], + ]); + + my %alerts = %{$res->[0][1]{list}[0]{alerts}}; + my %alertIds = map { $alerts{$_}{trigger}{offset} => $_ } keys %alerts; + $self->assert_num_equals(2, scalar keys %alertIds); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + my $data = $self->{instance}->getnotify(); + my @events; + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @events, $e; + } + } + } + + $self->assert_num_equals(1, scalar @events); + $self->assert_str_equals('cassandane', + $events[0]{userId}); # accountId + $self->assert_str_equals('574E2CD0-2D2A-4554-8B63-C7504481D3A9', + $events[0]{uid}); + $self->assert_str_equals(encode_eventid('574E2CD0-2D2A-4554-8B63-C7504481D3A9'), + $events[0]{calendarEventId}); + $self->assert_str_equals('', $events[0]{recurrenceId}); + $self->assert_str_equals($alertIds{'PT0S'}, $events[0]{alertId}); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 3660 ); + + $data = $self->{instance}->getnotify(); + @events = (); + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @events, $e; + } + } + } + + $self->assert_num_equals(1, scalar @events); + $self->assert_str_equals('cassandane', + $events[0]{userId}); # accountId + $self->assert_str_equals('574E2CD0-2D2A-4554-8B63-C7504481D3A9', + $events[0]{uid}); + $self->assert_str_equals(encode_eventid('574E2CD0-2D2A-4554-8B63-C7504481D3A9'), + $events[0]{calendarEventId}); + $self->assert_str_equals('', $events[0]{recurrenceId}); + $self->assert_str_equals($alertIds{'PT1H'}, $events[0]{alertId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification_recurring b/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification_recurring new file mode 100644 index 0000000000..ec99cf19c2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification_recurring @@ -0,0 +1,104 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendaralert_notification_recurring + :min_version_3_5 :needs_component_calalarmd :needs_component_jmap +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + my $calendarId = $caldav->NewCalendar({name => 'foo'}); + $self->assert_not_null($calendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start yesterday in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + $startdt->subtract(DateTime::Duration->new(days => 1)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $recurdt = $startdt->clone(); + $recurdt->add(DateTime::Duration->new(days => 1)); + my $recurid = $recurdt->strftime('%Y-%m-%dT%H:%M:%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$calendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + my $data = $self->{instance}->getnotify(); + my @events; + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @events, $e; + } + } + } + + $self->assert_num_equals(1, scalar @events); + $self->assert_str_equals('cassandane', + $events[0]{userId}); # accountId + $self->assert_str_equals('574E2CD0-2D2A-4554-8B63-C7504481D3A9', + $events[0]{uid}); + $self->assert_str_equals(encode_eventid('574E2CD0-2D2A-4554-8B63-C7504481D3A9'), + $events[0]{calendarEventId}); + $self->assert_str_equals($recurid, $events[0]{recurrenceId}); + $self->assert_str_equals('E157A1FC-06BB-4495-933E-4E99C79A8649', + $events[0]{alertId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification_standalone b/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification_standalone new file mode 100644 index 0000000000..66a17cbf2d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendaralert_notification_standalone @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendaralert_notification_standalone + :min_version_3_5 :needs_component_calalarmd :needs_component_jmap +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + my $calendarId = $caldav->NewCalendar({name => 'foo'}); + $self->assert_not_null($calendarId); + + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $icalStart = $startdt->strftime('%Y%m%dT%H%M%S'); + my $icalRecurid = $startdt->strftime('%Y%m%dT%H%M%S'); + my $recurid = $startdt->strftime('%Y-%m-%dT%H:%M:%S'); + + # set the trigger to notify us at the start of the event + my $trigger="PT0S"; + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$calendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + # clean notification cache + $self->{instance}->getnotify(); + + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + my $data = $self->{instance}->getnotify(); + my @events; + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @events, $e; + } + } + } + + $self->assert_num_equals(1, scalar @events); + $self->assert_str_equals('cassandane', + $events[0]{userId}); # accountId + $self->assert_str_equals('574E2CD0-2D2A-4554-8B63-C7504481D3A9', + $events[0]{uid}); + $self->assert_str_equals(encode_eventid('574E2CD0-2D2A-4554-8B63-C7504481D3A9', $icalRecurid), + $events[0]{calendarEventId}); + $self->assert_str_equals($recurid, $events[0]{recurrenceId}); + $self->assert_str_equals('E157A1FC-06BB-4495-933E-4E99C79A8649', + $events[0]{alertId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_attendee_noorganizer b/cassandane/tiny-tests/JMAPCalendars/calendarevent_attendee_noorganizer new file mode 100644 index 0000000000..aec8a81a45 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_attendee_noorganizer @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_attendee_noorganizer + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my ($eventId, $ical) = $self->icalfile('attendee_noorganizer'); + my $event = $self->putandget_vevent($eventId, $ical); + my $wantParticipants = { + '29deb29d758dbb27ffa3c39b499edd85b53dd33f' => { + '@type' => 'Participant', + 'sendTo' => { + 'imip' => 'mailto:attendee@local' + }, + 'roles' => { + 'attendee' => JSON::true + }, + 'participationStatus' => 'needs-action', + 'expectReply' => JSON::false, + } + }; + $self->assert_deep_equals($wantParticipants, $event->{participants}); + $self->assert_null($event->{replyTo}); + + xlog "Update event via JMAP"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + participants => $wantParticipants, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['participants', 'replyTo', 'x-href'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_deep_equals($wantParticipants, $res->[1][1]{list}[0]{participants}); + $self->assert_null($res->[1][1]{list}[0]{replyTo}); + + my $xhref = $res->[1][1]{list}[0]{'x-href'}; + $self->assert_not_null($xhref); + + xlog "Validate no ORGANIZER got added"; + $res = $caldav->Request('GET', $xhref); + $self->assert(not($res->{content} =~ m/ORGANIZER/)); + $self->assert($res->{content} =~ m/ATTENDEE/); + + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj < 3 || ($maj == 3 && $min < 7)) { + # versions 3.7 or higher are tested in calendarevent_set_replyto + xlog "Create event with no replyTo via JMAP (should fail)"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + calendarIds => { + 'Default' => JSON::true, + }, + title => "title", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT2H", + "timeZone" => "Europe/London", + participants => $wantParticipants, + }, + }, + }, 'R1'], + ]); + $self->assert_deep_equals(['replyTo', 'participants'], + $res->[0][1]{notCreated}{1}{properties}); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_blob_lookup b/cassandane/tiny-tests/JMAPCalendars/calendarevent_blob_lookup new file mode 100644 index 0000000000..f3ed8c5f7b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_blob_lookup @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_blob_lookup + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + title => 'event1', + start => '2020-01-01T09:00:00', + timeZone => 'Europe/Vienna', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + my $blobId = $res->[0][1]{created}{event1}{blobId}; + $self->assert_not_null($blobId); + + $res = $jmap->CallMethods([ + ['Blob/lookup', { + typeNames => [ + 'CalendarEvent', + ], + ids => [$blobId], + }, 'R1'], + ], [ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/blob', + ]); + $self->assert_deep_equals([{ + id => $blobId, + matchedIds => { + CalendarEvent => [ + $eventId, + ], + }, + }], $res->[0][1]{list}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_blobid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_blobid new file mode 100644 index 0000000000..40ea5e0630 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_blobid @@ -0,0 +1,155 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_blobid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create other user"; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user.other"); + $admintalk->setacl("user.other", admin => 'lrswipkxtecdan') or die; + $admintalk->setacl("user.other", other => 'lrswipkxtecdn') or die; + + my $service = $self->{instance}->get_service("http"); + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + xlog $self, "create calendar event in other users calendar"; + + my $res = $otherJmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uid1', + title => "event1", + description => "", + freeBusyStatus => "busy", + start => "2019-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + action => "email", + }, + }, + }, + } + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + + xlog $self, "share calendar"; + + $admintalk->setacl("user.other.#calendars.Default", cassandane => 'lrswipkxtecdn') or die; + + xlog $self, "set per-user event data for cassandane user"; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'other', + update => { + $eventId => { + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT10M", + }, + action => "email", + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog $self, "get event blobIds for cassandane and other user"; + + $res = $otherJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'other', + ids => [$eventId], + properties => ['blobId'], + }, 'R1'] + ]); + + # fetch a second time to make sure this works with a cached response + $res = $otherJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'other', + ids => [$eventId], + properties => ['blobId'], + }, 'R1'] + ]); + my $otherBlobId = $res->[0][1]{list}[0]{blobId}; + $self->assert_not_null($otherBlobId); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'other', + ids => [$eventId], + properties => ['blobId'], + }, 'R1'] + ]); + my $cassBlobId = $res->[0][1]{list}[0]{blobId}; + $self->assert_not_null($cassBlobId); + + xlog $self, "compare blob ids"; + + $self->assert_str_not_equals($otherBlobId, $cassBlobId); + + xlog $self, "download blob with userdata"; + + $res = $jmap->Download('other', $cassBlobId); + $self->assert_str_equals("BEGIN:VCALENDAR", substr($res->{content}, 0, 15)); + $self->assert_num_not_equals(-1, index($res->{content}, 'TRIGGER:-PT10M')); + + xlog $self, "update event"; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'other', + update => { + $eventId => { + title => 'updatedTitle', + } + } + }, 'R1'], + ['CalendarEvent/get', { + accountId => 'other', + ids => [$eventId], + properties => ['blobId'], + }, 'R1'], + + ]); + $self->assert_str_equals($res->[0][1]{updated}{$eventId}{blobId}, + $res->[1][1]{list}[0]{blobId}); + $self->assert_str_not_equals($cassBlobId, $res->[1][1]{list}[0]{blobId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes b/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes new file mode 100644 index 0000000000..52457ead28 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes @@ -0,0 +1,198 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_changes + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create calendars A and B"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { + "1" => { + name => "A", color => "coral", sortOrder => 1, isVisible => JSON::true, + }, + "2" => { + name => "B", color => "blue", sortOrder => 1, isVisible => JSON::true + } + }}, "R1"] + ]); + my $calidA = $res->[0][1]{created}{"1"}{id}; + my $calidB = $res->[0][1]{created}{"2"}{id}; + my $state = $res->[0][1]{newState}; + + xlog $self, "create event #1 in calendar $calidA and event #2 in calendar $calidB"; + $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + $calidA => JSON::true, + }, + "title" => "1", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2015-10-06T00:00:00", + }, + "2" => { + calendarIds => { + $calidB => JSON::true, + }, + "title" => "2", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2015-10-06T00:00:00", + } + }}, "R1"]]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get calendar event updates"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { sinceState => $state }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $state = $res->[0][1]{newState}; + + xlog $self, "get zero calendar event updates"; + $res = $jmap->CallMethods([['CalendarEvent/changes', {sinceState => $state}, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $state = $res->[0][1]{newState}; + + xlog $self, "update event #1 and #2"; + $res = $jmap->CallMethods([['CalendarEvent/set', { update => { + $id1 => { + calendarIds => { + $calidA => JSON::true, + }, + "title" => "1(updated)", + }, + $id2 => { + calendarIds => { + $calidB => JSON::true, + }, + "title" => "2(updated)", + } + }}, "R1"]]); + $self->assert_num_equals(2, scalar keys %{$res->[0][1]{updated}}); + + xlog $self, "get exactly one update"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { + sinceState => $state, + maxChanges => 1 + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $state = $res->[0][1]{newState}; + + xlog $self, "get the final update"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { sinceState => $state }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $state = $res->[0][1]{newState}; + + xlog $self, "update event #1 and destroy #2"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + update => { + $id1 => { + calendarIds => { + $calidA => JSON::true, + }, + "title" => "1(updated)", + "description" => "", + }, + }, + destroy => [ $id2 ] + }, "R1"]]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{updated}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get calendar event updates"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { sinceState => $state }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id1, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{destroyed}[0]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $state = $res->[0][1]{newState}; + + xlog $self, "get zero calendar event updates"; + $res = $jmap->CallMethods([['CalendarEvent/changes', {sinceState => $state}, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $state = $res->[0][1]{newState}; + + xlog $self, "move event #1 from calendar $calidA to $calidB"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + update => { + $id1 => { + calendarIds => { + $calidB => JSON::true, + }, + }, + } + }, "R1"]]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{updated}}); + + xlog $self, "get calendar event updates"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { sinceState => $state }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id1, $res->[0][1]{updated}[0]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $state = $res->[0][1]{newState}; + + xlog $self, "update and remove event #1"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + update => { + $id1 => { + calendarIds => { + $calidB => JSON::true, + }, + "title" => "1(goodbye)", + }, + }, + destroy => [ $id1 ] + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get calendar event updates"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { sinceState => $state }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $state = $res->[0][1]{newState}; +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_add_override_keep_main_unchanged b/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_add_override_keep_main_unchanged new file mode 100644 index 0000000000..f16b72de2b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_add_override_keep_main_unchanged @@ -0,0 +1,136 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_changes_add_override_keep_main_unchanged + :min_version_3_7 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<CallMethods([ + ['CalendarEvent/get', { + }, 'R1'], + ]); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + + my $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <7e017102-0caf-490a-bbdf-422141d34e75@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CyrusIMAP.org/Cyrus +METHOD:REQUEST +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:2730ae11-dbe9-43f5-a97b-47e039cb40a3 +SEQUENCE:1 +DTSTAMP:20220519T120822Z +CREATED:20220519T120822Z +DTSTART;TZID=America/New_York:20220519T160000 +DURATION:PT1H +PRIORITY:0 +SUMMARY:test +RRULE:FREQ=WEEKLY +STATUS:CONFIRMED +TRANSP:OPAQUE +ORGANIZER:mailto:organizer@example.com +ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:organizer@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite for recurring main event"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog $self, "Query changes"; + $res = $jmap->CallMethods([ + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/changes', + path => '/created', + }, + }, 'R2'], + ]); + $state = $res->[0][1]{newState}; + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_not_null($res->[1][1]{list}[0]{recurrenceRules}); + $self->assert_null($res->[1][1]{list}[0]{recurrenceOverrides}); + + $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <7e017102-0caf-490a-bbdf-422141d34e75@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CyrusIMAP.org/Cyrus +METHOD:REQUEST +CALSCALE:GREGORIAN +BEGIN:VEVENT +RECURRENCE-ID;TZID=America/New_York:20220602T160000 +UID:2730ae11-dbe9-43f5-a97b-47e039cb40a3 +DTSTAMP:20220519T121052Z +CREATED:20220519T120822Z +DTSTART;TZID=America/New_York:20220603T160000 +DURATION:PT1H +SEQUENCE:1 +PRIORITY:0 +SUMMARY:test +STATUS:CONFIRMED +TRANSP:OPAQUE +CLASS:PUBLIC +ORGANIZER:mailto:organizer@example.com +ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:organizer@example.com +ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE;X-SEQUENCE=1;X-DTSTAMP=20220519T120959Z: + mailto:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP override without main event"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog $self, "Query changes"; + $res = $jmap->CallMethods([ + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/changes', + path => '/updated', + }, + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_not_null($res->[1][1]{list}[0]{recurrenceRules}); + $self->assert_not_null($res->[1][1]{list}[0]{recurrenceOverrides}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_ignore_specials b/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_ignore_specials new file mode 100644 index 0000000000..1a2ebadec7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_ignore_specials @@ -0,0 +1,67 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_changes_ignore_specials + :needs_component_sieve :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [], + }, 'R1'], + ]); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <6de280c9-edff-4019-8ebd-cfebc73f8201@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: 6de280c9-edff-4019-8ebd-cfebc73f8201 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + $res = $jmap->CallMethods([ + ['CalendarEvent/changes', { + sinceState => $state + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_issue2558 b/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_issue2558 new file mode 100644 index 0000000000..265b6210de --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_changes_issue2558 @@ -0,0 +1,22 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_changes_issue2558 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "get calendar event updates with bad state"; + my $res = $jmap->CallMethods([['CalendarEvent/changes', { sinceState => 'nonsense' }, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get calendar event updates without state"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { }, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_copy b/cassandane/tiny-tests/JMAPCalendars/calendarevent_copy new file mode 100644 index 0000000000..2c0b0b514e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_copy @@ -0,0 +1,96 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_copy + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared accounts"; + $admintalk->create("user.other"); + + my $othercaldav = Net::CalDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl('user.other', admin => 'lrswipkxtecdan'); + $admintalk->setacl('user.other', other => 'lrswipkxtecdn'); + + xlog $self, "create source calendar"; + my $srcCalendarId = $caldav->NewCalendar({name => 'Source Calendar'}); + $self->assert_not_null($srcCalendarId); + + xlog $self, "create destination calendar"; + my $dstCalendarId = $othercaldav->NewCalendar({name => 'Destination Calendar'}); + $self->assert_not_null($dstCalendarId); + + xlog $self, "share calendar"; + $admintalk->setacl("user.other.#calendars.$dstCalendarId", "cassandane" => 'lrswipkxtecdn') or die; + + my $event = { + calendarIds => { + $srcCalendarId => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "participants" => undef, + "alerts"=> undef, + }; + + xlog $self, "create event"; + my $res = $jmap->CallMethods([['CalendarEvent/set',{ + create => {"1" => $event}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + my $eventId = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "copy event"; + $res = $jmap->CallMethods([['CalendarEvent/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $eventId, + calendarIds => { + $dstCalendarId => JSON::true, + }, + }, + }, + onSuccessDestroyOriginal => JSON::true, + }, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + my $copiedEventId = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'other', + ids => [$copiedEventId], + }, 'R1'], + ['CalendarEvent/get', { + accountId => undef, + ids => [$eventId], + }, 'R2'], + ]); + $self->assert_str_equals('foo', $res->[0][1]{list}[0]{title}); + $self->assert_str_equals($eventId, $res->[1][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_copy_state b/cassandane/tiny-tests/JMAPCalendars/calendarevent_copy_state new file mode 100644 index 0000000000..599e775ec3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_copy_state @@ -0,0 +1,117 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_copy_state + :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared accounts"; + $admintalk->create("user.other"); + + my $othercaldav = Net::CalDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl('user.other', admin => 'lrswipkxtecdan'); + $admintalk->setacl('user.other', other => 'lrswipkxtecdn'); + + xlog $self, "create source calendar"; + my $srcCalendarId = $caldav->NewCalendar({name => 'Source Calendar'}); + $self->assert_not_null($srcCalendarId); + + xlog $self, "create destination calendar"; + my $dstCalendarId = $othercaldav->NewCalendar({name => 'Destination Calendar'}); + $self->assert_not_null($dstCalendarId); + + xlog $self, "share calendar"; + $admintalk->setacl("user.other.#calendars.$dstCalendarId", "cassandane" => 'lrswipkxtecdn') or die; + + my $event = { + calendarIds => { + $srcCalendarId => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "participants" => undef, + "alerts"=> undef, + }; + + xlog $self, "create event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => {"1" => $event} + }, "R1"], + ['CalendarEvent/get', { + accountId => 'other', + ids => ['foo'], # Just fetching current state for 'other' + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}); + my $eventId = $res->[0][1]{created}{"1"}{id}; + my $fromState = $res->[0][1]->{newState}; + $self->assert_not_null($fromState); + my $state = $res->[1][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "move event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + ifFromInState => $fromState, + ifInState => $state, + create => { + 1 => { + id => $eventId, + calendarIds => { + $dstCalendarId => JSON::true, + }, + }, + }, + onSuccessDestroyOriginal => JSON::true, + destroyFromIfInState => $fromState, + }, "R1"], + ['CalendarEvent/get', { + accountId => 'other', + ids => ['#1'], + properties => ['title'], + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}); + my $oldState = $res->[0][1]->{oldState}; + $self->assert_str_equals($oldState, $state); + my $newState = $res->[0][1]->{newState}; + $self->assert_not_null($newState); + $self->assert_str_equals('CalendarEvent/set', $res->[1][0]); + $self->assert_str_equals($eventId, $res->[1][1]{destroyed}[0]); + $self->assert_str_equals('foo', $res->[2][1]{list}[0]{title}); + + # Is the blobId downloadable? + my $blob = $jmap->Download({ accept => 'text/calendar' }, + 'other', + $res->[0][1]{created}{"1"}{blobId}); + $self->assert_str_equals('text/calendar; component=VEVENT', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_matches(qr/\r\nSUMMARY;LANGUAGE=en:foo\r\n/, $blob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_debugblobid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_debugblobid new file mode 100644 index 0000000000..6e242df730 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_debugblobid @@ -0,0 +1,149 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_debugblobid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create other user"; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user.other"); + $admintalk->setacl("user.other", admin => 'lrswipkxtecdan') or die; + $admintalk->setacl("user.other", other => 'lrswipkxtecdn') or die; + + my $service = $self->{instance}->get_service("http"); + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + xlog $self, "create calendar event in other users calendar"; + + my $res = $otherJmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uid1', + title => "event1", + description => "", + freeBusyStatus => "busy", + start => "2019-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + action => "email", + }, + }, + }, + } + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + + xlog $self, "share calendar"; + + $admintalk->setacl("user.other.#calendars.Default", cassandane => 'lrswipkxtecdn') or die; + + xlog $self, "set per-user event data for cassandane user"; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'other', + update => { + $eventId => { + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT10M", + }, + action => "email", + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog $self, "get debugBlobId as regular user"; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/debug', + ]; + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'other', + ids => [$eventId], + properties => ['debugBlobId'], + }, 'R1'] + ], $using); + my $debugBlobId = $res->[0][1]{list}[0]{debugBlobId}; + $self->assert_not_null($debugBlobId); + + xlog $self, "attempt to download debugBlob as non-admin (should fail)"; + + my $downloadUri = $jmap->downloaduri('other', $debugBlobId); + my %Headers = ( + 'Authorization' => $jmap->auth_header(), + ); + my $RawResponse = $jmap->ua->get($downloadUri, { headers => \%Headers }); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawResponse); + } + $self->assert_str_equals('404', $RawResponse->{status}); + + xlog $self, "get debugBlobId as admin user"; + + my $adminJmap = Mail::JMAPTalk->new( + user => 'admin', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $res = $adminJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'other', + ids => [$eventId], + properties => ['debugBlobId'], + }, 'R1'] + ], $using); + $debugBlobId = $res->[0][1]{list}[0]{debugBlobId}; + $self->assert_not_null($debugBlobId); + + xlog $self, "download debugBlob with userdata"; + + $res = $adminJmap->Download('other', $debugBlobId); + $self->assert_str_equals("multipart/mixed", substr($res->{headers}{'content-type'}, 0, 15)); + $self->assert_num_not_equals(-1, index($res->{content}, 'SUMMARY:event1')); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_calalarmd b/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_calalarmd new file mode 100644 index 0000000000..145a4586b9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_calalarmd @@ -0,0 +1,284 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_defaultalerts_calalarmd + :min_version_3_9 :needs_component_jmap :needs_component_calalarmd +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $now = DateTime->now(); + $now->set_time_zone('Australia/Sydney'); + + xlog $self, "Create calendar without default alarms"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + 1 => { + name => 'test', + }, + } + }, 'R1'], + ]); + my $calendarId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($calendarId); + + xlog $self, "Remove any default alarms"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + defaultAlertsWithTime => undef, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarId}); + + xlog $self, "Create event that starts in an hour, ocurring daily"; + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(hours => 1)); + my $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + my $uuid = "574E2CD0-2D2A-4554-8B63-C7504481D3A9"; + my $href = "$calendarId/$uuid.ics"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/calendar'); + + xlog $self, "Assert that useDefaultAlerts got rewritten to false"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['useDefaultAlerts'], + }, 'R1'], + ]); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]{useDefaultAlerts}); + my $eventId = $res->[0][1]{list}[0]{id}; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog "Custom alarm triggers half an hour before event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch - 1800); + $self->assert_alarms({summary => 'Simple', start => $start}); + + xlog $self, "Set default alarms to trigger at start of event"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }, + } + }, + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarId}); + + xlog $self, "Set useDefaultAlerts=true for event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + useDefaultAlerts => JSON::true, + } + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog $self, "Forward clock by one day"; + $now->add(DateTime::Duration->new(days => 1)); + $startdt->add(DateTime::Duration->new(days => 1)); + $start = $startdt->strftime('%Y%m%dT%H%M%S'); + $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + # clean notification cache + $self->{instance}->getnotify(); + + xlog "Custom alarm does not trigger half an hour before event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch - 1800); + $self->assert_alarms(); + + xlog "Default alarm triggers at start of event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch); + $self->assert_alarms({summary => 'Simple', start => $start}); + + xlog $self, "Update default alarms to trigger half an hour after event"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + defaultAlertsWithTime => { + alert2 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT30M', + }, + action => 'display', + }, + } + }, + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarId}); + + xlog $self, "Forward clock by one day"; + $now->add(DateTime::Duration->new(days => 1)); + $startdt->add(DateTime::Duration->new(days => 1)); + $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + xlog "Custom alarm does not trigger half an hour before event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch - 1800); + $self->assert_alarms(); + + xlog "Former default alarm does not trigger at start of event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch); + $self->assert_alarms(); + + xlog "Current default alarm gets triggered half an hour after the event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch + 1800 ); + $self->assert_alarms({summary => 'Simple', start => $start}); + + xlog $self, "Forward clock by one day"; + $now->add(DateTime::Duration->new(days => 1)); + $startdt->add(DateTime::Duration->new(days => 1)); + $start = $startdt->strftime('%Y%m%dT%H%M%S'); + + xlog $self, "Remove default alarms again"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + $calendarId => { + defaultAlertsWithTime => undef, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$calendarId}); + + xlog "Custom alarm does not trigger half an hour before event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch - 1800); + $self->assert_alarms(); + + xlog "Former default alarm does not trigger at start of event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch); + $self->assert_alarms(); + + xlog "Former default alarm does not trigger half an hour after the event"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $startdt->epoch + 1800 ); + $self->assert_alarms(); +} + +sub _can_match { + my $event = shift; + my $want = shift; + + # I wrote a really good one of these for Caldav, but this will do for now + foreach my $key (keys %$want) { + return 0 if not exists $event->{$key}; + return 0 if $event->{$key} ne $want->{$key}; + } + + return 1; +} + + +sub assert_alarms { + my $self = shift; + my @want = @_; + # pick first calendar alarm from notifications + my $data = $self->{instance}->getnotify(); + if ($self->{replica}) { + my $more = $self->{replica}->getnotify(); + push @$data, @$more; + } + my @events; + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @events, $e; + } + } + } + + my @left; + while (my $event = shift @events) { + my $found = 0; + my @newwant; + foreach my $data (@want) { + if (not $found and _can_match($event, $data)) { + $found = 1; + } + else { + push @newwant, $data; + } + } + if (not $found) { + push @left, $event; + } + @want = @newwant; + } + + if (@want or @left) { + my $dump = Data::Dumper->Dump([\@want, \@left], [qw(want left)]); + $self->assert_equals(0, scalar @want, + "expected events were not received:\n$dump"); + $self->assert_equals(0, scalar @left, + "unexpected extra events were received:\n$dump"); + } +} + diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_etag_preserved_after_alarm b/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_etag_preserved_after_alarm new file mode 100644 index 0000000000..7b36bdc155 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_etag_preserved_after_alarm @@ -0,0 +1,102 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_defaultalerts_etag_preserved_after_alarm + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "Set default alarms"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + } + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog $self, "Create event that starts in a few seconds"; + my $eventUid = '5f0dec98-8952-418e-91fa-159cb2ba28da'; + my $now = DateTime->now(); + $now->set_time_zone('Etc/UTC'); + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + uid => $eventUid, + calendarIds => { + 'Default' => JSON::true, + }, + title => "event1", + start => $startdt->strftime('%Y-%m-%dT%H:%M:%S'), + duration => "PT1H", + timeZone => "Etc/UTC", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + xlog $self, "Get event ETag before alarm fires"; + my %Headers; + if ($caldav->{user}) { + $Headers{'Authorization'} = $caldav->auth_header(); + } + $res = $caldav->{ua}->request('HEAD', + $caldav->request_url("Default/$eventUid.ics"), + { headers => \%Headers, }); + + my $etag = $res->{headers}{etag}; + $self->assert_not_null($etag); + + xlog $self, "Run calalarmd"; + $self->{instance}->getnotify(); + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60 ); + my $notifdata = $self->{instance}->getnotify(); + my @notifs; + foreach (@$notifdata) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @notifs, $e; + } + } + } + $self->assert_num_equals(1, scalar @notifs); + + xlog $self, "Get event ETag after alarm fired"; + my %Headers; + if ($caldav->{user}) { + $Headers{'Authorization'} = $caldav->auth_header(); + } + $res = $caldav->{ua}->request('HEAD', + $caldav->request_url("Default/$eventUid.ics"), + { headers => \%Headers, }); + + xlog $self, "Assert ETag has not changed"; + my $oldEtag = $etag; + my $newEtag = $res->{headers}{etag}; + $self->assert_str_equals($oldEtag, $newEtag); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_imip b/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_imip new file mode 100644 index 0000000000..1e64647e95 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_imip @@ -0,0 +1,187 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_defaultalerts_imip + :needs_component_sieve :needs_component_httpd :needs_component_jmap :min_version_3_9 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my ($maj, $min) = Cassandane::Instance->get_version(); + my $uuid = new Data::UUID; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }; + + my $alertWithoutTimeId = 'baedd1d3-36d6-4d8f-986c-073c5e1f2f70'; + my $alertWithoutTime = { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }; + + xlog 'Set default alerts on calendar'; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + $alertWithTimeId => $alertWithTime, + }, + defaultAlertsWithoutTime => { + $alertWithoutTimeId => $alertWithoutTime, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + my $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <6de280c9-edff-4019-8ebd-cfebc73f8201@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: 6de280c9-edff-4019-8ebd-cfebc73f8201 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +X-JMAP-USEDEFAULTALERTS;VALUE=BOOLEAN:TRUE +BEGIN:VALARM +UID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-1 +TRIGGER:PT25M +ACTION:DISPLAY +END:VALARM +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog $self, "Assert that useDefaultAlerts is set"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'alerts', 'useDefaultAlerts'] + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{useDefaultAlerts}); + $self->assert_deep_equals({ $alertWithTimeId => $alertWithTime }, + $res->[0][1]{list}[0]{alerts}); + + my $eventId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($eventId); + + my $customAlertId = '3b438031-621e-4e1c-b7eb-fe8c75cc2d6a'; + my $customAlert = { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT10M', + }, + action => 'display', + }; + + xlog "Set custom alert on event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + alerts => { + $customAlertId => $customAlert, + }, + useDefaultAlerts => JSON::false, + }, + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Update event via iTIP"; + $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 10:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <6de280c9-edff-4019-8ebd-cfebc73f8201@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: 6de280c9-edff-4019-8ebd-cfebc73f8201 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Updated Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +BEGIN:VALARM +UID:0CF835D0-CFEB-44AE-904A-C26AB62B73BB-1 +TRIGGER:PT25M +ACTION:DISPLAY +END:VALARM +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP update"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'alerts', 'useDefaultAlerts'] + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_equals(JSON::false, + $res->[0][1]{list}[0]{useDefaultAlerts}); + $self->assert_deep_equals({ $customAlertId => $customAlert }, + $res->[0][1]{list}[0]{alerts}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_updated_by_caldav_put b/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_updated_by_caldav_put new file mode 100644 index 0000000000..4457278436 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_defaultalerts_updated_by_caldav_put @@ -0,0 +1,423 @@ +#!perl +use Cassandane::Tiny; +use Data::ICal; + +sub test_calendarevent_defaultalerts_updated_by_caldav_put + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "Set default alerts on calendar"; + $self->{defaultAlertIds} = [ + '73aac5e1-e736-4c81-8b30-fb6ad5781f95', + 'a7ce891b-ae41-4fdb-a3d1-346d3889c90b' + ]; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + $self->{defaultAlertIds}[0] => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + $self->{defaultAlertIds}[1] => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT15M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + $self->assert_preserved_when_unchanged; + $self->assert_preserved_by_snooze_alarm; + $self->assert_preserved_by_apple_alarm; + + $self->assert_disabled_by_user_alarm; + $self->assert_disabled_by_removed_default_alarm; + $self->assert_disabled_by_no_alarms; + + $self->assert_not_disabled_if_xheader_set; +} + +sub assert_valarms +{ + my ($self, $vevent, %args) = @_; + + my @props = @{$vevent->property('X-JMAP-USEDEFAULTALERTS')}; + if ($args{useDefaultAlerts}) { + $self->assert_not_null($props[0]); + $self->assert_str_equals('TRUE', $props[0]->value); + } elsif ($props) { + $self->assert_str_equals('FALSE', $props[0]->value); + } + + my @valarms = grep { $_->ical_entry_type eq 'VALARM' } @{$vevent->entries}; + my @gotUids = sort map { @{$_->property('UID')}[0]->value } @valarms; + my @wantUids = sort @{${args}{uids}}; + + $self->assert_deep_equals(\@wantUids, \@gotUids); +} + +sub caldav_get +{ + my ($self, $eventHref) = @_; + my $caldav = $self->{caldav}; + + xlog $self, "GET event"; + my %headers = ( + 'Content-Type' => 'text/calendar', + 'Authorization' => $caldav->auth_header, + ); + my $res = $caldav->{ua}->request('GET', + $caldav->request_url($eventHref), { + headers => \%headers, + }); + $self->assert_str_equals('200', $res->{status}); + + my $vcalendar = Data::ICal->new(data => $res->{content}); + my @vevents = grep { $_->ical_entry_type eq 'VEVENT' } @{$vcalendar->entries}; + my $vevent = $vevents[0]; + $self->assert_not_null($vevent); + return ($vcalendar, $vevent, $res->{headers}{etag}); +} + +sub caldav_put +{ + my ($self, $href, $args) = @_; + my $caldav = $self->{caldav}; + + my %headers = ( + 'Content-Type' => 'text/calendar', + 'Authorization' => $caldav->auth_header + ); + @headers{ keys %{$args->{headers}} } = values %{$args->{headers}}; + my $res = $caldav->{ua}->request('PUT', + $caldav->request_url($href), { + headers => \%headers, + content => $args->{body}, + }); + + $self->assert_str_equals('204', $res->{status}); + return $res->{headers}{etag}; +} + + +sub create_jevent +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $ug = Data::UUID->new; + my $eventUid = $ug->create_str; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + $eventUid => { + uid => $eventUid, + calendarIds => { + 'Default' => JSON::true, + }, + title => "event1", + start => '2023-02-17T15:10:00', + duration => "PT1H", + timeZone => "Etc/UTC", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{created}); + + my $eventHref = "Default/$eventUid.ics"; + + xlog "Rewrite unchanged iCalendar event"; + # We'll later change the iCalendar event data using + # the Data::ICal module. This library seems to sort + # iCalendar properties alphabetically when it + # serializes the event to iCalendar, so let's make + # sure the Cyrus on-disk representation matches. + # We need this to compare ETags later. + my ($vcalendar, $vevent) = $self->caldav_get($eventHref); + $self->caldav_put($eventHref, { body => $vcalendar->as_string }); + + return $eventHref; +} + +sub assert_preserved_when_unchanged +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog $self, "Assert useDefaultAlerts=true for no changes"; + + xlog $self, "Create JMAP event with default alerts"; + my $eventHref = $self->create_jevent; + + xlog $self, "Assert alarms via CalDAV"; + my ($vcalendar, $vevent, $getetag1) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); + + xlog $self, "PUT back unchanged event"; + my $putetag = $self->caldav_put($eventHref, { body => $vcalendar->as_string }); + $self->assert_null($putetag); + + xlog $self, "Assert alarms via CalDAV"; + ($vcalendar, $vevent, $getetag2) = $self->caldav_get($eventHref); + $self->assert_str_equals($getetag1, $getetag2); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); +} + +sub assert_preserved_by_snooze_alarm +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog $self, "Assert useDefaultAlerts=true for snooze alarm"; + + my $alarm = Data::ICal::Entry::Alarm::Display->new; + my $alarmUid = (Data::UUID->new)->create_str; + + # We actually should also acknowledge the default alarm + $alarm->add_properties( + description => 'useralarm', + trigger => '-PT15M', + 'related-to' => $self->{defaultAlertIds}[0], + uid => $alarmUid, + ); + + xlog $self, "Create JMAP event with default alerts"; + my $eventHref = $self->create_jevent; + + xlog $self, "Assert alarms via CalDAV"; + my ($vcalendar, $vevent, $getetag1) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); + + xlog $self, "Add snooze alarm via CalDAV"; + $vevent->add_entry($alarm); + my $putetag = $self->caldav_put($eventHref, { body => $vcalendar->as_string }); + $self->assert_null($putetag); + + xlog $self, "Assert alarms via CalDAV"; + ($vcalendar, $vevent, $getetag2) = $self->caldav_get($eventHref); + my @wantUids = (($alarmUid), @{$self->{defaultAlertIds}}); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, uids => \@wantUids, + ); + $self->assert_str_not_equals($getetag1, $getetag2); +} + +sub assert_disabled_by_user_alarm +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog $self, "Assert useDefaultAlerts=false for added user alarm"; + + my $alarm = Data::ICal::Entry::Alarm::Display->new; + my $alarmUid = (Data::UUID->new)->create_str; + $alarm->add_properties( + description => 'useralarm', + trigger => 'PT15M', + uid => $alarmUid, + ); + + xlog $self, "Create JMAP event with default alerts"; + my $eventHref = $self->create_jevent; + + xlog $self, "Assert alarms via CalDAV"; + my ($vcalendar, $vevent, $getetag1) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); + + xlog $self, "Add user alarm via CalDAV"; + $vevent->add_entry($alarm); + my $putetag = $self->caldav_put($eventHref, { body => $vcalendar->as_string }); + $self->assert_null($putetag); + + xlog $self, "Assert alarms via CalDAV"; + ($vcalendar, $vevent, $getetag2) = $self->caldav_get($eventHref); + my @wantUids = (($alarmUid), @{$self->{defaultAlertIds}}); + $self->assert_valarms($vevent, + useDefaultAlerts => 0, uids => \@wantUids, + ); + $self->assert_str_not_equals($getetag1, $getetag2); +} + +sub assert_preserved_by_apple_alarm +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog $self, "Assert useDefaultAlerts=true for added Apple default alarm"; + + my $alarm = Data::ICal::Entry::Alarm::Display->new; + my $alarmUid = (Data::UUID->new)->create_str; + $alarm->add_properties( + description => 'applealarm', + trigger => '-PT15M', + uid => $alarmUid, + 'x-apple-default-alarm' => 'TRUE', + ); + + xlog $self, "Create JMAP event with default alerts"; + my $eventHref = $self->create_jevent; + + xlog $self, "Assert alarms via CalDAV"; + my ($vcalendar, $vevent, $getetag1) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); + + xlog $self, "Add user alarm via CalDAV"; + $vevent->add_entry($alarm); + my $putetag = $self->caldav_put($eventHref, { body => $vcalendar->as_string }); + $self->assert_null($putetag); + + xlog $self, "Assert alarms via CalDAV"; + ($vcalendar, $vevent, $getetag2) = $self->caldav_get($eventHref); + my @wantUids = (($alarmUid), @{$self->{defaultAlertIds}}); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, uids => \@wantUids, + ); + $self->assert_str_not_equals($getetag1, $getetag2); +} + +sub assert_disabled_by_removed_default_alarm +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog $self, "Assert useDefaultAlerts=false for removed default alarm"; + + xlog $self, "Create JMAP event with default alerts"; + my $eventHref = $self->create_jevent; + + xlog $self, "Assert alarms via CalDAV"; + my ($vcalendar, $vevent, $getetag1) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); + + xlog $self, "Remove one of two default alarms"; + splice(@{$vevent->entries}, 1); + my $keptAlarmUid = $vevent->entries->[0]->property('uid')->[0]->value; + my $putetag = $self->caldav_put($eventHref, { body => $vcalendar->as_string }); + $self->assert_null($putetag); + + xlog $self, "Assert alarms via CalDAV"; + ($vcalendar, $vevent, $getetag2) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 0, uids => [ $keptAlarmUid ], + ); + $self->assert_str_not_equals($getetag1, $getetag2); +} + +sub assert_disabled_by_no_alarms +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog $self, "Assert useDefaultAlerts=false for no alarms"; + + xlog $self, "Create JMAP event with default alerts"; + my $eventHref = $self->create_jevent; + + xlog $self, "Assert alarms via CalDAV"; + my ($vcalendar, $vevent, $getetag1) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); + + xlog $self, "Remove all alarms from VEVENT"; + my $ical = $vcalendar->as_string; + $ical =~ s/BEGIN:VALARM.*END:VALARM\r\n//gms; + my $putetag = $self->caldav_put($eventHref, { body => $ical }); + $self->assert_null($putetag); + + xlog $self, "Assert alarms via CalDAV"; + ($vcalendar, $vevent, $getetag2) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 0, + uids => [ ], + ); + $self->assert_str_not_equals($getetag1, $getetag2); +} + +sub assert_not_disabled_if_xheader_set +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog $self, "Assert useDefaultAlerts=true for user alarm if x-header is set"; + + my $alarm = Data::ICal::Entry::Alarm::Display->new; + my $alarmUid = (Data::UUID->new)->create_str; + $alarm->add_properties( + description => 'useralarm', + trigger => 'PT15M', + uid => $alarmUid, + ); + + xlog $self, "Create JMAP event with default alerts"; + my $eventHref = $self->create_jevent; + + xlog $self, "Assert alarms via CalDAV"; + my ($vcalendar, $vevent, $getetag1) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); + + xlog $self, "Remove VALARMs from VEVENT and add new user VALARM"; + splice(@{$vevent->entries}); + $vevent->add_entry($alarm); + + xlog $self, "PUT via CalDAV"; + my $putetag = $self->caldav_put($eventHref, { + headers => { + 'X-Cyrus-rewrite-usedefaultalerts' => 'f', + }, + body => $vcalendar->as_string, + }); + $self->assert_null($putetag); + + xlog $self, "Assert alarms via CalDAV"; + ($vcalendar, $vevent, $getetag2) = $self->caldav_get($eventHref); + $self->assert_valarms($vevent, + useDefaultAlerts => 1, + uids => $self->{defaultAlertIds}, + ); + $self->assert_str_not_equals($getetag1, $getetag2); +} + diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_encode_imip_uri b/cassandane/tiny-tests/JMAPCalendars/calendarevent_encode_imip_uri new file mode 100644 index 0000000000..78c98d843b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_encode_imip_uri @@ -0,0 +1,109 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_encode_imip_uri + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $uid = 'event1uid'; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<{instance}->getnotify(); + + xlog "Create event with percent-encoded participant uri"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => $uid, + title => 'event1', + start => '2020-01-01T09:00:00', + timeZone => 'Europe/Vienna', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + plusuri => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:plus%2Buri@example.com', + }, + expectReply => JSON::true, + participationStatus => 'needs-action', + }, + }, + }, + }, + sendSchedulingMessages => JSON::true, + }, 'R1'], + ['CalendarEvent/get', { + properties => ['participants'], + }, 'R2'], + ]); + + xlog "Assert the Participant uri is encoded"; + $self->assert_str_equals('mailto:plus%2Buri@example.com', + $res->[1][1]{list}[0]{participants}{plusuri}{sendTo}{imip}); + + xlog "Assert the iCalendar data has the encoded URI"; + my $blobId = $res->[0][1]{created}{event1}{blobId}; + $res = $jmap->Download('cassandane', $blobId); + my $ical = $res->{content}; + $self->assert($ical =~ /mailto:plus%2Buri\@example\.com/g); + + xlog "Assert the iMIP notification has the decoded recipient"; + my $data = $self->{instance}->getnotify(); + my ($imipnotif) = grep { $_->{METHOD} eq 'imip' } @$data; + my $payload = decode_json($imipnotif->{MESSAGE}); + $self->assert_str_equals('plus+uri@example.com', $payload->{recipient}); + my $expect_id = encode_eventid($uid); + $self->assert_str_equals($expect_id, $payload->{id}); + $self->assert_str_equals('REQUEST', $payload->{method}); + + xlog "Assert the iTIP message has the encoded URI"; + my $itip = $payload->{ical}; + $self->assert($itip =~ /mailto:plus%2Buri\@example\.com/g); + $self->assert($itip =~ "METHOD:REQUEST"); + + xlog "Deliver iTIP REPLY for participant"; + $itip =~ s/METHOD:REQUEST/METHOD:REPLY/g; + $itip =~ s/NEEDS-ACTION/ACCEPTED/g; + + my $imip = <<"EOF"; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <6de280c9-edff-4019-8ebd-cfebc73f8201\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: 6de280c9-edff-4019-8ebd-cfebc73f8201 + +$itip +EOF + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog "Assert the participant status got updated"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['participants'], + }, 'R1'], + ]); + $self->assert_str_equals('mailto:plus%2Buri@example.com', + $res->[0][1]{list}[0]{participants}{plusuri}{sendTo}{imip}); + $self->assert_str_equals('accepted', + $res->[0][1]{list}[0]{participants}{plusuri}{participationStatus}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_alerts b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_alerts new file mode 100644 index 0000000000..a38c021dbd --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_alerts @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_alerts + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my ($maj, $min) = Cassandane::Instance->get_version(); + + my ($id, $ical) = $self->icalfile('alerts'); + + my $alerts = { + '0CF835D0-CFEB-44AE-904A-C26AB62B73BB-1' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + action => "email", + }, + '0CF835D0-CFEB-44AE-904A-C26AB62B73BB-2' => { + '@type' => 'Alert', + trigger => { + '@type' => 'AbsoluteTrigger', + when => "2016-09-28T13:55:00Z", + }, + acknowledged => "2016-09-28T14:00:05Z", + action => "display", + }, + '0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "PT10M", + }, + action => "display", + }, + '0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3-snoozed1' => { + '@type' => 'Alert', + trigger => { + '@type' => 'AbsoluteTrigger', + when => '2016-09-28T15:00:05Z', + }, + action => "display", + relatedTo => { + '0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3' => { + '@type' => 'Relation', + relation => { + parent => JSON::true, + }, + } + }, + }, + '0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3-snoozed2' => { + '@type' => 'Alert', + trigger => { + '@type' => 'AbsoluteTrigger', + when => '2016-09-28T15:00:05Z', + }, + action => "display", + relatedTo => { + '0CF835D0-CFEB-44AE-904A-C26AB62B73BB-3' => { + '@type' => 'Relation', + relation => {} + }, + }, + }, + '0CF835D0-CFEB-44AE-904A-C26AB62B73BB-4' => { + '@type' => 'Alert', + trigger => { + '@type' => 'AbsoluteTrigger', + when => '1976-04-01T00:55:45Z', + }, + action => "display", + }, + }; + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_str_equals(JSON::false, $event->{useDefaultAlerts}); + $self->assert_deep_equals($alerts, $event->{alerts}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_attachbinary b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_attachbinary new file mode 100644 index 0000000000..e739caa7d7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_attachbinary @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_attachbinary + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:test +ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=text/plain:aGVsbG8= +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $rawIcal, + 'Content-Type' => 'text/calendar'); + + xlog "Fetch with Cyrus extension"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['links'], + }, 'R1'], + ]); + my $event = $res->[0][1]{list}[0]; + $self->assert_not_null($event); + + my @links = values %{$event->{links}}; + $self->assert_num_equals(1, scalar @links); + $self->assert_null($links[0]{href}); + $self->assert_str_equals('text/plain', $links[0]{contentType}); + my $blobId = $links[0]{blobId}; + $self->assert_not_null($blobId); + + xlog "Fetch blob"; + $res = $jmap->Download('cassandane', $blobId); + $self->assert_str_equals("hello", $res->{content}); + + xlog "Fetch without Cyrus extension"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['links'], + }, 'R2'], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + ]); + $event = $res->[0][1]{list}[0]; + $self->assert_not_null($event); + + @links = values %{$event->{links}}; + $self->assert_num_equals(1, scalar @links); + $self->assert_str_equals('data:text/plain;base64,aGVsbG8=', $links[0]{href}); + $self->assert_str_equals('text/plain', $links[0]{contentType}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_baseeventid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_baseeventid new file mode 100644 index 0000000000..01c74b5d5e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_baseeventid @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_baseeventid + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $event1Uid = '1cf5da26-38e9-47ac-8449-04354ae3772d'; + my $event2Uid = '3e1356b8-5e55-4413-98c9-27da12271b99'; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + 'Default' => JSON::true, + }, + uid => $event1Uid, + title => "event1", + start => "2023-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'daily', + count => 3, + }], + recurrenceOverrides => { + '2023-01-02T09:00:00' => { + start => '2023-01-02T12:00:00', + }, + }, + }, + event2 => { + calendarIds => { + 'Default' => JSON::true, + }, + uid => $event2Uid, + title => "event2", + start => "2023-01-01T01:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'daily', + count => 3, + }], + }, + } + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event1', '#event2',], + properties => ['baseEventId'], + }, 'R2'], + ]); + my $event1Id = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($event1Id); + $self->assert_null($res->[1][1]{list}[0]{baseEventId}); + + my $event2Id = $res->[0][1]{created}{event2}{id}; + $self->assert_not_null($event2Id); + $self->assert_null($res->[1][1]{list}[0]{baseEventId}); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + filter => { + after => '2023-01-01T01:00:00', + before => '2023-01-03T00:00:00', + }, + sort => [{ + property => 'start', + }], + expandRecurrences => JSON::true, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => ['baseEventId', 'utcStart',], + }, 'R2'], + ]); + + $self->assert_deep_equals([ + encode_eventid($event1Uid, '20230101T090000'), + encode_eventid($event2Uid, '20230102T010000'), + encode_eventid($event1Uid, '20230102T090000'), + ], $res->[0][1]{ids}); + + my @events = sort { + $a->{utcStart} cmp $b->{utcStart} + } @{$res->[1][1]{list}}; + + $self->assert_str_equals($event1Id, $events[0]{baseEventId}); + $self->assert_str_equals($event2Id, $events[1]{baseEventId}); + $self->assert_str_equals($event1Id, $events[2]{baseEventId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_color b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_color new file mode 100644 index 0000000000..aaec196675 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_color @@ -0,0 +1,14 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_color + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('color'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals("red", $event->{color}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_color_categories b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_color_categories new file mode 100644 index 0000000000..e41a55a68c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_color_categories @@ -0,0 +1,15 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_color_categories + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('color-categories'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals("red", $event->{color}); + $self->assert_null($event->{keywords}{red}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_description b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_description new file mode 100644 index 0000000000..cac0815e0a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_description @@ -0,0 +1,15 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_description + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('description'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals("Hello, world!", $event->{description}); + $self->assert_str_equals("text/plain", $event->{descriptionContentType}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_duplicate_recurrence_ids b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_duplicate_recurrence_ids new file mode 100644 index 0000000000..7f6a4806b7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_duplicate_recurrence_ids @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_duplicate_recurrence_ids + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +RECURRENCE-ID;TZID=America/New_York:20210101T060000 +DTSTART;TZID=Europe/Berlin:20210101T120000 +DURATION:PT1H +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:instance1 +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +BEGIN:VEVENT +RECURRENCE-ID;TZID=America/New_York:20210101T060000 +DTSTART;TZID=Europe/Berlin:20210101T120000 +DURATION:PT1H +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:instance2 +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $ical, + 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['title', 'recurrenceId'] + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('instance1', $res->[0][1]{list}[0]{title}); + $self->assert_str_equals('2021-01-01T06:00:00', + $res->[0][1]{list}[0]{recurrenceId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_empty_apple_location b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_empty_apple_location new file mode 100644 index 0000000000..05e63695ee --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_empty_apple_location @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_empty_apple_location + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['locations'], + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_null($res->[0][1]{list}[0]{locations}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_emptyprops b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_emptyprops new file mode 100644 index 0000000000..f751938e3b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_emptyprops @@ -0,0 +1,123 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_emptyprops + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create VEVENT with empty string properties"; + + my $ical = <Request('PUT', '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + xlog "Make sure CalendarEvent/get returns it"; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + }, 'R2'], + ]); + + my $event = $res->[1][1]{list}[0]; + $self->assert_not_null($event); + + xlog "Make sure CalendarEvent/set{update} handles it"; + + # This triggers a specific empty-string related + # bug that only surfaces during update. + $event->{links} = { + links1 => { + href => 'https://example.com/2c505abe', + }, + }; + + delete($event->{blobId}); + delete($event->{debugBlobId}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $event->{id} => $event, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ $event->{id} ], + }, 'R2'], + ]); + + $self->assert(exists $res->[0][1]{updated}{$event->{id}}); + $self->assert_not_null($res->[1][1]{list}[0]{id}); + + xlog "Make sure CalendarEvent/set{create} handles it"; + + $event->{links} = undef; + $event->{uid} = '113a2c25-5458-48ce-9c35-29eb957a4631'; + delete($event->{id}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event2 => $event, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event2}{id}; + $self->assert_not_null($eventId); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [ $eventId ], + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{list}[0]{id}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_endtimezone b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_endtimezone new file mode 100644 index 0000000000..49ebaa5121 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_endtimezone @@ -0,0 +1,22 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_endtimezone + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('endtimezone'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals("2016-09-28T13:00:00", $event->{start}); + $self->assert_str_equals("Europe/London", $event->{timeZone}); + $self->assert_str_equals("PT1H", $event->{duration}); + + my @locations = values %{$event->{locations}}; + $self->assert_num_equals(1, scalar @locations); + $self->assert_str_equals("Europe/Vienna", $locations[0]{timeZone}); + $self->assert_str_equals("end", $locations[0]{relativeTo}); + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_expandrecurrences_date b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_expandrecurrences_date new file mode 100644 index 0000000000..24de90f165 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_expandrecurrences_date @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_expandrecurrences_date + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <Request('PUT', '/dav/calendars/user/cassandane/Default/123456789.ics', + $ical, 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + filter => { + after => '2020-04-21T14:00:00', + before => '2020-04-22T13:59:59', + }, + expandRecurrences => JSON::true, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids', + }, + properties => ['start'], + }, 'R2'], + ]); + $self->assert_str_equals('2020-04-22T00:00:00', $res->[1][1]{list}[0]{start}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_floatingtzid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_floatingtzid new file mode 100644 index 0000000000..954ef1507a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_floatingtzid @@ -0,0 +1,18 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_floatingtzid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('floatingtzid'); + + # As seen in the wild: A floating DTSTART and a DTEND with TZID. + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals("2019-03-10T11:15:00", $event->{start}); + $self->assert_str_equals("Europe/Amsterdam", $event->{timeZone}); + $self->assert_str_equals("PT1H45M", $event->{duration}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ignore_dead_standalone_instance b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ignore_dead_standalone_instance new file mode 100644 index 0000000000..d9753887ae --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ignore_dead_standalone_instance @@ -0,0 +1,102 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_ignore_dead_standalone_instance + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//Fastmail/2020.5/EN +BEGIN:VEVENT +CATEGORIES:CONFERENCE +DESCRIPTION:Be there or be square +DTEND:19960920T220000Z +DTSTAMP:19960704T120000Z +DTSTART:19960919T143000Z +ORGANIZER:MAILTO:jsmith@example.com +RECURRENCE-ID:19960919T143000 +SEQUENCE:0 +SUMMARY:Partyx +TRANSP:OPAQUE +UID:889i-uid1@example.com +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['title', 'recurrenceOverrides'], + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + + $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//Fastmail/2020.5/EN +BEGIN:VEVENT +CATEGORIES:CONFERENCE +DESCRIPTION:Be there or be square +DTEND:19960920T220000Z +DTSTAMP:19960704T120000Z +DTSTART:19960918T143000Z +ORGANIZER:MAILTO:jsmith@example.com +RRULE:FREQ=DAILY +SEQUENCE:0 +SUMMARY:Party +TRANSP:OPAQUE +UID:889i-uid1@example.com +END:VEVENT +BEGIN:VEVENT +CATEGORIES:CONFERENCE +DESCRIPTION:Be there or be square +DTEND:19960920T220000Z +DTSTAMP:19960704T120000Z +DTSTART:19960918T143000Z +ORGANIZER:MAILTO:jsmith@example.com +RECURRENCE-ID:19960919T143000Z +SEQUENCE:1 +SUMMARY:Partyx +TRANSP:OPAQUE +UID:889i-uid1@example.com +END:VEVENT +BEGIN:VEVENT +CATEGORIES:CONFERENCE +DESCRIPTION:Be there or be square +DTEND:19960920T220000Z +DTSTAMP:19960704T120000Z +DTSTART:19960918T143000Z +ORGANIZER:MAILTO:jsmith@example.com +RECURRENCE-ID:19960923T143000Z +SEQUENCE:1 +SUMMARY:Partyx +TRANSP:OPAQUE +UID:889i-uid1@example.com +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['title', 'recurrenceOverrides'], + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('Party', $res->[0][1]{list}[0]{title}); + $self->assert_num_equals(2, scalar keys %{$res->[0][1]{list}[0]{recurrenceOverrides}}); + $self->assert_not_null($res->[0][1]{list}[0]{recurrenceOverrides}{'1996-09-19T14:30:00'}); + $self->assert_not_null($res->[0][1]{list}[0]{recurrenceOverrides}{'1996-09-23T14:30:00'}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ignore_embedded_ianatz b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ignore_embedded_ianatz new file mode 100644 index 0000000000..fbda44efe0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ignore_embedded_ianatz @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_ignore_embedded_ianatz + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog "Create VEVENT with bogus IANA VTIMEZONE"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//foo//bar//EN +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/Vienna +LAST-MODIFIED:20210802T073921Z +X-LIC-LOCATION:Europe/Vienna +BEGIN:STANDARD +TZNAME:-05 +TZOFFSETFROM:-054517 +TZOFFSETTO:-054517 +DTSTART:16010101T000000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20210328T010000 +DTEND;TZID=Europe/Vienna:20210328T040000 +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20201231T230000Z +CREATED:20201231T230000Z +ORGANIZER:mailto:cassandane@example.com +ATTENDEE:mailto:attendee@local +SUMMARY:test +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $ical, + 'Content-Type' => 'text/calendar'); + + xlog "Assert start and duration"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['start', 'duration', 'timeZone'], + }, 'R1'], + ]); + + my $eventId = $res->[0][1]{list}[0]{id}; + $self->assert_str_equals('2021-03-28T01:00:00', $res->[0][1]{list}[0]{start}); + $self->assert_str_equals('PT2H', $res->[0][1]{list}[0]{duration}); + $self->assert_str_equals('Europe/Vienna', $res->[0][1]{list}[0]{timeZone}); + + xlog "Assert timerange query"; + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + filter => { + after => '2021-03-27T23:00:00', + before => '2021-03-28T02:00:00' + }, + }, 'R1'], + ['CalendarEvent/query', { + filter => { + after => '2021-03-28T02:00:00', + before => '2021-03-28T23:00:00' + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$eventId], $res->[0][1]{ids}); + $self->assert_deep_equals([], $res->[1][1]{ids}); + + my @notifs = grep($_->{CLASS} eq 'IMIP', @{$self->{instance}->getnotify()}); + $self->assert_num_equals(1, scalar @notifs); + my $message = decode_json($notifs[0]->{MESSAGE}); + my $event = $message->{patch}; + $self->assert_str_equals('2021-03-28T01:00:00', $event->{start}); + $self->assert_str_equals('PT2H', $event->{duration}); + $self->assert_str_equals('Europe/Vienna', $event->{timeZone}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_isorigin b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_isorigin new file mode 100644 index 0000000000..80b8535914 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_isorigin @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_isorigin + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Create events"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventNoReplyTo => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + title => 'eventNoReplyTo', + start => '2021-01-01T15:30:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + eventIsOrganizer => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + title => 'eventIsOrganizer', + start => '2021-01-01T15:30:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + participant1 => { + sendTo => { + imip => 'mailto:someone@example.com', + }, + }, + }, + }, + eventIsInvitee => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + title => 'eventIsInvitee', + start => '2021-01-01T15:30:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + replyTo => { + imip => 'mailto:someone@example.com', + }, + participants => { + participant1 => { + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + properties => ['isOrigin'], + }, 'R2'], + ]); + + my $eventNoReplyToId = $res->[0][1]{created}{eventNoReplyTo}{id}; + $self->assert_not_null($eventNoReplyToId); + my $eventIsOrganizerId = $res->[0][1]{created}{eventIsOrganizer}{id}; + $self->assert_not_null($eventIsOrganizerId); + my $eventIsInviteeId = $res->[0][1]{created}{eventIsInvitee}{id}; + $self->assert_not_null($eventIsInviteeId); + + $self->assert_equals(JSON::true, $res->[0][1]{created}{eventNoReplyTo}{isOrigin}); + $self->assert_equals(JSON::true, $res->[0][1]{created}{eventIsOrganizer}{isOrigin}); + $self->assert_equals(JSON::false, $res->[0][1]{created}{eventIsInvitee}{isOrigin}); + + my %events = map { $_->{id} => $_ } @{$res->[1][1]{list}}; + $self->assert_equals(JSON::true, $events{$eventNoReplyToId}{isOrigin}); + $self->assert_equals(JSON::true, $events{$eventIsOrganizerId}{isOrigin}); + $self->assert_equals(JSON::false, $events{$eventIsInviteeId}{isOrigin}); + + xlog "Add scheduling to formerly unscheduled event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventNoReplyToId => { + replyTo => { + imip => 'mailto:someone@example.com', + }, + participants => { + participant1 => { + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ $eventNoReplyToId ], + properties => ['isOrigin'], + }, 'R2'], + ]); + + $self->assert_equals(JSON::false, $res->[0][1]{updated}{$eventNoReplyToId}{isOrigin}); + $self->assert_equals(JSON::false, $res->[1][1]{list}[0]{isOrigin}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_keywords b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_keywords new file mode 100644 index 0000000000..a37e8e8017 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_keywords @@ -0,0 +1,18 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_keywords + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('keywords'); + + my $event = $self->putandget_vevent($id, $ical); + my $keywords = { + 'foo' => JSON::true, + 'bar' => JSON::true, + 'baz' => JSON::true, + }; + $self->assert_deep_equals($keywords, $event->{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_links b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_links new file mode 100644 index 0000000000..08b0380896 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_links @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_links + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('links'); + my $uri = "http://jmap.io/spec.html#calendar-events"; + + my $links = { + 'fad3249914b09ede1558fa01004f4f8149559591' => { + '@type' => 'Link', + href => "http://jmap.io/spec.html#calendar-events", + contentType => "text/html", + size => 4480, + title => "the spec", + rel => "enclosure", + cid => '123456789asd', + }, + '113fa6c507397df199a18d1371be615577f9117f' => { + '@type' => 'Link', + href => "http://example.com/some.url", + }, + 'describedby-attach' => { + '@type' => 'Link', + href => "http://describedby/attach", + rel => "describedby", + }, + 'describedby-url' => { + '@type' => 'Link', + href => "http://describedby/url", + rel => 'describedby', + } + }; + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_deep_equals($links, $event->{links}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_location_newline b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_location_newline new file mode 100644 index 0000000000..7e04a7e094 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_location_newline @@ -0,0 +1,21 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_location_newline + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my ($id, $ical) = $self->icalfile('location-newline'); + my $event = $self->putandget_vevent($id, $ical); + my @locations = values(%{$event->{locations}}); + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj == 3 && $min >= 6) { + $self->assert_num_equals(1, scalar @locations); + $self->assert_str_equals("xyz\nxyz", $locations[0]{name}); + } + else { + $self->assert_num_equals(2, scalar @locations); + $self->assert_str_equals("xyz\nxyz", $locations[0]{name}); + $self->assert_str_equals("xyz\nxyz", $locations[1]{name}); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations new file mode 100644 index 0000000000..4c4bac346c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations @@ -0,0 +1,15 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_locations + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('locations'); + + my $event = $self->putandget_vevent($id, $ical); + my @locations = values %{$event->{locations}}; + $self->assert_num_equals(1, scalar @locations); + $self->assert_str_equals("A location with a comma,\nand a newline.", $locations[0]{name}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_apple b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_apple new file mode 100644 index 0000000000..0fac84c55b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_apple @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_locations_apple + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('locations-apple'); + + my $event = $self->putandget_vevent($id, $ical); + my @locations = values %{$event->{locations}}; + $self->assert_num_equals(1, scalar @locations); + $self->assert_str_equals("a place in Vienna", $locations[0]{name}); + $self->assert_str_equals("geo:48.208304,16.371602", $locations[0]{coordinates}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_geo b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_geo new file mode 100644 index 0000000000..e5711ba31f --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_geo @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_locations_geo + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('locations-geo'); + + my $event = $self->putandget_vevent($id, $ical); + my @locations = values %{$event->{locations}}; + $self->assert_num_equals(1, scalar @locations); + $self->assert_matches(qr{\Ageo:37\.38601\d*,-122\.08290\d*\Z}, + $locations[0]{coordinates}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_uri b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_uri new file mode 100644 index 0000000000..89ed622f4d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_locations_uri @@ -0,0 +1,20 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_locations_uri + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('locations-uri'); + + my $event = $self->putandget_vevent($id, $ical); + my @locations = values %{$event->{locations}}; + $self->assert_num_equals(1, scalar @locations); + + $self->assert_str_equals("On planet Earth", $locations[0]->{name}); + + my @links = values %{$locations[0]->{links}}; + $self->assert_num_equals(1, scalar @links); + $self->assert_equals("skype:foo", $links[0]->{href}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ms_timezone b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ms_timezone new file mode 100644 index 0000000000..26f43055eb --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_ms_timezone @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_ms_timezone + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('ms_timezone'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals("2016-09-28T13:00:00", $event->{start}); + $self->assert_str_equals("America/New_York", $event->{timeZone}); + $self->assert_str_equals("PT2H", $event->{duration}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizer b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizer new file mode 100644 index 0000000000..3a7e12ec83 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizer @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_organizer + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('organizer'); + + my $event = $self->putandget_vevent($id, $ical); + my $wantParticipants = { + 'bf8360ce374961f497599431c4bacb50d4a67ca1' => { + '@type' => 'Participant', + name => 'Organizer', + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:organizer@local', + }, + expectReply => JSON::false, + participationStatus => 'needs-action', + }, + '29deb29d758dbb27ffa3c39b499edd85b53dd33f' => { + '@type' => 'Participant', + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee@local', + }, + expectReply => JSON::false, + participationStatus => 'needs-action', + }, + }; + $self->assert_deep_equals($wantParticipants, $event->{participants}); + $self->assert_equals('mailto:organizer@local', $event->{replyTo}{imip}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizer_bogusuri b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizer_bogusuri new file mode 100644 index 0000000000..4cb3edda61 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizer_bogusuri @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_organizer_bogusuri + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + # As seen in the wild: an ORGANIZER/ATTENDEE with a value + # that hasn't even an URI scheme. + + my ($id, $ical) = $self->icalfile('organizer_bogusuri'); + + my $event = $self->putandget_vevent($id, $ical); + + my $wantParticipants = { + '55d3677ce6a79b250d0fc3b5eed5130807d93dd3' => { + '@type' => 'Participant', + name => 'Organizer', + roles => { + 'attendee' => JSON::true, + 'owner' => JSON::true, + }, + sendTo => { + other => '/foo-bar/principal/', + }, + expectReply => JSON::false, + participationStatus => 'needs-action', + }, + '29deb29d758dbb27ffa3c39b499edd85b53dd33f' => { + '@type' => 'Participant', + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee@local', + }, + expectReply => JSON::false, + participationStatus => 'needs-action', + }, + }; + $self->assert_deep_equals($wantParticipants, $event->{participants}); + $self->assert_null($event->{replyTo}{imip}); + $self->assert_str_equals('/foo-bar/principal/', $event->{replyTo}{other}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizermailto b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizermailto new file mode 100644 index 0000000000..d72db3d6c3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_organizermailto @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_organizermailto + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('organizermailto'); + + my $event = $self->putandget_vevent($id, $ical); + + my $wantParticipants = { + 'bf8360ce374961f497599431c4bacb50d4a67ca1' => { + '@type' => 'Participant', + name => 'Organizer', + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:organizer@local', + }, + expectReply => JSON::false, + participationStatus => 'needs-action', + }, + '29deb29d758dbb27ffa3c39b499edd85b53dd33f' => { + '@type' => 'Participant', + name => 'Attendee', + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee@local', + }, + expectReply => JSON::false, + participationStatus => 'needs-action', + }, + }; + $self->assert_deep_equals($wantParticipants, $event->{participants}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_participants b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_participants new file mode 100644 index 0000000000..f554da8677 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_participants @@ -0,0 +1,90 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_participants + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('participants'); + + my $event = $self->putandget_vevent($id, $ical); + + my $wantParticipants = { + '375507f588e65ec6eb800757ab94ccd10ad58599' => { + '@type' => 'Participant', + name => 'Monty Burns', + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + participationStatus => 'accepted', + sendTo => { + imip => 'mailto:smithers@example.com', + }, + expectReply => JSON::false, + }, + '39b16b858076733c1d890cbcef73eca0e874064d' => { + '@type' => 'Participant', + name => 'Homer Simpson', + participationStatus => 'accepted', + roles => { + 'optional' => JSON::true, + }, + locationId => 'loc1', + sendTo => { + imip => 'mailto:homer@example.com', + }, + expectReply => JSON::false, + }, + 'carl' => { + '@type' => 'Participant', + name => 'Carl Carlson', + participationStatus => 'tentative', + roles => { + 'attendee' => JSON::true, + }, + scheduleSequence => 3, + scheduleUpdated => '2017-01-02T03:04:05Z', + delegatedFrom => { + 'a6ef900d284067bb327d7be1469fb44693a5ec13' => JSON::true, + }, + sendTo => { + imip => 'mailto:carl@example.com', + }, + expectReply => JSON::false, + }, + 'a6ef900d284067bb327d7be1469fb44693a5ec13' => { + '@type' => 'Participant', + name => 'Lenny Leonard', + participationStatus => 'delegated', + roles => { + 'attendee' => JSON::true, + }, + delegatedTo => { + 'carl' => JSON::true, + }, + sendTo => { + imip => 'mailto:lenny@example.com', + }, + expectReply => JSON::false, + }, + 'd6db3540fe51335b7154f144456e9eac2778fc8f' => { + '@type' => 'Participant', + name => 'Larry Burns', + participationStatus => 'declined', + roles => { + 'attendee' => JSON::true, + }, + memberOf => { + '29a545214b66cbd7635fdec3a35d074ff3484479' => JSON::true, + }, + scheduleUpdated => '2015-09-29T14:44:23Z', + sendTo => { + imip => 'mailto:larry@example.com', + }, + expectReply => JSON::false, + }, + }; + $self->assert_deep_equals($wantParticipants, $event->{participants}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy new file mode 100644 index 0000000000..181338c988 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy @@ -0,0 +1,14 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_privacy + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('privacy'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals("private", $event->{privacy}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy_ignore_override b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy_ignore_override new file mode 100644 index 0000000000..75162b430c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy_ignore_override @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_privacy_ignore_override + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "PUT event where privacy differs in override"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTSTAMP:20210923T034327Z +DTSTART;TZID=American/New_York:20210101T153000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=3 +SUMMARY:An Event +SEQUENCE:1 +X-JMAP-PRIVACY:PRIVATE +END:VEVENT +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +RECURRENCE-ID:20210102T153000 +DTSTAMP:20210923T034327Z +DTSTART;TZID=American/New_York:20210102T153000 +DURATION:PT1H +SUMMARY:An event exception +SEQUENCE:1 +X-JMAP-PRIVACY:PUBLIC +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + xlog "Assert privacy of recurrence exception gets ignored"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['privacy', 'recurrenceOverrides'] + }, 'R1'], + ]); + $self->assert_str_equals('private', $res->[0][1]{list}[0]{privacy}); + $self->assert_deep_equals({ title => 'An event exception'}, + $res->[0][1]{list}[0]{recurrenceOverrides}{'2021-01-02T15:30:00'}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy_shared b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy_shared new file mode 100644 index 0000000000..cceb780673 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_privacy_shared @@ -0,0 +1,210 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_privacy_shared + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "share calendar"; + my ($shareeJmap) = $self->create_user('sharee'); + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + sharee => { + mayReadItems => JSON::true, + mayWriteAll => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "create fullblown event for each privacy setting"; + my $eventTemplate = { + calendarIds => { + 'Default' => JSON::true, + }, + color => 'blue', + created => '2020-12-21T07:47:00Z', + description => 'description', + duration => 'PT1H', + excludedRecurrenceRules => [{ + frequency => 'daily', + interval => 2, + count => 1, + }], + keywords => { + keyword1 => JSON::true, + }, + links => { + link1 => { + href => 'https://local/link1.jpg', + }, + }, + locale => 'en', + locations => { + loc1 => { + name => 'name', + }, + }, + participants => { + participant1 => { + sendTo => { + imip => 'mailto:participant1@local', + }, + roles => { + attendee => JSON::true, + }, + }, + }, + priority => 7, + prodId => '-//Foo//Bar//EN', + recurrenceOverrides => { + '2021-01-02T01:00:00' => { + title => 'overrideTitle', + duration => 'PT2H', + }, + }, + recurrenceRules => [{ + frequency => 'daily', + count => 3, + }], + relatedTo => { + '3a996522-dfc3-484c-bea9-070c408143ea' => { }, + }, + replyTo => { + imip => 'mailto:orga@local', + }, + sequence => 3, + showWithoutTime => JSON::true, + start => '2021-01-01T01:00:00', + status => 'tentative', + timeZone => 'Europe/Berlin', + title => 'title', + updated => '2020-12-21T07:47:00Z', + virtualLocations => { + virtloc1 => { + name => 'name', + uri => 'tel:+1-555-555-5555', + }, + }, + }; + + my @wantProperties = keys %{$eventTemplate}; + push @wantProperties, 'calendarIds', 'isDraft', 'utcStart', 'utcEnd'; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + publicEvent => { ( privacy => 'public' ), %$eventTemplate }, + privateEvent => { ( privacy => 'private' ), %$eventTemplate }, + secretEvent => { ( privacy => 'secret' ), %$eventTemplate } + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#publicEvent'], + properties => \@wantProperties, + }, 'R2'], + ['CalendarEvent/get', { + ids => ['#privateEvent'], + properties => \@wantProperties, + }, 'R3'], + ['CalendarEvent/get', { + ids => ['#secretEvent'], + properties => \@wantProperties, + }, 'R4'], + ]); + + my $publicEvent = $res->[1][1]{list}[0]; + $self->assert_not_null($publicEvent); + + my $privateEvent = $res->[2][1]{list}[0]; + $self->assert_not_null($privateEvent); + + my $secretEvent = $res->[3][1]{list}[0]; + $self->assert_not_null($secretEvent); + + xlog "calendar owner may see all events and properties"; + foreach my $event ($publicEvent, $privateEvent, $secretEvent) { + foreach my $prop (keys %{$eventTemplate}) { + $self->assert_not_null($event->{$prop}); + } + } + + xlog "sharee may see all properties of public event"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$publicEvent->{id}], + properties => \@wantProperties, + }, 'R1'], + ]); + my $sharedPublicEvent = $res->[0][1]{list}[0]; + delete($publicEvent->{'x-href'}); + delete($sharedPublicEvent->{'x-href'}); + delete($publicEvent->{'blobId'}); + delete($sharedPublicEvent->{'blobId'}); + delete($publicEvent->{'debugBlobId'}); + delete($sharedPublicEvent->{'debugBlobId'}); + $self->assert_deep_equals($publicEvent, $sharedPublicEvent); + + xlog "sharee may only see public properties of private event"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$privateEvent->{id}], + properties => \@wantProperties, + }, 'R1'], + ]); + my $sharedPrivateEvent = $res->[0][1]{list}[0]; + my %publicProps = ( + '@type' => 1, + calendarIds => 1, + created => 1, + due => 1, + duration => 1, + estimatedDuration => 1, + excludedRecurrenceRules => 1, + freeBusyStatus => 1, + id => 1, + isDraft => 1, + privacy => 1, + recurrenceRules => 1, + recurrenceOverrides => 1, + sequence => 1, + showWithoutTime => 1, + start => 1, + timeZone => 1, + timeZones => 1, + uid => 1, + updated => 1, + utcStart => 1, + utcEnd => 1, + ); + my @nonPublic; + foreach my $prop (keys %{$privateEvent}) { + if (not $publicProps{$prop}) { + push @nonPublic, $prop; + } + } + delete @{$privateEvent}{@nonPublic}; + delete $privateEvent->{recurrenceOverrides}{'2021-01-02T01:00:00'}{title}; + $self->assert_deep_equals($privateEvent, $sharedPrivateEvent); + + xlog "sharee must not see secret event"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$secretEvent->{id}], + properties => \@wantProperties, + }, 'R1'], + ]); + $self->assert_deep_equals([$secretEvent->{id}], $res->[0][1]{notFound}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_properties b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_properties new file mode 100644 index 0000000000..dd5992db33 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_properties @@ -0,0 +1,18 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_properties + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('simple'); + + my $event = $self->putandget_vevent($id, $ical, ["x-href", "calendarIds"]); + $self->assert_not_null($event); + $self->assert_not_null($event->{id}); + $self->assert_not_null($event->{uid}); + $self->assert_not_null($event->{"x-href"}); + $self->assert_not_null($event->{calendarIds}); + $self->assert_num_equals(5, scalar keys %$event); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_rdate_period b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_rdate_period new file mode 100644 index 0000000000..d2ae26b10c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_rdate_period @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_rdate_period + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('rdate_period'); + + my $event = $self->putandget_vevent($id, $ical); + my $o; + + $o = $event->{recurrenceOverrides}->{"2016-03-04T15:00:00"}; + $self->assert_not_null($o); + $self->assert_str_equals("PT1H", $o->{duration}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrence b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrence new file mode 100644 index 0000000000..c727f08a14 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrence @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_recurrence + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('recurrence'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event->{recurrenceRules}[0]); + $self->assert_str_equals("RecurrenceRule", $event->{recurrenceRules}[0]{q{@type}}); + $self->assert_str_equals("monthly", $event->{recurrenceRules}[0]{frequency}); + $self->assert_str_equals("gregorian", $event->{recurrenceRules}[0]{rscale}); + # This assertion is a bit brittle. It depends on the libical-internal + # sort order for BYDAY + $self->assert_deep_equals([{ + '@type' => 'NDay', + "day" => "mo", + "nthOfPeriod" => 2, + }, { + '@type' => 'NDay', + "day" => "mo", + "nthOfPeriod" => 1, + }, { + '@type' => 'NDay', + "day" => "tu", + }, { + '@type' => 'NDay', + "day" => "th", + "nthOfPeriod" => -2, + }, { + '@type' => 'NDay', + "day" => "sa", + "nthOfPeriod" => -1, + }, { + '@type' => 'NDay', + "day" => "su", + "nthOfPeriod" => -3, + }], $event->{recurrenceRules}[0]{byDay}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceid new file mode 100644 index 0000000000..fed07a280a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceid @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_recurrenceid + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <Request('PUT', 'Default/2a358cee-6489-4f14-a57f-c104db4dc357.ics', $ical, + 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => [ + 'recurrenceId', + 'recurrenceIdTimeZone', + 'start', + 'timeZone', + ], + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + my $event = $res->[1][1]{list}[0]; + + $self->assert_str_equals('2016-09-28T16:00:00', $event->{start}); + $self->assert_str_equals('Europe/Berlin', $event->{timeZone}); + $self->assert_str_equals('2016-09-28T01:00:00', $event->{recurrenceId}); + $self->assert_str_equals('Europe/London', $event->{recurrenceIdTimeZone}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceid_date_start_datetime b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceid_date_start_datetime new file mode 100644 index 0000000000..b24a3f529a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceid_date_start_datetime @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_recurrenceid_date_start_datetime + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <putandget_vevent('2a358cee-6489-4f14-a57f-c104db4dc357', + $ical, ['recurrenceOverrides']); + + $self->assert_deep_equals({ + '2016-09-02T16:15:14' => { + title => 'testWithDateTimeRecurId', + }, + '2016-09-03T16:15:14' => { + title => 'testWithDateRecurId', + }, + }, $event->{recurrenceOverrides}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceinstances b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceinstances new file mode 100644 index 0000000000..53a0d47d61 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceinstances @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_recurrenceinstances + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + xlog $self, "create event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + uid => 'event1uid', + title => "event1", + description => "", + freeBusyStatus => "busy", + start => "2019-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'weekly', + count => 5, + }, { + frequency => 'daily', + count => 2, + }], + recurrenceOverrides => { + '2019-01-15T09:00:00' => { + title => 'override1', + }, + '2019-01-10T12:00:00' => { + # rdate + }, + '2019-01-22T09:00:00' => { + excluded => JSON::true, + }, + }, + }, + } + }, 'R1'] + ]); + + my @ids = ( + encode_eventid('event1uid','20190108T090000'), + encode_eventid('event1uid','20190115T090000'), + encode_eventid('event1uid','20190110T120000'), + encode_eventid('event1uid','20190122T090000'), # is excluded + encode_eventid('event1uid','20191201T090000'), # does not exist + encode_eventid('event1uid','20190102T090000'), # from second rrule + ); + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => \@ids, + properties => ['start', 'title', 'recurrenceId'], + }, 'R1'], + ]); + $self->assert_num_equals(4, scalar @{$res->[0][1]{list}}); + + $self->assert_str_equals($ids[0], $res->[0][1]{list}[0]{id}); + $self->assert_str_equals('2019-01-08T09:00:00', $res->[0][1]{list}[0]{start}); + $self->assert_str_equals('2019-01-08T09:00:00', $res->[0][1]{list}[0]{recurrenceId}); + + $self->assert_str_equals($ids[1], $res->[0][1]{list}[1]{id}); + $self->assert_str_equals('override1', $res->[0][1]{list}[1]{title}); + $self->assert_str_equals('2019-01-15T09:00:00', $res->[0][1]{list}[1]{start}); + $self->assert_str_equals('2019-01-15T09:00:00', $res->[0][1]{list}[1]{recurrenceId}); + + $self->assert_str_equals($ids[2], $res->[0][1]{list}[2]{id}); + $self->assert_str_equals('2019-01-10T12:00:00', $res->[0][1]{list}[2]{start}); + $self->assert_str_equals('2019-01-10T12:00:00', $res->[0][1]{list}[2]{recurrenceId}); + + $self->assert_str_equals($ids[5], $res->[0][1]{list}[3]{id}); + $self->assert_str_equals('2019-01-02T09:00:00', $res->[0][1]{list}[3]{start}); + $self->assert_str_equals('2019-01-02T09:00:00', $res->[0][1]{list}[3]{recurrenceId}); + + $self->assert_num_equals(2, scalar @{$res->[0][1]{notFound}}); + $self->assert_str_equals($ids[3], $res->[0][1]{notFound}[0]); + $self->assert_str_equals($ids[4], $res->[0][1]{notFound}[1]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceoverrides b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceoverrides new file mode 100644 index 0000000000..18d4fc4549 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceoverrides @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_recurrenceoverrides + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('recurrenceoverrides'); + my $aid = $id . "-alarmuid"; + + my $event = $self->putandget_vevent($id, $ical); + my $o; + + $o = $event->{recurrenceOverrides}->{"2016-12-24T20:00:00"}; + $self->assert_not_null($o); + + $self->assert(exists $event->{recurrenceOverrides}->{"2016-02-01T13:00:00"}); + $self->assert_equals(JSON::true, $event->{recurrenceOverrides}->{"2016-02-01T13:00:00"}{excluded}); + + $o = $event->{recurrenceOverrides}->{"2016-05-01T13:00:00"}; + $self->assert_not_null($o); + $self->assert_str_equals("foobarbazbla", $o->{"title"}); + $self->assert_str_equals("2016-05-01T17:00:00", $o->{"start"}); + $self->assert_str_equals("PT2H", $o->{"duration"}); + $self->assert_not_null($o->{alerts}{$aid}); + + $o = $event->{recurrenceOverrides}->{"2016-09-01T13:00:00"}; + $self->assert_not_null($o); + $self->assert_str_equals("foobarbazblabam", $o->{"title"}); + $self->assert(not exists $o->{"start"}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceoverrides_before_after b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceoverrides_before_after new file mode 100644 index 0000000000..5f5289c681 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_recurrenceoverrides_before_after @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_recurrenceoverrides_before_after + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create events"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uidlocal', + title => "event1", + start => "2020-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'daily', + }], + recurrenceOverrides => { + '2020-01-02T09:00:00' => { + title => 'override1', + }, + '2020-01-03T09:00:00' => { + title => 'override2', + }, + '2020-01-04T09:00:00' => { + title => 'override3', + }, + '2020-01-05T09:00:00' => { + title => 'override4', + }, + }, + }, + } + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#1'], + properties => ['recurrenceOverrides'], + recurrenceOverridesAfter => '2020-01-03T08:00:00Z', + recurrenceOverridesBefore => '2020-01-05T08:00:00Z', + }, 'R2'], + ]); + + $self->assert_deep_equals({ + '2020-01-03T09:00:00' => { + title => 'override2', + }, + '2020-01-04T09:00:00' => { + title => 'override3', + }, + }, $res->[1][1]{list}[0]{recurrenceOverrides}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_reducepartitipants b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_reducepartitipants new file mode 100644 index 0000000000..d0e62506fc --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_reducepartitipants @@ -0,0 +1,110 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_reducepartitipants + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uidlocal', + title => "event1", + start => "2020-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + replyTo => { + imip => 'mailto:owner@example.com', + }, + participants => { + owner => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:owner@example.com', + }, + }, + attendee1 => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee1@example.com', + }, + }, + attendee2 => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee2@example.com', + }, + }, + cassandane => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event1'], + reduceParticipants => JSON::true, + properties => ['participants'], + }, 'R2'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + my $wantUris = { + 'mailto:owner@example.com' => 1, + 'mailto:cassandane@example.com' => 1, + }; + my %haveUris = map { $_->{sendTo}{imip} => 1 } + values %{$res->[1][1]{list}[0]{participants}}; + $self->assert_deep_equals($wantUris, \%haveUris); + + $caldav->Request( + 'PROPPATCH', + '', + x('D:propertyupdate', $caldav->NS(), + x('D:set', + x('D:prop', + x('C:calendar-user-address-set', + x('D:href', 'attendee1@example.com'), + ) + ) + ) + ) + ); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$eventId], + reduceParticipants => JSON::true, + properties => ['participants'], + }, 'R1'], + ]); + $wantUris = { + 'mailto:owner@example.com' => 1, + 'mailto:attendee1@example.com' => 1, + }; + %haveUris = map { $_->{sendTo}{imip} => 1 } + values %{$res->[0][1]{list}[0]{participants}}; + $self->assert_deep_equals($wantUris, \%haveUris); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_relatedto b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_relatedto new file mode 100644 index 0000000000..aabf47a1d9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_relatedto @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_relatedto + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('relatedto'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals($id, $event->{uid}); + $self->assert_deep_equals({ + "58ADE31-001" => { + '@type' => 'Relation', + relation => { + 'first' => JSON::true, + } + }, + "58ADE31-003" => { + '@type' => 'Relation', + relation => { + 'next' => JSON::true, + } + }, + "foo" => { + '@type' => 'Relation', + relation => { + 'x-unknown1' => JSON::true, + 'x-unknown2' => JSON::true, + } + }, + "bar" => { + '@type' => 'Relation', + relation => {} + }, + }, $event->{relatedTo}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_reset_iter b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_reset_iter new file mode 100644 index 0000000000..02504c99c1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_reset_iter @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_reset_iter + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Create events in calendar A and B, both have IMAP uid 1"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendarA => { + name => 'A', + }, + calendarB => { + name => 'B', + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + create => { + eventA => { + calendarIds => { + '#calendarA' => JSON::true, + }, + '@type' => 'Event', + uid => 'eventA-uid', + title => 'eventA', + start => '2021-01-01T15:30:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + eventB => { + calendarIds => { + '#calendarB' => JSON::true, + }, + '@type' => 'Event', + uid => 'eventB-uid', + title => 'eventB', + start => '2022-01-01T15:30:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + }, + }, 'R2'], + ['CalendarEvent/get', { + properties => ['calendarIds', 'uid', 'title', 'start'], + }, 'R2'], + ]); + + xlog "Assert CalendarEvent/get iterator state is reset properly"; + $self->assert_num_equals(2, scalar @{$res->[2][1]{list}}); + $self->assert_str_not_equals((keys %{$res->[2][1]{list}[0]{calendarIds}})[0], + (keys %{$res->[2][1]{list}[1]{calendarIds}})[0]); + $self->assert_str_not_equals($res->[2][1]{list}[0]{uid}, + $res->[2][1]{list}[1]{uid}); + $self->assert_str_not_equals($res->[2][1]{list}[0]{title}, + $res->[2][1]{list}[1]{title}); + $self->assert_str_not_equals($res->[2][1]{list}[0]{start}, + $res->[2][1]{list}[1]{start}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_rscale b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_rscale new file mode 100644 index 0000000000..949e699080 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_rscale @@ -0,0 +1,19 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_rscale + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('rscale'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_str_equals("Some day in Adar I", $event->{title}); + $self->assert_str_equals("yearly", $event->{recurrenceRules}[0]{frequency}); + $self->assert_str_equals("hebrew", $event->{recurrenceRules}[0]{rscale}); + $self->assert_str_equals("forward", $event->{recurrenceRules}[0]{skip}); + $self->assert_num_equals(8, $event->{recurrenceRules}[0]{byMonthDay}[0]); + $self->assert_str_equals("5L", $event->{recurrenceRules}[0]{byMonth}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sanitize_geouri b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sanitize_geouri new file mode 100644 index 0000000000..d1ef7657a6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sanitize_geouri @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_sanitize_geouri + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "Create event with TEXT-escaped geo: URIs"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DURATION:PT1H +UID:40d6fe3c-6a51-489e-823e-3ea22f427a3e +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:test +X-APPLE-STRUCTURED-LOCATION + ;X-JMAP-ID=location1 + ;VALUE=URI;X-APPLE-RADIUS=141.175139;X-TITLE=test1 + :geo:13.4125\,103.8667 +LOCATION + ;X-JMAP-ID=location1 + :test1 +X-JMAP-LOCATION + ;X-JMAP-ID=location2 + ;VALUE=TEXT;X-JMAP-GEO="geo:14.4125\,104.8667" + :test2 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + my $res = $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + xlog $self, "Assert text-escaped geo: URI values are sanitized when read"; + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['locations'], + }, 'R1'] + ]); + $self->assert_str_equals('geo:13.4125,103.8667', + $res->[0][1]{list}[0]{locations}{location1}{coordinates}); + $self->assert_str_equals('geo:14.4125,104.8667', + $res->[0][1]{list}[0]{locations}{location2}{coordinates}); + + xlog $self, "Assert text-escaped geo: URI values are rejected when set"; + + my $eventId = $res->[0][1]{list}[0]{id}; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'locations/location1/coordinates' => 'geo:13.4125\,103.8667', + } + }, + }, 'R1'] + ]); + $self->assert_deep_equals(['locations/location1/coordinates'], + $res->[0][1]{notUpdated}{$eventId}{properties}); + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'locations/location2/coordinates' => 'geo:14.4125\,104.8667', + } + }, + }, 'R1'] + ]); + $self->assert_deep_equals(['locations/location2/coordinates'], + $res->[0][1]{notUpdated}{$eventId}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sentby_caldav b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sentby_caldav new file mode 100644 index 0000000000..b79dcc780d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sentby_caldav @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_sentby_caldav + :needs_component_httpd :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/testitip.ics', + $ical, 'Content-Type' => 'text/calendar', + 'Schedule-Sender-Address' => 'sender@example.net', + 'Schedule-Sender-Name' => 'Sally Sender', + ); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'sentBy'] + }, 'R1'], + ]); + $self->assert_str_equals('sender@example.net', $res->[0][1]{list}[0]{sentBy}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sentby_imip b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sentby_imip new file mode 100644 index 0000000000..67bfcb15f9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_sentby_imip @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_sentby_imip + :needs_component_sieve :needs_component_httpd :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <6de280c9-edff-4019-8ebd-cfebc73f8201@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: 6de280c9-edff-4019-8ebd-cfebc73f8201 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'sentBy'] + }, 'R1'], + ]); + $self->assert_str_equals('sender@example.net', $res->[0][1]{list}[0]{sentBy}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_simple b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_simple new file mode 100644 index 0000000000..e9d7410c39 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_simple @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_simple + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($uid, $ical) = $self->icalfile('simple'); + + my $event = $self->putandget_vevent($uid, $ical); + $self->assert_not_null($event); + $self->assert_str_equals('Event', $event->{q{@type}}); + $self->assert_str_equals(encode_eventid($uid), $event->{id}); + $self->assert_str_equals($uid, $event->{uid}); + $self->assert_null($event->{relatedTo}); + $self->assert_str_equals("yo", $event->{title}); + $self->assert_str_equals("-//Apple Inc.//Mac OS X 10.9.5//EN", $event->{prodId}); + $self->assert_str_equals("en", $event->{locale}); + $self->assert_str_equals("turquoise", $event->{color}); + $self->assert_str_equals("double yo", $event->{description}); + $self->assert_str_equals("text/plain", $event->{descriptionContentType}); + $self->assert_equals($event->{freeBusyStatus}, "free"); + $self->assert_equals($event->{showWithoutTime}, JSON::false); + $self->assert_str_equals("2016-09-28T16:00:00", $event->{start}); + $self->assert_str_equals("Etc/UTC", $event->{timeZone}); + $self->assert_str_equals("PT1H", $event->{duration}); + $self->assert_str_equals("2015-09-28T12:52:12Z", $event->{created}); + $self->assert_str_equals("2015-09-28T13:24:34Z", $event->{updated}); + $self->assert_num_equals(9, $event->{sequence}); + $self->assert_num_equals(3, $event->{priority}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_standalone_instances b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_standalone_instances new file mode 100644 index 0000000000..c81f3107a9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_standalone_instances @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_standalone_instances + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +RECURRENCE-ID;TZID=America/New_York:20210101T060000 +DTSTART;TZID=Europe/Berlin:20210101T120000 +DURATION:PT1H +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:instance1 +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +BEGIN:VEVENT +RECURRENCE-ID;TZID=America/New_York:20210301T060000 +DTSTART;TZID=America/New_York:20210301T080000 +DURATION:PT1H +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:instance2 +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $ical, + 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => [ + 'recurrenceId', + 'recurrenceIdTimeZone', + 'start', + 'timeZone', + 'title', + 'uid', + ], + }, 'R2'], + ]); + + my %events = map { $_->{title} => $_ } @{$res->[1][1]{list}}; + $self->assert_num_equals(2, scalar keys %events); + $self->assert_str_not_equals($events{instance1}{id}, $events{instance2}{id}); + + $self->assert_str_equals('2021-01-01T12:00:00', + $events{instance1}{start}); + $self->assert_str_equals('Europe/Berlin', + $events{instance1}{timeZone}); + $self->assert_str_equals('2021-01-01T06:00:00', + $events{instance1}{recurrenceId}); + $self->assert_str_equals('America/New_York', + $events{instance1}{recurrenceIdTimeZone}); + $self->assert_str_equals('2a358cee-6489-4f14-a57f-c104db4dc357', + $events{instance1}{uid}); + + $self->assert_str_equals('2021-03-01T08:00:00', + $events{instance2}{start}); + $self->assert_str_equals('America/New_York', + $events{instance2}{timeZone}); + $self->assert_str_equals('2021-03-01T06:00:00', + $events{instance2}{recurrenceId}); + $self->assert_str_equals('America/New_York', + $events{instance2}{recurrenceIdTimeZone}); + $self->assert_str_equals('2a358cee-6489-4f14-a57f-c104db4dc357', + $events{instance2}{uid}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_standalone_instances_multi b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_standalone_instances_multi new file mode 100644 index 0000000000..7fd26146be --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_standalone_instances_multi @@ -0,0 +1,56 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_standalone_instances_multi + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $now = DateTime->now(); + $now->set_time_zone('Etc/UTC'); + my $dtstamp = $now->strftime('%Y%m%dT%H%M%SZ'); + + my $n = 700; + + my $ical = <clone(); + $t->add(DateTime::Duration->new(days => $i)); + my $recurid = $t->strftime('%Y%m%dT%H%M%SZ'); + + $ical .= <Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['recurrenceId'], + }, 'R1'], + ]); + $self->assert_num_equals($n, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_utcstart b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_utcstart new file mode 100644 index 0000000000..ae971b80ed --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_utcstart @@ -0,0 +1,107 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_utcstart + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Initialize calendar timezone. + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + timeZone => 'America/New_York', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + # Assert utcStart for main event and recurrenceOverrides. + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2019-12-06T11:21:01", + duration => "PT5M", + timeZone => "Europe/Vienna", + recurrenceRules => [{ + frequency => 'daily', + count => 3, + }], + recurrenceOverrides => { + '2019-12-07T11:21:01.8' => { + start => '2019-12-07T13:00:00', + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#1'], + properties => ['utcStart', 'utcEnd', 'recurrenceOverrides'], + }, 'R2'] + ]); + my $eventId1 = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId1); + my $event = $res->[1][1]{list}[0]; + $self->assert_not_null($event); + + $self->assert_str_equals('2019-12-06T10:21:01Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-06T10:26:01Z', $event->{utcEnd}); + $self->assert_str_equals('2019-12-07T12:00:00Z', + $event->{recurrenceOverrides}{'2019-12-07T11:21:01'}{utcStart}); + $self->assert_str_equals('2019-12-07T12:05:00Z', + $event->{recurrenceOverrides}{'2019-12-07T11:21:01'}{utcEnd}); + + # Assert utcStart for regular recurrence instance. + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [encode_eventid('eventuid1local', '20191208T112101')], + properties => ['utcStart', 'utcEnd'], + }, 'R2'] + ]); + $event = $res->[0][1]{list}[0]; + $self->assert_not_null($event); + + $self->assert_str_equals('2019-12-08T10:21:01Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-08T10:26:01Z', $event->{utcEnd}); + + # Assert utcStart for floating event with calendar timeZone. + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 2 => { + uid => 'eventuid2local', + calendarIds => { + Default => JSON::true, + }, + title => "event2", + start => "2019-12-08T23:30:00", + duration => "PT2H", + timeZone => undef, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#2'], + properties => ['utcStart', 'utcEnd', 'timeZone'], + }, 'R2'] + ]); + my $eventId2 = $res->[0][1]{created}{2}{id}; + $self->assert_not_null($eventId2); + $event = $res->[1][1]{list}[0]; + $self->assert_not_null($event); + + # Floating event time falls back to calendar time zone America/New_York. + $self->assert_str_equals('2019-12-09T04:30:00Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-09T06:30:00Z', $event->{utcEnd}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_utctime_with_tzid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_utctime_with_tzid new file mode 100644 index 0000000000..1ce7e8c5b6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_utctime_with_tzid @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_utctime_with_tzid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + # As seen on the wires... + my ($id, $ical) = $self->icalfile('utctime-with-tzid'); + + my $event = $self->putandget_vevent($id, $ical, ['timeZone', 'start', 'duration']); + $self->assert_not_null($event); + $self->assert_str_equals('Europe/Vienna', $event->{timeZone}); + $self->assert_str_equals('2019-12-19T19:00:00', $event->{start}); + $self->assert_str_equals('PT2H20M', $event->{duration}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_virtuallocations_conference b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_virtuallocations_conference new file mode 100644 index 0000000000..ef520446e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_get_virtuallocations_conference @@ -0,0 +1,22 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_get_virtuallocations_conference + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my ($id, $ical) = $self->icalfile('locations-conference'); + + my $event = $self->putandget_vevent($id, $ical); + my $virtualLocations = $event->{virtualLocations}; + $self->assert_num_equals(2, scalar (values %{$virtualLocations})); + + my $loc1 = $virtualLocations->{loc1}; + $self->assert_str_equals('Moderator dial-in', $loc1->{name}); + $self->assert_str_equals('tel:+123451', $loc1->{uri}); + + my $loc2 = $virtualLocations->{loc2}; + $self->assert_str_equals('Chat room', $loc2->{name}); + $self->assert_str_equals('xmpp:chat123@conference.example.com', $loc2->{uri}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz new file mode 100644 index 0000000000..9db8c8c9e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_guesstz + :min_version_3_5 :needs_component_jmap :needs_dependency_guesstz +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $eventId = '123456789'; + my $ical = <putandget_vevent($eventId, + $ical, ['timeZone']); + $self->assert_str_equals('America/New_York', $event->{timeZone}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_etc_gmt b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_etc_gmt new file mode 100644 index 0000000000..1542ce8e7f --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_etc_gmt @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_guesstz_etc_gmt + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "PUT non-IANA VTIMEZONE with an unknown UTC offset"; + + my $ical = <<'EOF'; +BEGIN:VCALENDAR +PRODID:Xxx +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:foo +LAST-MODIFIED:20221209T093419Z +X-PROLEPTIC-TZNAME:LMT +BEGIN:STANDARD +TZNAME:-0930 +TZOFFSETFROM:-0918 +TZOFFSETTO:-0935 +DTSTART:19121001T000000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +SUMMARY:test +DTSTART;TZID=foo:20230124T160000 +DTEND;TZID=foo:20230124T163000 +UID:8d5eabe8-88c4-4b6a-87b9-6b6a27d253c1 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20230119T221533Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +EOF + + my $href = '/dav/calendars/user/cassandane/Default/test.ics'; + $caldav->Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar', + ); + + xlog $self, "Assert VTIMEZONE converts to IANA timezone"; + + my $res = $jmap->CallMethods([ + [ 'CalendarEvent/get', { + properties => ['timeZone'], + }, 'R1'] + ]); + $self->assert_str_equals('Etc/GMT+10', $res->[0][1]{list}[0]{timeZone}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_gmt b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_gmt new file mode 100644 index 0000000000..0cd394b14b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_gmt @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_guesstz_gmt + :min_version_3_5 :needs_component_jmap :needs_dependency_guesstz +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $eventId = '123456789'; + my $ical = <putandget_vevent($eventId, + $ical, ['timeZone']); + $self->assert_str_equals('Etc/GMT+8', $event->{timeZone}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_ignore_xjmapid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_ignore_xjmapid new file mode 100644 index 0000000000..f9bd0ce6c5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_ignore_xjmapid @@ -0,0 +1,90 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_guesstz_ignore_xjmapid + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "PUT non-IANA VTIMEZONE with a X-JMAP-ID"; + + my $ical = <<'EOF'; +BEGIN:VCALENDAR +PRODID:Microsoft Exchange Server 2010 +VERSION:2.0 +BEGIN:VTIMEZONE +X-JMAP-ID:/(UTC-05:00) Eastern Time (US & Canada) +TZID:(UTC-05:00) Eastern Time (US & Canada) +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 +SUMMARY:test +DTSTART;TZID="(UTC-05:00) Eastern Time (US & Canada)":20230124T160000 +DTEND;TZID="(UTC-05:00) Eastern Time (US & Canada)":20230124T163000 +UID:8d5eabe8-88c4-4b6a-87b9-6b6a27d253c1 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20230119T221533Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +EOF + + my $href = '/dav/calendars/user/cassandane/Default/test.ics'; + $caldav->Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar', + ); + + xlog $self, "Assert VTIMEZONE converts to IANA timezone"; + + my $res = $jmap->CallMethods([ + [ 'CalendarEvent/get', { + properties => ['timeZone'], + }, 'R1'] + ]); + $self->assert_str_equals('America/New_York', + $res->[0][1]{list}[0]{timeZone}); + + my $eventId = $res->[0][1]{list}[0]{id}; + $res = $jmap->CallMethods([ + [ 'CalendarEvent/set', { + update => { + $eventId => { + title => 'test2', + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + $res = $jmap->CallMethods([ + [ 'CalendarEvent/get', { + properties => ['title', 'timeZone'], + }, 'R1'] + ]); + $self->assert_str_equals('test2', + $res->[0][1]{list}[0]{title}); + $self->assert_str_equals('America/New_York', + $res->[0][1]{list}[0]{timeZone}); + + xlog "Assert non-IANA VTIMEZONE is kept in iCalendar"; + $res = $caldav->Request('GET', $href); + $self->assert($res->{content} =~ + m/DTSTART;TZID=\"\(UTC-05:00\) Eastern Time \(US & Canada\)\"/); + $self->assert($res->{content} =~ + m/TZID:\(UTC-05:00\) Eastern Time \(US & Canada\)/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_recur b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_recur new file mode 100644 index 0000000000..91ed101fc8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_guesstz_recur @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_guesstz_recur + :min_version_3_5 :needs_component_jmap :needs_dependency_guesstz +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $eventId = '123456789'; + my $ical = <putandget_vevent($eventId, + $ical, ['timeZone']); + $self->assert_str_equals('Europe/Berlin', $event->{timeZone}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_ignore_orphaned_ical_rows b/cassandane/tiny-tests/JMAPCalendars/calendarevent_ignore_orphaned_ical_rows new file mode 100644 index 0000000000..45016f961f --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_ignore_orphaned_ical_rows @@ -0,0 +1,179 @@ +#!perl +use DBI; +use Cassandane::Tiny; + +sub test_calendarevent_ignore_orphaned_ical_rows + :min_version_3_7 :needs_component_jmap :DelayedDelete +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<CallMethods([ + ['Calendar/set', { + create => { + 1 => { + name => 'test', + }, + }, + }, 'R1'], + ]); + my $calendarId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($calendarId); + + xlog "Create event via CalDAV"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:7e017102-0caf-490a-bbdf-422141d34e75 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=American/New_York:20210923T153000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER:mailto:cassandaneA@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:attendee@example.com +END:VEVENT +END:VCALENDAR +EOF + + $res = $caldav->Request('PUT', + '/dav/calendars/user/cassandane/' . $calendarId . '/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ]); + my $eventId = $res->[0][1]{ids}[0]; + $self->assert_not_null($eventId); + + xlog "Fetch dav.db rows"; + my $dbfile = ($self->{instance}->run_mbpath(-u => 'cassandane'))->{user}{dav}; + $self->assert_not_null($dbfile); + my $dbh = DBI->connect("dbi:SQLite:dbname=$dbfile", "", "", { + PrintError => 1, RaiseError => 1, AutoCommit => 1 + }); + my $ical_rows = $dbh->selectall_hashref('SELECT * FROM ical_objs;', 'rowid'); + my $jscal_rows = $dbh->selectall_hashref('SELECT * FROM jscal_objs;', 'rowid'); + $dbh->disconnect; + + xlog "Destroy calendar and events"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + destroy => [$calendarId], + onDestroyRemoveEvents => JSON::true, + }, 'R1'], + ]); + $self->assert_deep_equals([$calendarId], $res->[0][1]{destroyed}); + + xlog "Write back rows into dav.db"; + $dbh = DBI->connect("dbi:SQLite:dbname=$dbfile", "", "", { + PrintError => 1, RaiseError => 1, AutoCommit => 1 + }); + + while (my ($rowid, $row) = each %{$ical_rows}) { + my @cols = (), @vals = (); + while (my($k, $v) = each %{$row}) { + # make sure we insert in same order + push(@cols, $k); + push(@vals, $v); + } + my $stmt = 'INSERT INTO ical_objs (' . join(',', @cols) . ') VALUES (' . join(',', ('?') x scalar @vals) . ');'; + my $sth = $dbh->prepare($stmt); + $sth->execute(@vals) or die $sth->errorstr; + } + + while (my ($rowid, $row) = each %{$jscal_rows}) { + my @cols = (), @vals = (); + while (my($k, $v) = each %{$row}) { + # make sure we insert in same order + push(@cols, $k); + push(@vals, $v); + } + my $stmt = 'INSERT INTO jscal_objs (' . join(',', @cols) . ') VALUES (' . join(',', ('?') x scalar @vals) . ');'; + my $sth = $dbh->prepare($stmt); + $sth->execute(@vals) or die $sth->errorstr; + } + + $dbh->disconnect; + + xlog "Assert that event is not returned in JMAP"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id'], + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['id'], + }, 'R2'], + ['CalendarEvent/query', { + }, 'R3'], + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'updated', + }, + }, + }, 'R4'], + ['CalendarEvent/set', { + destroy => [$eventId], + }, 'R5'], + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{list}}); + $self->assert_deep_equals([], $res->[2][1]{ids}); + $self->assert(exists $res->[3][1]{notUpdated}{$eventId}); + $self->assert(exists $res->[4][1]{notDestroyed}{$eventId}); + + xlog "Assert that event is not updated via iMIP"; + my $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <7e017102-0caf-490a-bbdf-422141d34e75@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:7e017102-0caf-490a-bbdf-422141d34e75 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=American/New_York:20210923T153000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER:mailto:cassandaneA@example.com +ATTENDEE;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:attendee@example.com +END:VEVENT +END:VCALENDAR +EOF + + $self->{instance}->getsyslog(); + + xlog $self, "Deliver iMIP invite"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + $self->assert_syslog_does_not_match( + $self->{instance}, + qr/mailbox={jmap}; + my $caldav = $self->{caldav}; + + xlog "PUT event for invitee"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:organizer@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/testitip.ics', + $ical, 'Content-Type' => 'text/calendar'); + + xlog "Assert sequence number"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'sequence'] + }, 'R1'], + ]); + $self->assert_num_equals(1, $res->[0][1]{list}[0]{sequence}); + my $eventId = $res->[0][1]{list}[0]{id}; + + xlog "Update invitee's participant"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + sendSchedulingMessages => JSON::true, + update => { + $eventId => { + 'participants/cassandane/expectReply' => JSON::false, + 'participants/cassandane/participationStatus' => 'accepted', + 'participants/cassandane/scheduleSequence' => 1, + 'participants/cassandane/scheduleUpdated' => '2022-01-20T14:56:36Z', + }, + + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_null($res->[0][1]{updated}{$eventId}{sequence}); + + xlog "Assert sequence number did not increase"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'sequence'] + }, 'R1'], + ]); + $self->assert_num_equals(1, $res->[0][1]{list}[0]{sequence}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_itip_request_sequence b/cassandane/tiny-tests/JMAPCalendars/calendarevent_itip_request_sequence new file mode 100644 index 0000000000..535a9bcc67 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_itip_request_sequence @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_itip_request_sequence + :needs_component_httpd :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "PUT event for organizer"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:cassandane@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=invitee:MAILTO:invitee@example.com +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/testitip.ics', + $ical, 'Content-Type' => 'text/calendar'); + + xlog "Assert sequence number"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'sequence'] + }, 'R1'], + ]); + $self->assert_num_equals(1, $res->[0][1]{list}[0]{sequence}); + my $eventId = $res->[0][1]{list}[0]{id}; + + xlog "Update per-user prop"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + sendSchedulingMessages => JSON::true, + update => { + $eventId => { + color => 'blue', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_null($res->[0][1]{updated}{$eventId}{sequence}); + + xlog "Assert sequence number did not increase"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'sequence'] + }, 'R1'], + ]); + $self->assert_num_equals(1, $res->[0][1]{list}[0]{sequence}); + + xlog "Update shared prop"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + sendSchedulingMessages => JSON::true, + update => { + $eventId => { + title => 'updatedTitle', + }, + + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_num_equals(2, $res->[0][1]{updated}{$eventId}{sequence}); + + xlog "Assert sequence number did increase"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'sequence'] + }, 'R1'], + ]); + $self->assert_num_equals(2, $res->[0][1]{list}[0]{sequence}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_multiget_defaultalerts b/cassandane/tiny-tests/JMAPCalendars/calendarevent_multiget_defaultalerts new file mode 100644 index 0000000000..37cea11338 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_multiget_defaultalerts @@ -0,0 +1,163 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_multiget_defaultalerts + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "share calendar"; + my ($shareeJmap, $shareeCaldav) = $self->create_user('sharee'); + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + sharee => { + mayReadItems => JSON::true, + mayWriteAll => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Set default alert on calendar and personalized event"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ['CalendarEvent/set', { + create => { + 1 => { + uid => '5f0dec98-8952-418e-91fa-159cb2ba28da', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + my $eventId = $res->[1][1]{created}{1}{id}; + $self->assert_not_null($eventId); + my $eventHref = $res->[1][1]{created}{1}{'x-href'}; + $self->assert_not_null($eventHref); + + xlog "Set per-user property to force per-user data split"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + color => 'red', + } + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert alerts and ETag"; + + my $xmlMultiget = < + + + + + + $eventHref + +EOF + + xlog "Run multiget"; + $mgRes = $caldav->Request('REPORT', 'Default', $xmlMultiget, + 'Content-Type' => 'application/xml', + ); + my $icaldata = $mgRes->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content}; + $self->assert_matches(qr/TRIGGER:-PT5M/, $icaldata); + my $mgEtag = $mgRes->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{DAV:}getetag'}{content}; + $self->assert_not_null($mgEtag); + + my $xmlCalQuery = < + + + + + +EOF + + xlog "Run calendar query"; + $qrRes = $caldav->Request('REPORT', 'Default', $xmlCalQuery, + 'Content-Type' => 'application/xml', + ); + my $qrEtag = $qrRes->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{DAV:}getetag'}{content}; + $self->assert_str_equals($mgEtag, $qrEtag); + + xlog "Update default alerts"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT15M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Assert iCalendar data and ETags changed"; + + xlog "Run multiget"; + $mgRes = $caldav->Request('REPORT', 'Default', $xmlMultiget, + 'Content-Type' => 'application/xml', + ); + my $icaldata = $mgRes->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content}; + $self->assert_matches(qr/TRIGGER:-PT15M/, $icaldata); + my $newMgEtag = $mgRes->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{DAV:}getetag'}{content}; + $self->assert_str_not_equals($mgEtag, $newMgEtag); + + xlog "Run calendar query"; + $qrRes = $caldav->Request('REPORT', 'Default', $xmlCalQuery, + 'Content-Type' => 'application/xml', + ); + my $newQrEtag = $qrRes->{'{DAV:}response'}[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{DAV:}getetag'}{content}; + $self->assert_str_equals($newMgEtag, $newQrEtag); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_multiget_defaultalerts_per_calendar b/cassandane/tiny-tests/JMAPCalendars/calendarevent_multiget_defaultalerts_per_calendar new file mode 100644 index 0000000000..fc16a765f3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_multiget_defaultalerts_per_calendar @@ -0,0 +1,125 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_multiget_defaultalerts_per_calendar + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create two calendars with default alerts"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendarA => { + name => 'calendarA', + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT1H', + }, + action => 'display', + }, + }, + }, + calendarB => { + name => 'calendarB', + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT2H', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + my $calendarAId = $res->[0][1]{created}{calendarA}{id}; + $self->assert_not_null($calendarAId); + my $calendarBId = $res->[0][1]{created}{calendarB}{id}; + $self->assert_not_null($calendarBId); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventA1 => { + uid => '5f0dec98-8952-418e-91fa-159cb2ba28da', + calendarIds => { + $calendarAId => JSON::true, + }, + title => "eventA1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + eventA2 => { + uid => '68b31869-889e-49f2-ac6a-f94ce0179635', + calendarIds => { + $calendarAId => JSON::true, + }, + title => "eventA2", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + eventB1 => { + uid => 'b58fcc34-aca6-4ae5-a7d0-97411d1166a4', + calendarIds => { + $calendarBId => JSON::true, + }, + title => "eventB1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ]); + + my $eventA1Href = $res->[0][1]{created}{eventA1}{'x-href'}; + $self->assert_not_null($eventA1Href); + my $eventA2Href = $res->[0][1]{created}{eventA2}{'x-href'}; + $self->assert_not_null($eventA2Href); + my $eventB1Href = $res->[0][1]{created}{eventB1}{'x-href'}; + $self->assert_not_null($eventB1Href); + + xlog "Assert alerts and ETag"; + my $xml = < + + + + + + $eventA1Href + $eventA2Href + $eventB1Href + +EOF + $res = $caldav->Request('REPORT', 'Default', $xml, + 'Content-Type' => 'application/xml', + ); + + my %icaldataPerHref = map { + $_->{'{DAV:}href'}{content} => $_->{'{DAV:}propstat'}[0]{'{DAV:}prop'}{'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content} + } @{$res->{'{DAV:}response'}}; + + + $self->assert_matches(qr/TRIGGER:-PT1H/, $icaldataPerHref{$eventA1Href}); + $self->assert_matches(qr/TRIGGER:-PT1H/, $icaldataPerHref{$eventA2Href}); + $self->assert_matches(qr/TRIGGER:-PT2H/, $icaldataPerHref{$eventB1Href}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_organizer_noattendees b/cassandane/tiny-tests/JMAPCalendars/calendarevent_organizer_noattendees new file mode 100644 index 0000000000..5660664b74 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_organizer_noattendees @@ -0,0 +1,93 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_organizer_noattendees + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my ($event1Id, $ical) = $self->icalfile('organizer_noattendees'); + my $event = $self->putandget_vevent($event1Id, $ical); + my $wantParticipants = { + 'bf8360ce374961f497599431c4bacb50d4a67ca1' => { + '@type' => 'Participant', + name => 'Organizer', + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:organizer@local', + }, + expectReply => JSON::false, + participationStatus => 'needs-action', + }, + }; + my $wantReplyTo = { + imip => 'mailto:organizer@local', + }, + $self->assert_deep_equals($wantParticipants, $event->{participants}); + $self->assert_deep_equals($wantReplyTo, $event->{replyTo}); + + xlog "Update event via JMAP"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $event1Id => { + participants => $wantParticipants, + replyTo => $wantReplyTo, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$event1Id], + properties => ['participants', 'replyTo', 'x-href'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$event1Id}); + $self->assert_deep_equals($wantParticipants, $res->[1][1]{list}[0]{participants}); + $self->assert_deep_equals($wantReplyTo, $res->[1][1]{list}[0]{replyTo}); + + my $xhref1 = $res->[1][1]{list}[0]{'x-href'}; + $self->assert_not_null($xhref1); + + xlog "Validate no ATTENDEE got added"; + $res = $caldav->Request('GET', $xhref1); + $self->assert($res->{content} =~ m/ORGANIZER/); + $self->assert(not($res->{content} =~ m/ATTENDEE/)); + + xlog "Create event with owner-only participant via JMAP"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event2 => { + calendarIds => { + 'Default' => JSON::true, + }, + title => "title", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT2H", + "timeZone" => "Europe/London", + replyTo => $wantReplyTo, + participants => $wantParticipants, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event2'], + properties => ['participants', 'replyTo'], + }, 'R2'], + ]); + $self->assert_deep_equals($wantParticipants, $res->[1][1]{list}[0]{participants}); + $self->assert_deep_equals($wantReplyTo, $res->[1][1]{list}[0]{replyTo}); + + my $xhref2 = $res->[0][1]{created}{event2}{'x-href'}; + $self->assert_not_null($xhref2); + + xlog "Validate an ATTENDEE got added"; + $res = $caldav->Request('GET', $xhref2); + $self->assert($res->{content} =~ m/ORGANIZER/); + $self->assert($res->{content} =~ m/ATTENDEE/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_organizer_noattendees_legacy b/cassandane/tiny-tests/JMAPCalendars/calendarevent_organizer_noattendees_legacy new file mode 100644 index 0000000000..dbef5eb4c4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_organizer_noattendees_legacy @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_organizer_noattendees_legacy + :min_version_3_4 :max_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + # It's allowed to have an ORGANIZER even if there are no ATTENDEEs. + # The expected behaviour is that there's just a single organizer in the + # participants + + my ($id, $ical) = $self->icalfile('organizer_noattendees'); + + my $event = $self->putandget_vevent($id, $ical); + + my $wantParticipants = { + 'bf8360ce374961f497599431c4bacb50d4a67ca1' => { + '@type' => 'Participant', + name => 'Organizer', + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:organizer@local', + }, + expectReply => JSON::false, + participationStatus => 'needs-action', + }, + }; + $self->assert_deep_equals($wantParticipants, $event->{participants}); + $self->assert_equals('mailto:organizer@local', $event->{replyTo}{imip}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_parse_repair_broken_ical b/cassandane/tiny-tests/JMAPCalendars/calendarevent_parse_repair_broken_ical new file mode 100644 index 0000000000..866c39d14d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_parse_repair_broken_ical @@ -0,0 +1,224 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_parse_repair_broken_ical + :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my @testCases = ({ + desc => 'Top-level component is VEVENT', + wantParsed => { + '@type' => 'Event', + title => 'test', + uid => '2a358cee-6489-4f14-a57f-c104db4dc357', + }, + ical => < 'iCalendar stream with two iCalendar objects', + wantParsed => { + '@type' => 'Event', + title => 'test1', + uid => '1a968fa5-3afd-4736-8fac-21958ef3db90', + }, + ical => < 'VEVENT without mandatory UID property', + wantParsed => { + '@type' => 'Event', + title => 'test', + # this need not be exactly this uid value + uid => 'nouid218e89b7b9041f4b3c1999a93e6dec410b17b903', + }, + ical => < 'METHOD=PUBLISH without ORGANIZER in VEVENT', + wantParsed => { + '@type' => 'Event', + uid => '01b1ee27-32c9-4c45-909b-c4c222666ebe', + }, + # We want to make sure that 'method' is NOT returned. + wantAlsoProperties => ['method'], + ical => < 'Multiple repairs required', + wantParsed => { + '@type' => 'Event', + title => 'summary1', + # this need not be exactly this uid value + uid => 'nouidacf3612eeeeb579f42176697745fdc984d24aafc', + }, + # We want to make sure that 'method' is NOT returned. + wantAlsoProperties => ['method'], + ical => < 'Broken VALARMs: bad TRIGGER, no ACTION', + wantParsed => { + '@type' => 'Event', + alerts => { + valarmNoAction => { + '@type' => "Alert", + trigger => { + '@type' => "OffsetTrigger", + relativeTo => "start", + offset => "PT0S" + }, + action => "display" + } + }, + }, + ical => <{ical} =~ s/\r?\n/\r\n/gs; + + my @properties = keys %{$tc->{wantParsed}}; + push(@properties, @{$tc->{wantAlsoProperties}}); + + xlog $self, "Running test case: $tc->{desc}"; + + my $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + ical => { + data => [{ + 'data:asText' => $tc->{ical}, + }], + }, + }, + }, 'R0'], + ['CalendarEvent/parse', { + blobIds => [ "#ical" ], + repairBrokenIcal => JSON::true, + properties => \@properties, + }, 'R1'] + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/blob', + ]); + $self->assert_not_null($res->[0][1]{created}{ical}); + if (not grep(/^uid$/, @properties)) { + delete $res->[1][1]{parsed}{'#ical'}{uid}; + } + $self->assert_deep_equals($tc->{wantParsed}, + $res->[1][1]{parsed}{'#ical'}); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_parse_singlecommand b/cassandane/tiny-tests/JMAPCalendars/calendarevent_parse_singlecommand new file mode 100644 index 0000000000..5cf398b67a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_parse_singlecommand @@ -0,0 +1,110 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_parse_singlecommand + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $id1 = '97c46ea4-4182-493c-87ef-aee4edc2d38b'; + my $ical1 = <CallMethods([ + ['Blob/upload', + { create => { + "ical1" => { data => [{'data:asText' => $ical1}], type => 'text/calendar' }, + "ical2" => { data => [{'data:asText' => $ical2}], type => 'text/calendar' }, + "junk" => { data => [{'data:asText' => 'foo bar'}], type => 'text/calendar' } + } }, 'R0'], + ['CalendarEvent/parse', { + blobIds => [ "#ical1", "foo", "#junk", "#ical2" ], + properties => [ "\@type", "uid", "title", "start", + "recurrenceRules", "recurrenceOverrides" ] + }, "R1"]], + $using); + $self->assert_not_null($res); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_str_equals('R0', $res->[0][2]); + + $self->assert_str_equals('CalendarEvent/parse', $res->[1][0]); + $self->assert_str_equals('R1', $res->[1][2]); + $self->assert_str_equals($id1, $res->[1][1]{parsed}{"#ical1"}{uid}); + $self->assert_str_equals("bar", $res->[1][1]{parsed}{"#ical1"}{title}); + $self->assert_str_equals("2015-10-08T00:00:00", $res->[1][1]{parsed}{"#ical1"}{start}); + $self->assert_null($res->[1][1]{parsed}{"#ical1"}{recurrenceRule}); + $self->assert_null($res->[1][1]{parsed}{"#ical1"}{recurrenceOverrides}); + + $self->assert_str_equals("Group", $res->[1][1]{parsed}{"#ical2"}{"\@type"}); + $self->assert_num_equals(2, scalar @{$res->[1][1]{parsed}{"#ical2"}{entries}}); + $self->assert_str_equals($id2, $res->[1][1]{parsed}{"#ical2"}{entries}[1]{uid}); + $self->assert_str_equals("Event #2", $res->[1][1]{parsed}{"#ical2"}{entries}[1]{title}); + $self->assert_not_null($res->[1][1]{parsed}{"#ical2"}{entries}[1]{recurrenceRules}); + $self->assert_not_null($res->[1][1]{parsed}{"#ical2"}{entries}[1]{recurrenceOverrides}); + $self->assert_str_equals($id1, $res->[1][1]{parsed}{"#ical2"}{entries}[0]{uid}); + $self->assert_str_equals("foo", $res->[1][1]{parsed}{"#ical2"}{entries}[0]{title}); + + $self->assert_str_equals("#junk", $res->[1][1]{notParsable}[0]); + $self->assert_str_equals("foo", $res->[1][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_caldav b/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_caldav new file mode 100644 index 0000000000..7e8bd64686 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_caldav @@ -0,0 +1,114 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_participantreply_caldav + :min_version_3_7 :needs_component_jmap :NoStartInstances +{ + my ($self) = @_; + + my $instance = $self->{instance}; + $instance->{config}->set(defaultdomain => 'internal'); + $instance->{config}->set(calendar_user_address_set => 'internal'); + + $self->_start_instances(); + $self->_setup_http_service_objects(); + + my $jmap = $self->{jmap}; + $jmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'urn:ietf:params:jmap:calendars:preferences', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/debug', + ]); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $card = <{instance}->getnotify(); + + xlog $self, "create scheduled event"; + my $event = $self->putandget_vevent($uuid, $card); + my $id = $event->{id}; + + xlog $self, "verify invitation sent from organizer to attendees"; + my $data = $self->{instance}->getnotify(); + my @imips = grep { $_->{METHOD} eq 'imip' } @$data; + my $imip = $imips[0]; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("CalDAV", $payload->{schedulingMechanism}); + $self->assert_str_equals("cassandane\@example.com", $payload->{sender}); + $self->assert_matches(qr/(bugs|rr)\@looneytunes.com/, $payload->{recipient}); + $self->assert_num_equals(0, $payload->{patch}{sequence}); + $self->assert($ical =~ "METHOD:REQUEST"); + $self->assert($ical =~ "SEQUENCE:0"); + $self->assert($ical =~ "PARTSTAT=NEEDS-ACTION"); + + $imip = $imips[1]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("CalDAV", $payload->{schedulingMechanism}); + $self->assert_str_equals("cassandane\@example.com", $payload->{sender}); + $self->assert_matches(qr/(bugs|rr)\@looneytunes.com/, $payload->{recipient}); + $self->assert_num_equals(0, $payload->{patch}{sequence}); + $self->assert($ical =~ "METHOD:REQUEST"); + $self->assert($ical =~ "SEQUENCE:0"); + $self->assert($ical =~ "PARTSTAT=NEEDS-ACTION"); + + xlog $self, "set attendee status"; + my $res = $jmap->CallMethods([['CalendarEvent/participantReply', { + eventId => $id, + participantEmail => "bugs\@looneytunes.com", + updates => { + participationStatus => "accepted" + } + }, "R2"]]); + $self->assert_str_equals("1.1", $res->[0][1]{scheduleStatus}); + + xlog $self, "verify reply sent from attendee to organizer"; + $data = $self->{instance}->getnotify(); + @imips = grep { $_->{METHOD} eq 'imip' } @$data; + $imip = $imips[0]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("CalendarEvent/participantReply", + $payload->{schedulingMechanism}); + $self->assert_str_equals("bugs\@looneytunes.com", $payload->{sender}); + $self->assert_str_equals("cassandane\@example.com", $payload->{recipient}); + $self->assert_num_equals(0, $payload->{jsevent}{sequence}); + $self->assert($ical =~ "METHOD:REPLY"); + $self->assert($ical =~ "SEQUENCE:0"); + $self->assert($ical =~ "PARTSTAT=ACCEPTED"); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_simple b/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_simple new file mode 100644 index 0000000000..864710c95c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_simple @@ -0,0 +1,263 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_participantreply_simple + :min_version_3_7 :needs_component_jmap :NoStartInstances +{ + my ($self) = @_; + + my $instance = $self->{instance}; + $instance->{config}->set(defaultdomain => 'internal'); + $instance->{config}->set(calendar_user_address_set => 'internal'); + + $self->_start_instances(); + $self->_setup_http_service_objects(); + + my $jmap = $self->{jmap}; + $jmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'urn:ietf:params:jmap:calendars:preferences', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/debug', + ]); + + my $participants = { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'cassandane@example.com', + }, + }, + "att1" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'bugs@looneytunes.com', + }, + }, + "att2" => { + "name" => "Road Runner", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'rr@looneytunes.com', + }, + }, + }; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "create scheduled event"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + "sequence" => 1, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2022-11-23T16:45:00", + "recurrenceRules" => [{ + "\@type" => "RecurrenceRule", + "frequency" => "weekly" + }], + "timeZone" => "Australia/Melbourne", + "duration" => "PT1H", + "replyTo" => { imip => "mailto:cassandane\@example.com"}, + "participants" => $participants, + "recurrenceOverrides" => { + "2022-11-30T16:45:00" => { + "start" => "2022-12-01T16:45:00", + } + } + } + }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "verify invitation sent from organizer to attendees"; + my $data = $self->{instance}->getnotify(); + my @imips = grep { $_->{METHOD} eq 'imip' } @$data; + my $imip = $imips[0]; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("CalendarEvent/set", $payload->{schedulingMechanism}); + $self->assert_str_equals("cassandane\@example.com", $payload->{sender}); + $self->assert_matches(qr/(bugs|rr)\@looneytunes.com/, $payload->{recipient}); + $self->assert_num_equals(1, $payload->{patch}{sequence}); + $self->assert($ical =~ "METHOD:REQUEST"); + $self->assert($ical =~ "SEQUENCE:1"); + $self->assert($ical =~ "PARTSTAT=NEEDS-ACTION"); + + $imip = $imips[1]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("CalendarEvent/set", $payload->{schedulingMechanism}); + $self->assert_str_equals("cassandane\@example.com", $payload->{sender}); + $self->assert_matches(qr/(bugs|rr)\@looneytunes.com/, $payload->{recipient}); + $self->assert_num_equals(1, $payload->{patch}{sequence}); + $self->assert($ical =~ "METHOD:REQUEST"); + $self->assert($ical =~ "SEQUENCE:1"); + $self->assert($ical =~ "PARTSTAT=NEEDS-ACTION"); + + xlog $self, "set attendee status"; + $res = $jmap->CallMethods([['CalendarEvent/participantReply', { + eventId => $id, + participantEmail => "bugs\@looneytunes.com", + updates => { + participationStatus => "accepted" + } + }, "R2"]]); + $self->assert_str_equals("1.1", $res->[0][1]{scheduleStatus}); + + xlog $self, "verify reply sent from attendee to organizer"; + $data = $self->{instance}->getnotify(); + @imips = grep { $_->{METHOD} eq 'imip' } @$data; + $imip = $imips[0]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("CalendarEvent/participantReply", + $payload->{schedulingMechanism}); + $self->assert_str_equals("bugs\@looneytunes.com", $payload->{sender}); + $self->assert_str_equals("cassandane\@example.com", $payload->{recipient}); + $self->assert_num_equals(1, $payload->{jsevent}{sequence}); + $self->assert($ical =~ "METHOD:REPLY"); + $self->assert($ical =~ "SEQUENCE:1"); + $self->assert($ical =~ "PARTSTAT=ACCEPTED"); + + xlog $self, "verify updated request sent from organizer to attendee"; + $imip = $imips[1]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("CalendarEvent/participantReply", + $payload->{schedulingMechanism}); + $self->assert_str_equals("cassandane\@example.com", $payload->{sender}); + $self->assert_str_equals("bugs\@looneytunes.com", $payload->{recipient}); + $self->assert_num_equals(1, $payload->{jsevent}{sequence}); + $self->assert($ical =~ "METHOD:REQUEST"); + $self->assert($ical =~ "SEQUENCE:1"); + $self->assert($ical =~ "PARTSTAT=ACCEPTED"); + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min >= 9)) { + xlog $self, "verify no other update requests from organizer"; + $imip = $imips[2]; + $self->assert_null($imip); + + xlog $self, "actually update attendee status on the event"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + update => { + $id => { "participants/att1/participationStatus" => "accepted" } + } + }, "R1"]]); + + xlog $self, "set attendee status again"; + $res = $jmap->CallMethods([['CalendarEvent/participantReply', { + eventId => $id, + participantEmail => "bugs\@looneytunes.com", + updates => { + participationStatus => "accepted" + } + }, "R2"]]); + $self->assert_str_equals("2.0", $res->[0][1]{scheduleStatus}); + + xlog $self, "verify that NO iMIP messages are sent to organizer/attendee"; + $data = $self->{instance}->getnotify(); + @imips = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($imips); + } + + xlog $self, "set attendee status on override"; + $res = $jmap->CallMethods([['CalendarEvent/participantReply', { + eventId => encode_eventid(substr($id, 2), "20221130T164500"), + participantEmail => "rr\@looneytunes.com", + updates => { + participationStatus => "declined" + } + }, "R2"]]); + $self->assert_str_equals("1.1", $res->[0][1]{scheduleStatus}); + + xlog $self, "verify reply sent from attendee to organizer"; + $data = $self->{instance}->getnotify(); + @imips = grep { $_->{METHOD} eq 'imip' } @$data; + $imip = $imips[0]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("CalendarEvent/participantReply", + $payload->{schedulingMechanism}); + $self->assert_str_equals("rr\@looneytunes.com", $payload->{sender}); + $self->assert_str_equals("cassandane\@example.com", $payload->{recipient}); + $self->assert_num_equals(2, $payload->{jsevent}{sequence}); + $self->assert($ical =~ "METHOD:REPLY"); + $self->assert($ical =~ "SEQUENCE:2"); + $self->assert($ical =~ "PARTSTAT=DECLINED"); + + xlog $self, "verify updated request sent from organizer to attendee"; + $imip = $imips[1]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("CalendarEvent/participantReply", + $payload->{schedulingMechanism}); + $self->assert_str_equals("cassandane\@example.com", $payload->{sender}); + $self->assert_str_equals("rr\@looneytunes.com", $payload->{recipient}); + $self->assert_num_equals(2, $payload->{jsevent}{sequence}); + $self->assert($ical =~ "METHOD:REQUEST"); + $self->assert($ical =~ "SEQUENCE:2"); + $self->assert($ical =~ "PARTSTAT=DECLINED"); + + if ($maj > 3 || ($maj == 3 && $min >= 9)) { + xlog $self, "verify no other update requests from organizer"; + $imip = $imips[2]; + $self->assert_null($imip); + + xlog $self, "actually update attendee status on the event"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { + update => { + $id => { "participants/att2/participationStatus" => "declined" } + } + }, "R1"]]); + + xlog $self, "set attendee status again"; + $res = $jmap->CallMethods([['CalendarEvent/participantReply', { + eventId => encode_eventid(substr($id, 2), "20221130T164500"), + participantEmail => "rr\@looneytunes.com", + updates => { + participationStatus => "declined" + } + }, "R2"]]); + $self->assert_str_equals("2.0", $res->[0][1]{scheduleStatus}); + + xlog $self, "verify that NO iMIP messages are sent to organizer/attendee"; + $data = $self->{instance}->getnotify(); + @imips = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($imips); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_standalone b/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_standalone new file mode 100644 index 0000000000..02705284ec --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_participantreply_standalone @@ -0,0 +1,150 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_participantreply_standalone + :min_version_3_7 :needs_component_jmap :NoStartInstances +{ + my ($self) = @_; + + my $instance = $self->{instance}; + $instance->{config}->set(defaultdomain => 'internal'); + $instance->{config}->set(calendar_user_address_set => 'internal'); + + $self->_start_instances(); + $self->_setup_http_service_objects(); + + my $jmap = $self->{jmap}; + $jmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'urn:ietf:params:jmap:calendars:preferences', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/debug', + ]); + + my $participants = { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'cassandane@example.com', + }, + }, + "att" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'bugs@looneytunes.com', + }, + }, + }; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog "Create scheduled standalone instance"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-01-01T01:01:01', + recurrenceIdTimeZone => 'Europe/London', + participants => $participants, + }, + }, + }, 'R1'], + ]); + my $id = $res->[0][1]{created}{instance1}{id}; + + xlog $self, "verify invitation sent from organizer to attendees"; + my $data = $self->{instance}->getnotify(); + my @imips = grep { $_->{METHOD} eq 'imip' } @$data; + my $imip = $imips[0]; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("cassandane\@example.com", $payload->{sender}); + $self->assert_str_equals("bugs\@looneytunes.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REQUEST"); + $self->assert($ical =~ "PARTSTAT=NEEDS-ACTION"); + + xlog $self, "set attendee status"; + $res = $jmap->CallMethods([['CalendarEvent/participantReply', { + eventId => $id, + participantEmail => "bugs\@looneytunes.com", + updates => { + participationStatus => "accepted" + } + }, "R2"]]); + + xlog $self, "verify reply sent from attendee to organizer"; + $data = $self->{instance}->getnotify(); + @imips = grep { $_->{METHOD} eq 'imip' } @$data; + $imip = $imips[0]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("bugs\@looneytunes.com", $payload->{sender}); + $self->assert_str_equals("cassandane\@example.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REPLY"); + $self->assert($ical =~ "PARTSTAT=ACCEPTED"); + + xlog $self, "verify updated request sent from organizer to attendee"; + $imip = $imips[1]; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("cassandane\@example.com", $payload->{sender}); + $self->assert_str_equals("bugs\@looneytunes.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REQUEST"); + $self->assert($ical =~ "PARTSTAT=ACCEPTED"); + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min >= 9)) { + xlog $self, "verify no other update requests from organizer"; + $imip = $imips[2]; + $self->assert_null($imip); + + xlog $self, "actually update attendee status on the event"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + update => { + $id => { "participants/att/participationStatus" => "accepted" } + } + }, "R1"]]); + + xlog $self, "set attendee status again"; + $res = $jmap->CallMethods([['CalendarEvent/participantReply', { + eventId => $id, + participantEmail => "bugs\@looneytunes.com", + updates => { + participationStatus => "accepted" + } + }, "R2"]]); + $self->assert_str_equals("2.0", $res->[0][1]{scheduleStatus}); + + xlog $self, "verify that NO iMIP messages are sent to organizer/attendee"; + $data = $self->{instance}->getnotify(); + @imips = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($imips); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query new file mode 100644 index 0000000000..d8c4a49e57 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query @@ -0,0 +1,287 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my ($maj, $min) = Cassandane::Instance->get_version(); + + xlog $self, "create calendars"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendarA => { + name => "A", + }, + calendarB => { + name => "B", + } + } + }, "R1"] + ]); + my $calendarIdA = $res->[0][1]{created}{calendarA}{id}; + $self->assert_not_null($calendarIdA); + my $calendarIdB = $res->[0][1]{created}{calendarB}{id}; + $self->assert_not_null($calendarIdB); + + my %eventA1 = ( + uid => 'a1-03df209b28-4005-a458-751e2f6058b5' + ); + my %eventA2 = ( + uid => 'a2-73e05c2f12fa-43c4-a17f-9c6e35ddd8' + ); + my %eventB1 = ( + uid => 'b1-8528b44b7cdd-4867-85f0-09746080d9' + ); + + xlog $self, "create events"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventA1 => { + calendarIds => { + $calendarIdA => JSON::true, + }, + uid => $eventA1{uid}, + title => 'eventA1', + description => 'test', + start => '2023-01-01T01:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + }, + eventB1 => { + calendarIds => { + $calendarIdB => JSON::true, + }, + uid => $eventB1{uid}, + title => 'eventB1', + description => 'test', + start => '2023-02-01T01:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + }, + eventA2 => { + calendarIds => { + $calendarIdA => JSON::true, + }, + uid => $eventA2{uid}, + title => 'eventA2', + description => 'test', + start => '2023-03-01T01:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + }, + } + }, 'R1'] + ]); + + $eventA1{id} = $res->[0][1]{created}{eventA1}{id}; + $self->assert_not_null($eventA1{id}); + $eventA2{id} = $res->[0][1]{created}{eventA2}{id}; + $self->assert_not_null($eventA2{id}); + $eventB1{id} = $res->[0][1]{created}{eventB1}{id}; + $self->assert_not_null($eventB1{id}); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @testCases = ({ + filter => undef, + wantIds => [$eventA1{id}, $eventA2{id}, $eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + before => '2023-03-01T01:00:00', + }, + wantIds => [$eventA1{id}, $eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + after => '2023-01-01T02:00:00', + }, + wantIds => [$eventA2{id}, $eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + after => '2023-01-01T02:00:00', + before => '2023-03-01T01:00:00', + }, + wantIds => [$eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + operator => 'AND', + conditions => [{ + after => '2023-01-01T02:00:00', + }, { + before => '2023-03-01T01:00:00', + }], + }, + wantIds => [$eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + operator => 'NOT', + conditions => [{ + after => '2023-01-01T02:00:00', + }], + }, + wantIds => [$eventA1{id}], + wantFastPath => JSON::true, + }, { + filter => { + uid => $eventA2{uid}, + }, + wantIds => [$eventA2{id}], + wantFastPath => JSON::true, + }, { + filter => { + operator => 'NOT', + conditions => [{ + uid => $eventA2{uid}, + }], + }, + wantIds => [$eventA1{id}, $eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + operator => 'OR', + conditions => [{ + uid => $eventA1{uid}, + }, { + uid => $eventB1{uid}, + }], + }, + wantIds => [$eventA1{id}, $eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + inCalendars => [$calendarIdA, $calendarIdB], + }, + wantIds => [$eventA1{id}, $eventA2{id}, $eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + operator => 'NOT', + conditions => [{ + inCalendars => [$calendarIdA], + }], + }, + wantIds => [$eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + operator => 'OR', + conditions => [{ + inCalendars => [$calendarIdA, $calendarIdB], + }], + }, + wantIds => [$eventA1{id}, $eventA2{id}, $eventB1{id}], + wantFastPath => JSON::true, + }, { + filter => { + operator => 'AND', + conditions => [{ + inCalendars => [$calendarIdA], + }, { + text => 'test', + }], + }, + wantIds => [$eventA1{id}, $eventA2{id}], + wantFastPath => JSON::false, + }, { + filter => undef, + position => 1, + limit => 1, + wantTotal => 3, + wantIds => [$eventA2{id}], + wantFastPath => JSON::true, + }, { + filter => undef, + position => -1, + wantTotal => 3, + wantIds => [$eventB1{id}], + wantFastPath => JSON::false, + }, { + filter => { + operator => 'NOT', + conditions => [{ + blarg => 'foo', # invalid - blarg is not a calevent property + }], + }, + wantErr => { + type => 'invalidArguments', + arguments => [ 'filter/conditions[0]/blarg' ], + }, + }, { + filter => { + operator => 'NOT', + conditions => { # invalid - object rather than array + blarg => 'foo', + }, + }, + wantErr => { + type => 'invalidArguments', + arguments => [ 'filter/conditions' ], + }, + }, { + filter => { + operator => 'NOT', + # invalid - no conditions + }, + wantErr => { + type => 'invalidArguments', + arguments => [ 'filter/conditions' ], + }, + }, { + filter => { + operator => 'BLARG', # invalid operator + conditions => [{ + after => '2023-01-01T02:00:00', + }], + }, + wantErr => { + type => 'invalidArguments', + arguments => [ 'filter/operator' ], + }, + }); + + for my $tc (@testCases) { + my $q = { + filter => $tc->{filter}, + sort => [{ + property => 'uid', + }], + }; + + if (defined $tc->{position}) { + $q->{position} = $tc->{position}; + } + + if (defined $tc->{limit}) { + $q->{limit} = $tc->{limit}; + } + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', $q, 'R1'], + ]); + if ($tc->{wantErr}) { + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_deep_equals($tc->{wantErr}, $res->[0][1]); + } else { + my $wantTotal = defined $tc->{wantTotal} ? + $tc->{wantTotal} : scalar @{$tc->{wantIds}}; + $self->assert_num_equals($wantTotal, $res->[0][1]{total}); + $self->assert_deep_equals($tc->{wantIds}, $res->[0][1]{ids}); + + if ($maj > 3 || ($maj == 3 && $min > 8)) { + $self->assert_equals($tc->{wantFastPath}, + $res->[0][1]{debug}{isFastPath}); + } + } + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_anchor b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_anchor new file mode 100644 index 0000000000..1e111bed71 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_anchor @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_anchor + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + '1' => { + calendarIds => { + $calid => JSON::true, + }, + 'uid' => 'event1uid', + 'title' => 'event1', + 'start' => '2019-10-01T10:00:00', + 'timeZone' => 'Etc/UTC', + }, + '2' => { + calendarIds => { + $calid => JSON::true, + }, + 'uid' => 'event2uid', + 'title' => 'event2', + 'start' => '2019-10-02T10:00:00', + 'timeZone' => 'Etc/UTC', + }, + '3' => { + calendarIds => { + $calid => JSON::true, + }, + 'uid' => 'event3uid', + 'title' => 'event3', + 'start' => '2019-10-03T10:00:00', + 'timeZone' => 'Etc/UTC', + }, + } + }, 'R1']]); + my $eventId1 = $res->[0][1]{created}{1}{id}; + my $eventId2 = $res->[0][1]{created}{2}{id}; + my $eventId3 = $res->[0][1]{created}{3}{id}; + $self->assert_not_null($eventId1); + $self->assert_not_null($eventId2); + $self->assert_not_null($eventId3); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + sort => [{ + property => 'start', + isAscending => JSON::true, + }], + anchor => $eventId2, + }, 'R1'] + ]); + $self->assert_deep_equals([$eventId2,$eventId3], $res->[0][1]{ids}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + sort => [{ + property => 'start', + isAscending => JSON::true, + }], + anchor => $eventId3, + anchorOffset => -2, + limit => 1, + }, 'R1'] + ]); + $self->assert_deep_equals([$eventId1], $res->[0][1]{ids}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + sort => [{ + property => 'start', + isAscending => JSON::true, + }], + anchor => $eventId2, + anchorOffset => -5, + }, 'R1'] + ]); + $self->assert_deep_equals([$eventId1, $eventId2, $eventId3], $res->[0][1]{ids}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + sort => [{ + property => 'start', + isAscending => JSON::true, + }], + anchor => $eventId2, + anchorOffset => 5, + }, 'R1'] + ]); + $self->assert_deep_equals([], $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_date b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_date new file mode 100644 index 0000000000..9aeb117975 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_date @@ -0,0 +1,135 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_date + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + xlog $self, "create events"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + # Start: 2016-01-01 End: 2016-01-03 + "1" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "1", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2016-01-01T00:00:00", + "duration" => "P3D", + }, + }}, "R1"]]); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + # Match on start and end day + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T00:00:00", + "before" => "2016-01-03T23:59:59", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # Match after on the first second of the start day + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T00:00:00", + "before" => "2016-01-03T00:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # Match before on the last second of the end day + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-03T23:59:59", + "before" => "2016-01-03T23:59:59", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # Match on interim day + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-02T00:00:00", + "before" => "2016-01-03T00:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # Match on partially overlapping timerange + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2015-12-31T12:00:00", + "before" => "2016-01-01T12:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2015-01-03T12:00:00", + "before" => "2016-01-04T12:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # Difference from the spec: 'before' is defined to be exclusive, but + # a full-day event starting on that day still matches. + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2015-12-31T00:00:00", + "before" => "2016-01-01T00:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # In DAV db the event ends at 20160104. Test that it isn't returned. + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-04T00:00:00", + "before" => "2016-01-04T23:59:59", + }, + }, "R1"]]); + $self->assert_num_equals(0, $res->[0][1]{total}); + + # Create an infinite recurring datetime event + $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + # Start: 2017-01-01T08:00:00Z End: eternity + "1" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "2", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2017-01-01T00:00:00", + "duration" => "P1D", + "recurrenceRules" => [{ + "frequency" => "yearly", + }], + }, + }}, "R1"]]); + # Assert both events are found + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T00:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(2, $res->[0][1]{total}); + # Search close to eternity + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2038-01-01T00:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_datetime b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_datetime new file mode 100644 index 0000000000..43618e7031 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_datetime @@ -0,0 +1,114 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_datetime + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + xlog $self, "create events"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + # Start: 2016-01-01T08:00:00Z End: 2016-01-01T09:00:00Z + "1" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "1", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2016-01-01T09:00:00", + "timeZone" => "Europe/Vienna", + "duration" => "PT1H", + }, + }}, "R1"]]); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + # Exact start and end match + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T08:00:00", + "before" => "2016-01-01T09:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # Check that boundaries are exclusive + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T09:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(0, $res->[0][1]{total}); + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "before" => "2016-01-01T08:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(0, $res->[0][1]{total}); + + # Embedded subrange matches + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T08:15:00", + "before" => "2016-01-01T08:45:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # Overlapping subrange matches + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T08:15:00", + "before" => "2016-01-01T09:15:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T07:45:00", + "before" => "2016-01-01T08:15:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + # Create an infinite recurring datetime event + $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + # Start: 2017-01-01T08:00:00Z End: eternity + "1" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "e", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2017-01-01T09:00:00", + "timeZone" => "Europe/Vienna", + "duration" => "PT1H", + "recurrenceRules" => [{ + "frequency" => "yearly", + }], + }, + }}, "R1"]]); + # Assert both events are found + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2016-01-01T00:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(2, $res->[0][1]{total}); + # Search close to eternity + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "2038-01-01T00:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_deleted_calendar b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_deleted_calendar new file mode 100644 index 0000000000..2a66c1c199 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_deleted_calendar @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_deleted_calendar + :min_version_3_3 :needs_component_jmap :needs_component_httpd +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create calendars A and B"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + "1" => { + name => "A", + }, + "2" => { + name => "B", + } + }}, "R1"] + ]); + my $calidA = $res->[0][1]{created}{"1"}{id}; + my $calidB = $res->[0][1]{created}{"2"}{id}; + my $state = $res->[0][1]{newState}; + + xlog $self, "create event #1 in calendar $calidA and event #2 in calendar $calidB"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + create => { + "1" => { + "calendarIds" => { + $calidA => JSON::true, + }, + "title" => "foo", + "description" => "bar", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2016-07-01T10:00:00", + "timeZone" => "Europe/Vienna", + "duration" => "PT1H", + }, + "2" => { + "calendarIds" => { + $calidB => JSON::true, + }, + "title" => "foo", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2016-01-01T00:00:00", + "duration" => "P2D", + "timeZone" => undef, + } + }}, "R1"]]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get filtered calendar event list"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + "filter" => { + "after" => "2015-12-31T00:00:00", + "before" => "2016-12-31T23:59:59" + } + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "CalDAV delete calendar as cassandane"; + $caldav->DeleteCalendar("/dav/calendars/user/cassandane/$calidA"); + + xlog $self, "get filtered calendar event list"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + "filter" => { + "after" => "2015-12-31T00:00:00", + "before" => "2016-12-31T23:59:59" + } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id2, $res->[0][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_expandrecurrences b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_expandrecurrences new file mode 100644 index 0000000000..5e05308468 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_expandrecurrences @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_expandrecurrences + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + xlog $self, "create events"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + uid => 'event1uid', + title => "event1", + description => "", + freeBusyStatus => "busy", + start => "2019-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'weekly', + count => 3, + }, { + frequency => 'hourly', + byHour => [9, 14, 22], + count => 2, + }], + recurrenceOverrides => { + '2019-01-08T09:00:00' => { + start => '2019-01-08T12:00:00', + }, + '2019-01-03T13:00:00' => { + title => 'rdate', + }, + }, + }, + "2" => { + calendarIds => { + $calid => JSON::true, + }, + uid => 'event2uid', + title => "event2", + description => "", + freeBusyStatus => "busy", + start => "2019-01-02T11:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + }, + } + }, 'R1'] + ]); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + filter => { + before => '2019-02-01T00:00:00', + }, + sort => [{ + property => 'start', + isAscending => JSON::false, + }], + expandRecurrences => JSON::true, + }, 'R1'] + ]); + $self->assert_num_equals(6, $res->[0][1]{total}); + $self->assert_deep_equals([ + encode_eventid('event1uid','20190115T090000'), + encode_eventid('event1uid','20190108T090000'), + encode_eventid('event1uid','20190103T130000'), + encode_eventid('event2uid'), + encode_eventid('event1uid','20190101T140000'), + encode_eventid('event1uid','20190101T090000'), + ], $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_expandrecurrences_with_exrule b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_expandrecurrences_with_exrule new file mode 100644 index 0000000000..0221b24554 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_expandrecurrences_with_exrule @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_expandrecurrences_with_exrule + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + xlog $self, "create events"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + uid => 'event1uid', + title => "event1", + description => "", + freeBusyStatus => "busy", + start => "2020-08-04T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'weekly', + interval => 4, + }], + excludedRecurrenceRules => [{ + frequency => 'monthly', + byMonthDay => [1], + }, { + frequency => 'monthly', + byMonthDay => [4,22], + }], + recurrenceOverrides => { + '2021-01-01T09:00:00' => { + title => 'rdate overrides exrule', + }, + }, + }, + } + }, 'R1'] + ]); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + filter => { + before => '2021-02-01T00:00:00', + }, + sort => [{ + property => 'start', + isAscending => JSON::false, + }], + expandRecurrences => JSON::true, + }, 'R1'] + ]); + $self->assert_num_equals(5, $res->[0][1]{total}); + $self->assert_deep_equals([ + encode_eventid('event1uid','20210119T090000'), + encode_eventid('event1uid','20210101T090000'), + encode_eventid('event1uid','20201124T090000'), + encode_eventid('event1uid','20201027T090000'), + encode_eventid('event1uid','20200929T090000'), + ], $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_fastpath_position b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_fastpath_position new file mode 100644 index 0000000000..ab8ce78159 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_fastpath_position @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_fastpath_position + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/debug', + ]; + + my $events = {}; + + for my $i (0..9) { + $events->{"event$i"} = { + calendarIds => { + 'Default' => JSON::true, + }, + title => "event$i", + start => "2021-01-01T02:00:00", + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }; + } + + my $numEvents = scalar keys %$events; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => $events, + }, 'R1'], + ['CalendarEvent/query', { + }, 'R2'], + ], $using); + $self->assert_num_equals($numEvents, scalar keys %{$res->[0][1]{created}}); + + my $eventIds = $res->[1][1]{ids}; + $self->assert_num_equals($numEvents, scalar @${eventIds}); + $self->assert_equals(JSON::true, $res->[1][1]{debug}{isFastPath}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + position => 3, + }, 'R1'], + ], $using); + my @wantIds = @{$eventIds}[3 .. ($numEvents-1)]; + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::true, $res->[0][1]{debug}{isFastPath}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_no_sched_inbox b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_no_sched_inbox new file mode 100644 index 0000000000..f03f2eaea6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_no_sched_inbox @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_no_sched_inbox + :needs_component_sieve :needs_component_httpd :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $caldav = $self->{caldav}; + + $self->{store}->_select(); + $self->assert_num_equals(1, $imap->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Calendar/get', { }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + my $defaultCalendarId = $res->[0][1]{list}[0]{id}; + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => ['calendarIds'], + }, 'R2'], + ['CalendarEvent/query', { + filter => { + title => 'test', + }, + }, 'R3'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R3', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => ['calendarIds'], + }, 'R4'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_deep_equals({ + $defaultCalendarId => JSON::true, + }, $res->[1][1]{list}[0]{calendarIds}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{ids}}); + $self->assert_deep_equals({ + $defaultCalendarId => JSON::true, + }, $res->[3][1]{list}[0]{calendarIds}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_shared b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_shared new file mode 100644 index 0000000000..af8222c5a0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_shared @@ -0,0 +1,172 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admintalk = $self->{adminstore}->get_client(); + + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "share calendar home to user"; + $admintalk->setacl("user.manifold.#calendars", cassandane => 'lrswipkxtecdn'); + + # run tests for both the main and shared account + foreach ("cassandane", "manifold") { + my $account = $_; + + xlog $self, "create calendars A and B"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => $account, + create => { + "1" => { + name => "A", color => "coral", sortOrder => 1, isVisible => JSON::true, + }, + "2" => { + name => "B", color => "blue", sortOrder => 1, isVisible => JSON::true + } + }}, "R1"] + ]); + my $calidA = $res->[0][1]{created}{"1"}{id}; + my $calidB = $res->[0][1]{created}{"2"}{id}; + my $state = $res->[0][1]{newState}; + + if ($account eq 'manifold') { + $admintalk->setacl("user.manifold.#calendars.$calidA", cassandane => 'lrswipkxtecdn'); + $admintalk->setacl("user.manifold.#calendars.$calidB", cassandane => 'lrswipkxtecdn'); + } + + xlog $self, "create event #1 in calendar $calidA and event #2 in calendar $calidB"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + accountId => $account, + create => { + "1" => { + calendarIds => { + $calidA => JSON::true, + }, + "title" => "foo", + "description" => "bar", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2016-07-01T10:00:00", + "timeZone" => "Europe/Vienna", + "duration" => "PT1H", + }, + "2" => { + calendarIds => { + $calidB => JSON::true, + }, + "title" => "foo", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2016-01-01T00:00:00", + "duration" => "P2D", + } + }}, "R1"]]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "get unfiltered calendar event list"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { accountId => $account }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($account, $res->[0][1]{accountId}); + + xlog $self, "get filtered calendar event list with flat filter"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + accountId => $account, + "filter" => { + "after" => "2015-12-31T00:00:00", + "before" => "2016-12-31T23:59:59", + "text" => "foo", + "description" => "bar" + } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "get filtered calendar event list"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + accountId => $account, + "filter" => { + "operator" => "AND", + "conditions" => [ + { + "after" => "2015-12-31T00:00:00", + "before" => "2016-12-31T23:59:59" + }, + { + "text" => "foo", + "description" => "bar" + } + ] + } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by calendar $calidA"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + accountId => $account, + "filter" => { + "inCalendars" => [ $calidA ], + } + }, "R1"] ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by calendar $calidA or $calidB"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + accountId => $account, + "filter" => { + "inCalendars" => [ $calidA, $calidB ], + } + }, "R1"] ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by calendar NOT in $calidA and $calidB"; + $res = $jmap->CallMethods([['CalendarEvent/query', { + accountId => $account, + "filter" => { + "operator" => "NOT", + "conditions" => [{ + "inCalendars" => [ $calidA, $calidB ], + }], + }}, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{ids}}); + + xlog $self, "limit results"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { accountId => $account, limit => 1 }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + + xlog $self, "skip result a position 1"; + $res = $jmap->CallMethods([ ['CalendarEvent/query', { accountId => $account, position => 1 }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_sort b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_sort new file mode 100644 index 0000000000..4688c1528d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_sort @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_sort + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + '1' => { + calendarIds => { + $calid => JSON::true, + }, + 'uid' => 'event1uid', + 'title' => 'event1', + 'start' => '2019-10-01T10:00:00', + 'timeZone' => 'Etc/UTC', + }, + '2' => { + calendarIds => { + $calid => JSON::true, + }, + 'uid' => 'event2uid', + 'title' => 'event2', + 'start' => '2018-10-01T12:00:00', + 'timeZone' => 'Etc/UTC', + }, + } + }, 'R1']]); + my $eventId1 = $res->[0][1]{created}{1}{id}; + my $eventId2 = $res->[0][1]{created}{2}{id}; + $self->assert_not_null($eventId1); + $self->assert_not_null($eventId2); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + sort => [{ + property => 'start', + isAscending => JSON::true, + }] + }, 'R1'] + ]); + $self->assert_deep_equals([$eventId2,$eventId1], $res->[0][1]{ids}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + sort => [{ + property => 'start', + isAscending => JSON::false, + }] + }, 'R1'] + ]); + $self->assert_deep_equals([$eventId1,$eventId2], $res->[0][1]{ids}); + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_text b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_text new file mode 100644 index 0000000000..b4f1c4569c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_text @@ -0,0 +1,124 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_text + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + "title" => "foo", + "description" => "bar", + "locations" => { + "loc1" => { + name => "baz", + }, + }, + "freeBusyStatus" => "busy", + "start"=> "2016-01-01T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "replyTo" => { imip => "mailto:tux\@local" }, + "participants" => { + "tux" => { + name => "", + roles => { + 'owner' => JSON::true, + }, + locationId => "loc1", + sendTo => { + imip => 'tux@local', + }, + }, + "qux" => { + name => "Quuks", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'qux@local', + }, + }, + }, + recurrenceRules => [{ + frequency => "monthly", + count => 12, + }], + "recurrenceOverrides" => { + "2016-04-01T10:00:00" => { + "description" => "blah", + "locations/loc1/name" => "blep", + }, + "2016-05-01T10:00:00" => { + "title" => "boop", + }, + }, + }, + }}, "R1"]]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($id1); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my %textqueries = ( + title => "foo", + title => "boop", + description => "bar", + description => "blah", + location => "baz", + location => "blep", + owner => "tux", + owner => "tux\@local", + attendee => "qux", + attendee => "qux\@local", + attendee => "Quuks", + ); + + while (my ($propname, $propval) = each %textqueries) { + + # Assert that catch-all text search matches + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + "filter" => { + "text" => $propval, + } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + # Sanity check catch-all text search + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + "filter" => { + "text" => "nope", + } + }, "R1"] ]); + $self->assert_num_equals(0, $res->[0][1]{total}); + + # Assert that search by property name matches + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + "filter" => { + $propname => $propval, + } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + # Sanity check property name search + $res = $jmap->CallMethods([ ['CalendarEvent/query', { + "filter" => { + $propname => "nope", + } + }, "R1"] ]); + $self->assert_num_equals(0, $res->[0][1]{total}); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_unixepoch b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_unixepoch new file mode 100644 index 0000000000..d39aadf425 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_unixepoch @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_unixepoch + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + xlog $self, "create events"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "Establish first ARPANET link between UCLA and SRI", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "1969-11-21T17:00:00", + "timeZone" => "America/Los_Angeles", + "duration" => "PT1H", + }, + }}, "R1"]]); + + xlog $self, "Run squatter"; + + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "1969-01-01T00:00:00", + "before" => "1969-12-31T23:59:59", + }, + }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + + $res = $jmap->CallMethods([['CalendarEvent/query', { + "filter" => { + "after" => "1949-06-20T00:00:00", + "before" => "1968-10-14T00:00:00", + }, + }, "R1"]]); + $self->assert_num_equals(0, $res->[0][1]{total}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_unsupported b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_unsupported new file mode 100644 index 0000000000..71a6a16f6d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_unsupported @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; +use Data::UUID; + +sub test_calendarevent_query_unsupported + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $uuidgen = Data::UUID->new; + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $filter = { + operator => 'OR', + conditions => [] + }; + + # evoke a sqlite error for too complex expression trees. + # this filter is non- + for (1 .. 1001) { # this is the internal sqlite3 limit + push(@{$filter->{conditions}}, { + uid => $uuidgen->create_str, + after => '2023-03-04T14:00:00', + }); + } + + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + filter => $filter + }, 'R1'], + ]); + $self->assert_str_equals("unsupportedFilter", $res->[0][1]{type}); + + $self->{instance}->getsyslog(); # ignore seen.db DBERROR +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_with_timezone b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_with_timezone new file mode 100644 index 0000000000..6d34c94937 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_query_with_timezone @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_query_with_timezone + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Create event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + Default => JSON::true, + }, + title => 'event', + start => '2021-08-24T14:30:00', + duration => 'PT1H', + timeZone => 'Etc/UTC', + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + + my @testCases = ({ + filter => { + after => '2021-08-24T14:30:00', + }, + wantIds => [$eventId], + }, { + filter => { + after => '2021-08-25T00:30:00', + }, + timeZone => 'Australia/Melbourne', + wantIds => [$eventId], + }, { + filter => { + before => '2021-08-24T15:30:00', + }, + wantIds => [$eventId], + }, { + filter => { + before => '2021-08-25T01:30:00', + }, + timeZone => 'Australia/Melbourne', + wantIds => [$eventId], + }); + + foreach(@testCases) { + my $args = { + filter => $_->{filter}, + }; + $args->{timeZone} = $_->{timeZone} if defined; + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', $args, 'R1'], + ]); + $self->assert_deep_equals($_->{wantIds}, $res->[0][1]{ids}); + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts new file mode 100644 index 0000000000..b997ec0a5c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_alerts + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $alerts = { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + acknowledged => "2015-11-07T08:57:00Z", + action => "email", + }, + alert2 => { + trigger => { + '@type' => 'AbsoluteTrigger', + when => "2019-03-04T04:05:06Z", + }, + action => "display", + relatedTo => { + 'alert1' => { + relation => { + 'parent' => JSON::true, + }, + }, + }, + }, + alert3 => { + trigger => { + '@type' => 'OffsetTrigger', + offset => "PT1S", + } + }, + alert4 => { + trigger => { + '@type' => 'AbsoluteTrigger', + when => "2019-03-04T05:06:07Z", + }, + action => "display", + relatedTo => { + 'alert1' => { + relation => { }, + }, + }, + }, + + }; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT2H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "status" => "confirmed", + "alerts" => $alerts, + "useDefaultAlerts" => JSON::true, + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + $self->assert_normalized_event_equals($ret, $event); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_description b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_description new file mode 100644 index 0000000000..b5990ae1a4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_description @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_alerts_description + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + calendarIds => { + Default => JSON::true, + }, + title => 'title', + description => 'description', + start => '2015-11-07T09:00:00', + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + }, + 2 => { + calendarIds => { + Default => JSON::true, + }, + description => 'description', + start => '2016-11-07T09:00:00', + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + }, + 3 => { + calendarIds => { + Default => JSON::true, + }, + start => '2017-11-07T09:00:00', + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + }, + }, + }, 'R1'], + ]); + my $blobId1 = $res->[0][1]{created}{1}{'blobId'}; + $self->assert_not_null($blobId1); + + my $blobId2 = $res->[0][1]{created}{2}{'blobId'}; + $self->assert_not_null($blobId2); + + my $blobId3 = $res->[0][1]{created}{3}{'blobId'}; + $self->assert_not_null($blobId3); + + $res = $jmap->Download('cassandane', $blobId1); + $self->assert($res->{content} =~ /BEGIN:VALARM[\s\S]+DESCRIPTION:title[\s\S]+END:VALARM/g); + + $res = $jmap->Download('cassandane', $blobId2); + $self->assert($res->{content} =~ /BEGIN:VALARM[\s\S]+DESCRIPTION:description[\s\S]+END:VALARM/g); + + $res = $jmap->Download('cassandane', $blobId3); + $self->assert($res->{content} =~ /BEGIN:VALARM[\s\S]+DESCRIPTION:Reminder[\s\S]+END:VALARM/g); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_owner b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_owner new file mode 100644 index 0000000000..1fe8bc994f --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_owner @@ -0,0 +1,121 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_alerts_owner + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $ownerJmap = $self->{jmap}; + + xlog $self, "create sharee and share calendar"; + my ($shareeJmap) = $self->create_user('sharee'); + my $res = $ownerJmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + sharee => { + mayReadItems => JSON::true, + mayWriteAll => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog $self, "sharee creates event"; + my $now = DateTime->now(); + $now->set_time_zone('Etc/UTC'); + # bump everything forward so a slow run (say: valgrind) + # doesn't cause things to magically fire... + $now->add(DateTime::Duration->new(seconds => 300)); + + # define the event to start in a few seconds + my $startdt = $now->clone(); + $startdt->add(DateTime::Duration->new(seconds => 2)); + my $start = $startdt->strftime('%Y-%m-%dT%H:%M:%S'); + + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + start => $start, + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + + xlog $self, "owner sets alert on event"; + $res = $ownerJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + alerts => { + ownerAlert => { + '@type' => 'Alert', + uid => '97d7c889-272f-4ce3-8d21-4a32b17ecece', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog $self, "clear notifications"; + $self->{instance}->getnotify(); + + xlog $self, "simulate previous calalarmd run"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() - 60); + + xlog $self, "assert no alarm is fired"; + my $data = $self->{instance}->getnotify(); + my @events; + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + $self->assert_str_not_equals("CalendarAlarm", $e->{event}); + } + } + + xlog $self, "clear notifications"; + $self->{instance}->getnotify(); + + xlog $self, "run calalarmd"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60); + + xlog $self, "assert alarm is fired"; + my $data = $self->{instance}->getnotify(); + my @events; + foreach (@$data) { + if ($_->{CLASS} eq 'EVENT') { + my $e = decode_json($_->{MESSAGE}); + if ($e->{event} eq "CalendarAlarm") { + push @events, $e; + } + } + } + $self->assert_str_equals('CalendarAlarm', $events[0]{event}); + $self->assert_str_equals('ownerAlert', $events[0]{alertId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_uid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_uid new file mode 100644 index 0000000000..c9fb71729c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_alerts_uid @@ -0,0 +1,159 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_alerts_uid + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "Create CalDAV event with VALARM without UID"; + $caldav->Request('PUT', + "/dav/calendars/user/cassandane/Default/alarmnouid.ics", +< 'text/calendar'); + + xlog $self, "Create CalDAV event with VALARM with UID"; + my $alertFixedUid = '8378e120-cd1c-43fb-805e-06348592b644'; + $caldav->Request('PUT', + "/dav/calendars/user/cassandane/Default/alarmfixeduid.ics", +< 'text/calendar'); + + xlog $self, "Create JMAP events having UUID, SHA1 and simple alert ids"; + + my $uuidJmapId = 'b23087c0-8822-4f29-a279-741c102fdc26'; + my $sha1JmapId = 'f142b15de0ad6e1631fd2db106dd3e906a260747'; + my $simpleJmapId = 'alert1'; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + uuidJmapId => { + title => 'uuidJmapId', + calendarIds => { + Default => JSON::true, + }, + start => '2023-08-01T17:07:00', + timeZone => 'Etc/UTC', + alerts => { + $uuidJmapId => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + action => "display", + } + } + }, + sha1JmapId => { + title => 'sha1JmapId', + calendarIds => { + Default => JSON::true, + }, + start => '2023-08-02T17:07:00', + timeZone => 'Etc/UTC', + alerts => { + $sha1JmapId => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + action => "display", + } + } + }, + simpleJmapId => { + title => 'simpleJmapId', + calendarIds => { + Default => JSON::true, + }, + start => '2023-08-03T17:07:00', + timeZone => 'Etc/UTC', + alerts => { + $simpleJmapId => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + action => "display", + } + } + }, + }, + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{created}{uuidJmapId}); + $self->assert_not_null($res->[0][1]{created}{sha1JmapId}); + $self->assert_not_null($res->[0][1]{created}{simpleJmapId}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => ['title', 'alerts', 'cyrusimap.org:iCalProps'], + }, 'R2'], + ]); + + my %eventAlerts = map { $_->{title} => $_->{alerts} } @{$res->[1][1]{list}}; + + # Alert with no UID gets some JMAP id assigned + $self->assert_num_equals(1, scalar keys %{$eventAlerts{noUid}}); + $self->assert_null((values %{$eventAlerts{noUid}})[0]{uid}); + + # Alarm with UUID JMAP id gets the same value as UID + $self->assert_str_equals('uid', $eventAlerts{uuidJmapId}{ + $uuidJmapId}{'cyrusimap.org:iCalProps'}[0][0]); + $self->assert_str_equals($uuidJmapId, $eventAlerts{uuidJmapId}{ + $uuidJmapId}{'cyrusimap.org:iCalProps'}[0][3]); + + # Alarm with simple JMAP id gets some other value as UID + $self->assert_str_equals('uid', $eventAlerts{simpleJmapId}{ + $simpleJmapId}{'cyrusimap.org:iCalProps'}[0][0]); + $self->assert_str_not_equals($simpleJmapId, $eventAlerts{simpleJmapId}{ + $simpleJmapId}{'cyrusimap.org:iCalProps'}[0][3]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_attachbinary_blobid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_attachbinary_blobid new file mode 100644 index 0000000000..38ba765281 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_attachbinary_blobid @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_attachbinary_blobid + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +TRANSP:TRANSPARENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:event1 +ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=text/plain:aGVsbG8= +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $rawIcal, + 'Content-Type' => 'text/calendar'); + + xlog "Fetch Link.blobId"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['links'], + }, 'R1'], + ]); + my $event1 = $res->[0][1]{list}[0]; + $self->assert_not_null($event1); + my $blobId1 = (values %{$event1->{links}})[0]->{blobId}; + $self->assert_not_null($blobId1); + + xlog "Assert blobId is a smart blob"; + $self->assert_str_equals("I", substr($blobId1, 0, 1)); + + xlog "Create event with same blobId"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event2 => { + calendarIds => { + Default => JSON::true, + }, + title => "event2", + start => "2021-08-01T23:30:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + links => { + link => { + blobId => $blobId1, + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event2'], + properties => ['links', 'x-href'], + }, 'R2'], + ]); + my $event2 = $res->[1][1]{list}[0]; + $self->assert_not_null($event2); + my $blobId2 = (values %{$event2->{links}})[0]->{blobId}; + + xlog "Assert blobId is a G blob"; + $self->assert_str_equals("G", substr($blobId2, 0, 1)); + + xlog "Assert /set response reported new blobId"; + $self->assert_str_equals($blobId2, + $res->[0][1]{created}{event2}{"links/link/blobId"}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_attachbinary_datauri b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_attachbinary_datauri new file mode 100644 index 0000000000..7abe9de40a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_attachbinary_datauri @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_attachbinary_datauri + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event with data: URI in Link.href"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2019-12-10T23:30:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + links => { + link => { + href =>'data:text/plain;base64,aGVsbG8=', + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event1'], + properties => ['links', 'x-href'], + }, 'R2'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + xlog "Fetch event without Cyrus extension"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => ['#event1'], + properties => ['links'], + }, 'R1'], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + ]); + my $linkWithoutExt = (values %{$res->[0][1]{list}[0]{links}})[0]; + $self->assert_str_equals('data:text/plain;base64,aGVsbG8=', + $linkWithoutExt->{href}); + $self->assert_null($linkWithoutExt->{blobId}); + $self->assert_str_equals('text/plain', + $linkWithoutExt->{contentType}); + + xlog "Fetch event with Cyrus extension"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => ['#event1'], + properties => ['links', 'x-href'], + }, 'R1'], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + my $linkWithExt = (values %{$res->[0][1]{list}[0]{links}})[0]; + $self->assert_null($linkWithExt->{href}); + $self->assert_not_null($linkWithExt->{blobId}); + $self->assert_str_equals('text/plain', $linkWithExt->{contentType}); + my $xhref = $res->[0][1]{list}[0]{'x-href'}; + $self->assert_not_null($xhref); + + xlog "Assert ATTACH BINARY in VEVENT"; + my $caldavResponse = $caldav->Request('GET', $xhref); + my $ical = Data::ICal->new(data => $caldavResponse->{content}); + my %entries = map { $_->ical_entry_type() => $_ } @{$ical->entries()}; + my $vevent = $entries{'VEVENT'}; + $self->assert_not_null($vevent); + + my $attach = $vevent->property('ATTACH'); + $self->assert_num_equals(1, scalar @{$attach}); + $self->assert_str_equals('BINARY', $attach->[0]->parameters()->{VALUE}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_baseeventid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_baseeventid new file mode 100644 index 0000000000..0df5696079 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_baseeventid @@ -0,0 +1,126 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_baseeventid + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $event1Uid = '1cf5da26-38e9-47ac-8449-04354ae3772d'; + my $event2Uid = '20623313-524c-487f-bd20-beab02e87f88'; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + 'Default' => JSON::true, + }, + uid => $event1Uid, + title => "event1", + start => "2023-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'daily', + count => 3, + }], + }, + event2 => { + calendarIds => { + 'Default' => JSON::true, + }, + uid => $event2Uid, + title => "event2", + start => "2023-02-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'daily', + count => 3, + }], + }, + } + }, 'R1'], + ]); + my $event1Id = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($event1Id); + my $event2Id = $res->[0][1]{created}{event2}{id}; + $self->assert_not_null($event2Id); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + filter => { + before => '2023-01-03T00:00:00', + }, + sort => [{ + property => 'start', + }], + expandRecurrences => JSON::true, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => ['baseEventId', 'utcStart',], + }, 'R2'], + ]); + + my $eventInstance1Id = encode_eventid($event1Uid, '20230101T090000'); + my $eventInstance2Id = encode_eventid($event1Uid, '20230102T090000'); + + $self->assert_deep_equals([ + $eventInstance1Id, $eventInstance2Id + ], $res->[0][1]{ids}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + rdate => { + calendarIds => { + Default => JSON::true, + }, + baseEventId => $event1Id, + recurrenceId => '20230101T230000', + recurrenceIdTimeZone => 'Europe/Vienna', + start => '20230101T230000', + title => 'rdateevent', + }, + }, + update => { + $event1Id => { + baseEventId => $event2Id, + }, + $eventInstance1Id => { + baseEventId => $event2Id, + }, + $eventInstance2Id => { + baseEventId => $event1Id, + }, + }, + }, 'R1'], + ]); + + # Can't create an event with a baseEventId + $self->assert(grep $_ eq 'baseEventId', + @{$res->[0][1]{notCreated}{rdate}{properties}}); + + # Can't set the baseEventId on a non-instance + $self->assert(grep $_ eq 'baseEventId', + @{$res->[0][1]{notUpdated}{$event1Id}{properties}}); + + # Can't change the baseEventId of an instance + $self->assert(grep $_ eq 'baseEventId', + @{$res->[0][1]{notUpdated}{$eventInstance1Id}{properties}}); + + # Can keep the baseEventId of an instance + $self->assert(exists $res->[0][1]{updated}{$eventInstance2Id}); + + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_bogus_replyto b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_bogus_replyto new file mode 100644 index 0000000000..2e2a222238 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_bogus_replyto @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_bogus_replyto + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + 'Default' => JSON::true, + }, + title => "event1", + start => "2021-01-01T02:00:00", + timeZone => 'Europe/Berlin', + duration => 'PT1H', + replyTo => 'cassandane@example.com', + participants => { + part1 => { + sendTo => { + imip => 'part1@example.com', + }, + }, + }, + }, + event2 => { + calendarIds => { + 'Default' => JSON::true, + }, + title => "event2", + start => "2021-01-01T02:00:00", + timeZone => 'Europe/Berlin', + duration => 'PT1H', + replyTo => { + imip => 'cassandane@example.com', + }, + participants => { + part1 => { + sendTo => 'part1@example.com', + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_deep_equals(['replyTo'], + $res->[0][1]{notCreated}{event1}{properties}); + $self->assert_deep_equals(['participants/part1/sendTo'], + $res->[0][1]{notCreated}{event2}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_bymonth b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_bymonth new file mode 100644 index 0000000000..6a5994c8a3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_bymonth @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_bymonth + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "start"=> "2010-02-12T00:00:00", + "recurrenceRules"=> [{ + "frequency"=> "monthly", + "interval"=> 13, + "byMonth"=> [ + "4L" + ], + "count"=> 3, + }], + "\@type"=> "Event", + "title"=> "", + "description"=> "", + "locations"=> undef, + "links"=> undef, + "showWithoutTime"=> JSON::false, + "duration"=> "PT0S", + "timeZone"=> undef, + "recurrenceOverrides"=> undef, + "status"=> "confirmed", + "freeBusyStatus"=> "busy", + "replyTo"=> undef, + "participants"=> undef, + "useDefaultAlerts"=> JSON::false, + "alerts"=> undef + }; + + my $ret = $self->createandget_event($event); + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_caldav b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_caldav new file mode 100644 index 0000000000..b19d6113c7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_caldav @@ -0,0 +1,137 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_caldav + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { + "1" => { + name => "A", color => "coral", sortOrder => 1, isVisible => JSON::true + } + }}, "R1"]]); + my $calid = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "create event in calendar"; + $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "foo", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2015-10-06T00:00:00", + "duration" => "P1D", + "timeZone" => undef, + } + }}, "R1"]]); + my $eventId1 = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get x-href of event $eventId1"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$eventId1]}, "R1"]]); + my $xhref = $res->[0][1]{list}[0]{"x-href"}; + my $state = $res->[0][1]{state}; + + xlog $self, "GET event $eventId1 in CalDAV"; + $res = $caldav->Request('GET', $xhref); + my $ical = $res->{content}; + $self->assert_matches(qr/SUMMARY:foo/, $ical); + + xlog $self, "DELETE event $eventId1 via CalDAV"; + $res = $caldav->Request('DELETE', $xhref); + + xlog $self, "get (non-existent) event $eventId1"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$eventId1]}, "R1"]]); + $self->assert_str_equals($eventId1, $res->[0][1]{notFound}[0]); + + xlog $self, "get calendar event updates"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { sinceState => $state }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($eventId1, $res->[0][1]{destroyed}[0]); + $state = $res->[0][1]{newState}; + + my $uid2 = '97c46ea4-4182-493c-87ef-aee4edc2d38b'; + $ical = <Request('PUT', "$calid/$uid2.ics", $ical, 'Content-Type' => 'text/calendar'); + + xlog $self, "get calendar event updates"; + $res = $jmap->CallMethods([['CalendarEvent/changes', { sinceState => $state }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_equals($eventId2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get x-href of event $eventId2"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$eventId2]}, "R1"]]); + $xhref = $res->[0][1]{list}[0]{"x-href"}; + $state = $res->[0][1]{state}; + + xlog $self, "update event $eventId2"; + $res = $jmap->CallMethods([['CalendarEvent/set', { update => { + "$eventId2" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "bam", + "description" => "", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2015-10-10T00:00:00", + "duration" => "P1D", + "timeZone" => undef, + } + }}, "R1"]]); + + xlog $self, "GET event $eventId2 in CalDAV"; + $res = $caldav->Request('GET', $xhref); + $ical = $res->{content}; + $self->assert_matches(qr/SUMMARY:bam/, $ical); + + xlog $self, "destroy event $eventId2"; + $res = $jmap->CallMethods([['CalendarEvent/set', { destroy => [$eventId2] }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_equals($eventId2, $res->[0][1]{destroyed}[0]); + + xlog $self, "PROPFIND calendar $calid for non-existent event UID $uid2 in CalDAV"; + # We'd like to GET the just destroyed event, to make sure that it also + # vanished on the CalDAV layer. Unfortunately, that GET would cause + # Net-DAVTalk to burst into flames with a 404 error. Instead, issue a + # PROPFIND and make sure that the event id doesn't show in the returned + # DAV resources. + my $xml = < + + + +EOF + $res = $caldav->Request('PROPFIND', "$calid", $xml, + 'Content-Type' => 'application/xml', + 'Depth' => '1' + ); + $self->assert_does_not_match(qr{$uid2}, $res); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_create_ignore_uid_in_special_calendar b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_create_ignore_uid_in_special_calendar new file mode 100644 index 0000000000..d283ad19d5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_create_ignore_uid_in_special_calendar @@ -0,0 +1,90 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_create_ignore_uid_in_special_calendar + :min_version_3_7 :needs_component_jmap :needs_component_sieve +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admin = $self->{adminstore}->get_client(); + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <7e017102-0caf-490a-bbdf-422141d34e75@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:7e017102-0caf-490a-bbdf-422141d34e75 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog "Lookup event uid"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'uid'], + }, 'R0'], + ]); + my $eventId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($eventId); + my $eventUid = $res->[0][1]{list}[0]{uid}; + $self->assert_not_null($eventUid); + + xlog "Destroy event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [$eventId], + }, 'R0'], + ]); + $self->assert_deep_equals([$eventId], $res->[0][1]{destroyed}); + + xlog "Create event having the same uid"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => $eventUid, + title => 'test', + start => '2021-01-01T01:01:01', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{created}{event}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_create_no_reply_exdate b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_create_no_reply_exdate new file mode 100644 index 0000000000..04b8eb51ed --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_create_no_reply_exdate @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_create_no_reply_exdate + : needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + $self->{instance}->getnotify(); + + # Create an event having an EXDATE and the user as ATTENDEE with + # PARTSTAT=NEEDS-ACTION. This must not cause an iTIP REPLY to be + # sent, even if the sendSchedulingMessages argument is enabled. + + my $res = $jmap->CallMethods([ + [ + 'CalendarEvent/set', + { + create => { + event => { + '@type' => 'Event', + calendarIds => { + Default => JSON::true, + }, + title => 'test', + start => '2024-06-18T14:00:00', + timeZone => 'Etc/UTC', + recurrenceRules => [ + { + '@type' => 'RecurrenceRule', + count => 3, + frequency => 'weekly', + }, + ], + recurrenceOverrides => { + '2024-06-25T14:00:00' => { + excluded => JSON::true, + }, + }, + replyTo => { + imip => 'mailto:organizer@example.com', + }, + participants => { + attendee1 => { + '@type' => 'Participant', + expectReply => JSON::true, + participationStatus => 'needs-action', + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + attendee2 => { + '@type' => 'Participant', + expectReply => JSON::true, + participationStatus => 'needs-action', + roles => { + owner => JSON::true, + }, + sendTo => { + imip => 'mailto:organizer@example.com', + }, + }, + }, + }, + }, + sendSchedulingMessages => JSON::true, + }, + 'R1', + ], + ]); + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($imip); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created new file mode 100644 index 0000000000..50fd567aa0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created @@ -0,0 +1,100 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_created + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $t = DateTime->now(); + $t->set_time_zone('Etc/UTC'); + my $start = $t->strftime('%Y-%m-%dT%H:%M:%S'); + $t->add(DateTime::Duration->new(days => -2)); + my $past = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + $t->add(DateTime::Duration->new(days => 4)); + my $future = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventNoCreated => { + calendarIds => { + 'Default' => JSON::true, + }, + start => $start, + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'eventNoCreated', + }, + eventCreatedInPast => { + calendarIds => { + 'Default' => JSON::true, + }, + start => $start, + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'eventCreatedInPast', + created => $past, + }, + eventCreatedInFuture => { + calendarIds => { + 'Default' => JSON::true, + }, + start => $start, + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'eventCreatedInPast', + created => $future, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ '#eventNoCreated' ], + properties => ['created', 'title'], + }, 'R2'], + ['CalendarEvent/get', { + ids => [ '#eventCreatedInPast' ], + properties => ['created', 'title'], + }, 'R3'], + ['CalendarEvent/get', { + ids => [ '#eventCreatedInFuture' ], + properties => ['created', 'title'], + }, 'R4'], + ]); + + xlog "Event with no created property get set to now"; + my $created = $res->[1][1]{list}[0]{created}; + $self->assert(($past lt $created) and ($created lt $future)); + $self->assert_str_equals($created, + $res->[0][1]{created}{eventNoCreated}{created}); + my $eventNoCreatedId = $res->[1][1]{list}[0]{id}; + + xlog "Event with past created preserves value"; + $created = $res->[2][1]{list}[0]{created}; + $self->assert_str_equals($past, $created); + $self->assert_null($res->[0][1]{created}{eventCreatedInPast}{created}); + + xlog "Event with future created gets clamped to now"; + $created = $res->[3][1]{list}[0]{created}; + $self->assert(($past lt $created) and ($created lt $future)); + $self->assert_str_equals($created, + $res->[0][1]{created}{eventCreatedInFuture}{created}); + + xlog "Can update created value"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventNoCreatedId => { + created => $past, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ $eventNoCreatedId ], + properties => ['created'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventNoCreatedId}); + $self->assert_str_equals($past, $res->[1][1]{list}[0]{created}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created_legacy b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created_legacy new file mode 100644 index 0000000000..1c983e7c6c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created_legacy @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_created_legacy + :min_version_3_1 :max_version_3_6 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + }; + + my $ret = $self->createandget_event($event); + $self->assert_normalized_event_equals($event, $ret); + my $eventId = $ret->{id}; + my $created = $ret->{created}; + $self->assert_not_null($created); + my $updated = $ret->{updated}; + $self->assert_not_null($updated); + + sleep 1; + + # Created is preserved, updated isn't. + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'bar', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $ret = $res->[1][1]{list}[0]; + $self->assert_str_equals($created, $ret->{created}); + $self->assert_str_not_equals($updated, $ret->{updated}); + + # Client can overwrite created and updated + $created = '2015-01-01T00:00:01Z'; + $updated = '2015-01-01T00:00:02Z'; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + created => $created, + updated => $updated + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $ret = $res->[1][1]{list}[0]; + $self->assert_str_equals($created, $ret->{created}); + $self->assert_str_equals($updated, $ret->{updated}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created_override b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created_override new file mode 100644 index 0000000000..a565a9505a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_created_override @@ -0,0 +1,128 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_created_override + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $t = DateTime->now(); + $t->set_time_zone('Etc/UTC'); + my $now = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + $t->add(DateTime::Duration->new(days => -2)); + my $past = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + $t->add(DateTime::Duration->new(days => -2)); + my $waypast = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + $t->add(DateTime::Duration->new(days => 8)); + my $future = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + + xlog "Create recurring event and set 'created' timestamp"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'event', + created => $past, + start => '2021-01-01T15:30:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceRules => [{ + frequency => 'daily', + count => 30, + }], + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + + xlog "Add new override: created > main:created"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + recurrenceOverrides => { + '2021-01-02T15:30:00' => { + title => 'eventOverride', + created => $now, + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + properties => ['created', 'recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_str_equals($past, $res->[1][1]{list}[0]{created}); + $self->assert_str_equals($now, $res->[1][1]{list}[0] + {recurrenceOverrides}{'2021-01-02T15:30:00'}{created}); + + xlog "Add new override: created < main:created"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides/2021-01-03T15:30:00' => { + title => 'eventOverride', + created => $waypast, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + properties => ['created', 'recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_str_equals($past, $res->[1][1]{list}[0]{created}); + $self->assert_str_equals($waypast, $res->[1][1]{list}[0] + {recurrenceOverrides}{'2021-01-03T15:30:00'}{created}); + + xlog "Add new override: created > now: server clamps to now"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides/2021-01-04T15:30:00' => { + title => 'eventOverride', + created => $future, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + properties => ['created', 'recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_str_equals($past, $res->[1][1]{list}[0]{created}); + $self->assert_str_equals(substr($now, 0, 15), + substr($res->[1][1]{list}[0]{recurrenceOverrides} + {'2021-01-04T15:30:00'}{created}, 0, 15)); + + xlog "Can change created of existing override"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides/2021-01-02T15:30:00/created' => $waypast, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + properties => ['created', 'recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_str_equals($waypast, $res->[1][1]{list}[0]{recurrenceOverrides} + {'2021-01-02T15:30:00'}{created}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts new file mode 100644 index 0000000000..aceebc8f3f --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_defaultalerts + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + xlog "Set default alerts on calendar and event"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + defaultAlertsWithoutTime => { + alert2 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => 'PT0S', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + 2 => { + uid => 'eventuid2local', + calendarIds => { + Default => JSON::true, + }, + title => "event2", + start => "2020-01-19T00:00:00", + showWithoutTime => JSON::true, + duration => "P1D", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + my $event1Href = $res->[1][1]{created}{1}{'x-href'}; + $self->assert_not_null($event1Href); + my $event2Href = $res->[1][1]{created}{2}{'x-href'}; + $self->assert_not_null($event2Href); + + my $CaldavResponse = $CalDAV->Request('GET', $event1Href); + my $icaldata = $CaldavResponse->{content}; + $self->assert_matches(qr/TRIGGER:-PT5M/, $icaldata); + + $CaldavResponse = $CalDAV->Request('GET', $event2Href); + $icaldata = $CaldavResponse->{content}; + $self->assert_matches(qr/TRIGGER:PT0S/, $icaldata); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_caldav_etag b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_caldav_etag new file mode 100644 index 0000000000..788f97f99a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_caldav_etag @@ -0,0 +1,117 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_defaultalerts_caldav_etag + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Update default alerts on calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + '4c7086e0-6114-4a71-b6ab-4b237c66f079' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Create events with and without default alerts"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + uid => '0b610a32-82ca-48b0-a01e-a77bcf932242', + calendarIds => { + Default => JSON::true, + }, + title => "event2", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + event2 => { + uid => '9c908bf3-f69e-4fc3-8c0f-1986342f1fe5', + calendarIds => { + Default => JSON::true, + }, + title => "event2", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + }, + }, + }, 'R1'], + ]); + my $event1Href = $res->[0][1]{created}{event1}{'x-href'}; + $self->assert_not_null($event1Href); + my $event2Href = $res->[0][1]{created}{event2}{'x-href'}; + $self->assert_not_null($event2Href); + + my %Headers = ( + 'Authorization' => $caldav->auth_header(), + ); + + xlog "Get ETags for events"; + $res = $caldav->{ua}->request('HEAD', $caldav->request_url($event1Href), { + headers => \%Headers, + }); + my $event1ETag = $res->{headers}{etag}; + $self->assert_not_null($event1ETag); + $res = $caldav->{ua}->request('HEAD', $caldav->request_url($event2Href), { + headers => \%Headers, + }); + my $event2ETag = $res->{headers}{etag}; + $self->assert_not_null($event2ETag); + + xlog "Update default alerts on calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + '56bd7c39-e618-41c9-91cb-fd2f2674e674' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "ETag for event with useDefaultAlerts must not match former ETag"; + $res = $caldav->{ua}->request('HEAD', $caldav->request_url($event1Href), { + headers => \%Headers, + }); + $self->assert_str_not_equals($event1ETag, $res->{headers}{etag}); + + xlog "ETag for event without useDefaultAlerts must match former ETag"; + $res = $caldav->{ua}->request('HEAD', $caldav->request_url($event2Href), { + headers => \%Headers, + }); + $self->assert_str_equals($event2ETag, $res->{headers}{etag}); + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_caldav_xapple b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_caldav_xapple new file mode 100644 index 0000000000..3bf7f6bc39 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_caldav_xapple @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_defaultalerts_caldav_xapple + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Update default alerts on calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + + xlog "Create event with default alerts"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R1'], + ]); + my $event1Href = $res->[0][1]{created}{event1}{'x-href'}; + $self->assert_not_null($event1Href); + + xlog "Get event via CalDAV"; + $res = $caldav->Request('GET', $event1Href); + + $ical = Data::ICal->new(data => $res->{content}); + my $vevent = (grep { $_->ical_entry_type() eq 'VEVENT' } @{$ical->entries()})[0]; + $self->assert_not_null($vevent); + + my $valarm = (grep { $_->ical_entry_type() eq 'VALARM' } @{$vevent->entries()})[0]; + $self->assert_not_null($valarm); + + xlog "Assert X-JMAP-DEFAULT-ALARM is set on VALARM"; + my $xprop = $valarm->property('x-jmap-default-alarm')->[0]; + $self->assert_str_equals('TRUE', $xprop->value()); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_description b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_description new file mode 100644 index 0000000000..0404da9641 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_description @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_defaultalerts_description + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + xlog "Set default alerts on calendar and event"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + }, + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + my $event1Href = $res->[1][1]{created}{1}{'x-href'}; + $self->assert_not_null($event1Href); + + my $CaldavResponse = $CalDAV->Request('GET', $event1Href); + my $icaldata = $CaldavResponse->{content}; + $self->assert($icaldata =~ /BEGIN:VALARM[\s\S]+DESCRIPTION:event1[\s\S]+END:VALARM/g); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_etag b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_etag new file mode 100644 index 0000000000..a1d1d9d29e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_etag @@ -0,0 +1,115 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_defaultalerts_etag + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + xlog "Set default alerts on calendar and event"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + }, + 2 => { + uid => 'eventuid2local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-21T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::false, + }, + }, + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + my $event1Href = $res->[1][1]{created}{1}{'x-href'}; + $self->assert_not_null($event1Href); + my $event2Href = $res->[1][1]{created}{2}{'x-href'}; + $self->assert_not_null($event2Href); + + xlog "Get ETags of events"; + my %Headers; + if ($CalDAV->{user}) { + $Headers{'Authorization'} = $CalDAV->auth_header(); + } + my $event1URI = $CalDAV->request_url($event1Href); + my $Response = $CalDAV->{ua}->request('HEAD', $event1URI, { + headers => \%Headers, + }); + my $event1ETag = $Response->{headers}{etag}; + $self->assert_not_null($event1ETag); + my $event2URI = $CalDAV->request_url($event2Href); + $Response = $CalDAV->{ua}->request('HEAD', $event2URI, { + headers => \%Headers, + }); + my $event2ETag = $Response->{headers}{etag}; + $self->assert_not_null($event2ETag); + + xlog "Update default alerts"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert2 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT10M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Refetch ETags of events"; + $Response = $CalDAV->{ua}->request('HEAD', $event1URI, { + headers => \%Headers, + }); + $self->assert_not_null($Response->{headers}{etag}); + $self->assert_str_not_equals($event1ETag, $Response->{headers}{etag}); + $Response = $CalDAV->{ua}->request('HEAD', $event2URI, { + headers => \%Headers, + }); + $self->assert_not_null($Response->{headers}{etag}); + $self->assert_str_equals($event2ETag, $Response->{headers}{etag}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_etag_shared b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_etag_shared new file mode 100644 index 0000000000..5391ccc4b2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_defaultalerts_etag_shared @@ -0,0 +1,179 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_defaultalerts_etag_shared + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + xlog "Set default alerts on calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert1 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Create other user and share owner calendar"; + my $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->create_user("other"); + $admintalk->setacl("user.cassandane.#calendars.Default", "other", "lrsiwntex") or die; + my $service = $self->{instance}->get_service("http"); + my $otherJMAP = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + my $otherCalDAV = Net::CalDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog "Create event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "eventCass", + start => "2020-01-19T11:00:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + useDefaultAlerts => JSON::true, + color => 'yellow', + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + my $cassHref = $res->[0][1]{created}{1}{'x-href'}; + $self->assert_not_null($cassHref); + + xlog "Get event as other user"; + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + 'urn:ietf:params:jmap:mail', + ]; + $res = $otherJMAP->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + properties => ['x-href'], + }, 'R1'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + my $otherHref = $res->[0][1]{list}[0]{'x-href'}; + $self->assert_not_null($otherHref); + + xlog "Set per-user prop to force per-user data split"; + $res = $otherJMAP->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + color => 'green', + }, + }, + }, 'R1'], + ], $using); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Get ETag of event as cassandane"; + my %Headers; + if ($CalDAV->{user}) { + $Headers{'Authorization'} = $CalDAV->auth_header(); + } + my $cassURI = $CalDAV->request_url($cassHref); + my $ua = $CalDAV->ua(); + my $Response = $ua->request('HEAD', $cassURI, { + headers => \%Headers, + }); + my $cassETag = $Response->{headers}{etag}; + $self->assert_not_null($cassETag); + + xlog "Get ETag of event as other"; + %Headers = (); + if ($otherCalDAV->{user}) { + $Headers{'Authorization'} = $otherCalDAV->auth_header(); + } + my $otherURI = $otherCalDAV->request_url($otherHref); + my $otherUa = $otherCalDAV->ua(); + $Response = $otherUa->request('HEAD', $otherURI, { + headers => \%Headers, + }); + my $otherETag = $Response->{headers}{etag}; + $self->assert_not_null($otherETag); + + xlog "Update default alerts for cassandane"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + defaultAlertsWithTime => { + alert2 => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT10M', + }, + action => 'display', + }, + }, + } + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Refetch ETags of events"; + %Headers = (); + if ($CalDAV->{user}) { + $Headers{'Authorization'} = $CalDAV->auth_header(); + } + $Response = $CalDAV->{ua}->request('HEAD', $cassURI, { + headers => \%Headers, + }); + $self->assert_not_null($Response->{headers}{etag}); + $self->assert_str_not_equals($cassETag, $Response->{headers}{etag}); + + %Headers = (); + if ($otherCalDAV->{user}) { + $Headers{'Authorization'} = $otherCalDAV->auth_header(); + } + $Response = $otherCalDAV->{ua}->request('HEAD', $otherURI, { + headers => \%Headers, + }); + $self->assert_not_null($Response->{headers}{etag}); + $self->assert_str_equals($otherETag, $Response->{headers}{etag}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_destroy_itip b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_destroy_itip new file mode 100644 index 0000000000..8030d1838e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_destroy_itip @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_destroy_itip + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my %expectNotif = ( + 'NEEDS-ACTION' => undef, + 'TENTATIVE' => 'REPLY', + 'ACCEPTED' => 'REPLY', + ); + + while (my ($partstat, $wantNotif) = each %expectNotif) { + + xlog "Create invite with PARTSTAT=$partstat"; + my $uid = 'event' . $partstat . 'uid'; + my $ical = <Request('PUT', "/dav/calendars/user/cassandane/Default/event$partstat.ics", + $ical, 'Content-Type' => 'text/calendar'); + + my $eventId = encode_eventid($uid); + + xlog "Clean notifications"; + $self->{instance}->getnotify(); + + xlog "Destroy event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [ $eventId ], + }, 'R1'], + ]); + $self->assert_deep_equals([ $eventId ], $res->[0][1]{destroyed}); + + my $data = $self->{instance}->getnotify(); + my ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + if ($wantNotif) { + xlog "Assert iTIP notification is sent"; + $self->assert_not_null($notif); + + my $expect_id = encode_eventid($uid); + my $notif_payload = decode_json($notif->{MESSAGE}); + $self->assert_str_equals($expect_id, $notif_payload->{id}); + $self->assert_str_equals($wantNotif, $notif_payload->{method}); + } else { + xlog "Assert no iTIP notification is sent"; + $self->assert_null($notif); + } + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_endtimezone b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_endtimezone new file mode 100644 index 0000000000..a3b460d767 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_endtimezone @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_endtimezone + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "description"=> "", + "freeBusyStatus"=> "busy", + "prodId" => "foo", + }; + + my $ret; + + $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + $self->assert_normalized_event_equals($event, $ret); + + $event->{locations} = { + "loc1" => { + "timeZone" => "Europe/Berlin", + "relativeTo" => "end", + }, + }; + $ret = $self->updateandget_event({ + id => $event->{id}, + calendarIds => $event->{calendarIds}, + locations => $event->{locations}, + }); + + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_endtimezone_recurrence b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_endtimezone_recurrence new file mode 100644 index 0000000000..db33720239 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_endtimezone_recurrence @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_endtimezone_recurrence + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "locations" => { + "loc1" => { + "timeZone" => "Europe/Berlin", + "relativeTo" => "end", + }, + }, + "showWithoutTime"=> JSON::false, + "description"=> "", + "freeBusyStatus"=> "busy", + "prodId" => "foo", + "recurrenceRules" => [{ + "frequency" => "monthly", + count => 12, + }], + "recurrenceOverrides" => { + "2015-12-07T09:00:00" => { + "locations/loc1/timeZone" => "America/New_York", + }, + }, + }; + + my $ret; + + $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_exrule b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_exrule new file mode 100644 index 0000000000..947b799918 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_exrule @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_exrule + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $event = { + calendarIds => { + Default => JSON::true, + }, + title => "title", + description => "description", + start => "2020-12-03T09:00:00", + duration => "PT1H", + timeZone => "Europe/London", + showWithoutTime => JSON::false, + freeBusyStatus => "busy", + recurrenceRules => [{ + frequency => 'weekly', + }], + excludedRecurrenceRules => [{ + frequency => 'monthly', + byMonthDay => [1], + }], + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_fullblown b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_fullblown new file mode 100644 index 0000000000..a3d2968276 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_fullblown @@ -0,0 +1,260 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_fullblown + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my ($maj, $min) = Cassandane::Instance->get_version(); + + my $event1 = { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + relatedTo => { + relatedEventUid => { + '@type' => 'Relation', + relation => { + first => JSON::true, + next => JSON::true, + child => JSON::true, + parent => JSON::true, + }, + }, + }, + prodId => '-//Foo//Bar//EN', + created => '2020-12-21T07:47:00Z', + updated => '2020-12-21T07:47:00Z', + sequence => 3, + title => 'event1title', + description => 'event1description', + descriptionContentType => 'text/plain', + showWithoutTime => JSON::true, + locations => { + loc1 => { + '@type' => 'Location', + name => 'loc1name', + description => 'loc1description', + locationTypes => { + hotel => JSON::true, + other => JSON::true, + }, + relativeTo => 'end', + timeZone => 'Africa/Windhoek', + coordinates => 'geo:-22.55941,17.08323', + links => { + link1 => { + '@type' => 'Link', + href => 'https://local/loc1link1.jpg', + cid => 'foo@local', + contentType => 'image/jpeg', + size => 123, + rel => 'icon', + display => 'fullsize', + title => 'loc1title', + }, + }, + }, + }, + virtualLocations => { + virtloc1 => { + '@type' => 'VirtualLocation', + name => 'virtloc1name', + description => 'virtloca1description', + uri => 'tel:+1-555-555-5555', + features => { + audio => JSON::true, + chat => JSON::true, + feed => JSON::true, + moderator => JSON::true, + phone => JSON::true, + screen => JSON::true, + video => JSON::true, + }, + }, + }, + links => { + link1 => { + '@type' => 'Link', + href => 'https://local/link1.jpg', + cid => 'foo@local', + contentType => 'image/jpeg', + size => 123, + rel => 'icon', + display => 'fullsize', + title => 'link1title', + }, + }, + locale => 'en', + keywords => { + keyword1 => JSON::true, + keyword2 => JSON::true, + }, + color => 'silver', + recurrenceRules => [{ + '@type' => 'RecurrenceRule', + frequency => 'monthly', + interval => 2, + rscale => 'gregorian', + skip => 'forward', + firstDayOfWeek => 'tu', + byDay => [{ + '@type' => 'NDay', + day => 'we', + nthOfPeriod => 3, + }], + byMonthDay => [1,6,13,16,30], + byHour => [7,13], + byMinute => [2,46], + bySecond => [5,10], + bySetPosition => [1,5,9], + count => 7, + }], + excludedRecurrenceRules => [{ + '@type' => 'RecurrenceRule', + frequency => 'monthly', + interval => 3, + rscale => 'gregorian', + skip => 'forward', + firstDayOfWeek => 'tu', + byDay => [{ + '@type' => 'NDay', + day => 'we', + nthOfPeriod => 3, + }], + byMonthDay => [1,6,13,16,30], + byHour => [7,13], + byMinute => [2,46], + bySecond => [5,10], + bySetPosition => [1,5,9], + count => 7, + }], + recurrenceOverrides => { + '2021-02-02T02:00:00' => { + title => 'recurrenceOverrideTitle', + }, + }, + priority => 7, + freeBusyStatus => 'free', + privacy => 'secret', + replyTo => { + imip => 'mailto:orga@local', + }, + participants => { + orga => { + '@type' => 'Participant', + email => 'orga@local', + sendTo => { + imip => 'mailto:orga@local', + }, + roles => { + owner => JSON::true, + }, + }, + participant1 => { + '@type' => 'Participant', + name => 'participant1Name', + email => 'participant1@local', + description => 'participant1Description', + sendTo => { + imip => 'mailto:participant1@local', + web => 'https://local/participant1', + }, + kind => 'individual', + roles => { + attendee => JSON::true, + chair => JSON::true, + }, + locationId => 'loc1', + language => 'de', + participationStatus => 'tentative', + participationComment => 'participant1Comment', + expectReply => JSON::true, + delegatedTo => { + participant2 => JSON::true, + }, + delegatedFrom => { + participant3 => JSON::true, + }, + links => { + link1 => { + '@type' => 'Link', + href => 'https://local/participant1link1.jpg', + cid => 'foo@local', + contentType => 'image/jpeg', + size => 123, + rel => 'describedby', + title => 'participant1title', + }, + }, + }, + participant2 => { + '@type' => 'Participant', + email => 'participant2@local', + sendTo => { + imip => 'mailto:participant2@local', + }, + roles => { + attendee => JSON::true, + }, + }, + participant3 => { + '@type' => 'Participant', + email => 'participant3@local', + sendTo => { + imip => 'mailto:participant3@local', + }, + roles => { + attendee => JSON::true, + }, + }, + }, + alerts => { + 'cb777aa2-0dcd-4489-a0ac-700d1f859934' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + offset => '-PT5M', + relativeTo => 'end', + }, + }, + 'b3dc4bdc-119f-4fae-ab94-556a07aa5514' => { + '@type' => 'Alert', + trigger => { + '@type' => 'AbsoluteTrigger', + when => '2021-01-01T01:00:00Z', + }, + acknowledged => '2020-12-21T07:47:00Z', + relatedTo => { + 'cb777aa2-0dcd-4489-a0ac-700d1f859934' => { + '@type' => 'Relation', + relation => { + parent => JSON::true, + }, + }, + }, + action => 'email', + }, + }, + + start => '2021-01-01T01:00:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + status => 'tentative', + }; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => $event1, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event1'], + }, 'R2'], + ]); + $self->assert_normalized_event_equals($event1, $res->[1][1]{list}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_hideattendees b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_hideattendees new file mode 100644 index 0000000000..e8252270d8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_hideattendees @@ -0,0 +1,123 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_hideattendees + :needs_component_jmap :min_version_0_0 :max_version_0_0 +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my ($shareeJmap, $shareeCalDAV) = $self->create_user('sharee'); + + xlog "create event and share with sharee"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uidlocal', + title => "event1", + start => "2020-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'daily', + count => 3, + }], + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + sharee => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:sharee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'needs-action', + }, + attendee1 => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee1@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + recurrenceOverrides => { + '2020-01-02T09:00:00' => { + 'participants/attendee2' => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee2@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + 'participants/attendee1/participationStatus' => 'tentative', + }, + + }, + hideAttendees => JSON::true, + }, + }, + }, 'R1'], + ['Calendar/set', { + update => { + Default => { + shareWith => { + 'sharee' => { + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayRSVP => JSON::true, + }, + }, + }, + }, + }, 'R2'], + ['CalendarEvent/get', { + ids => ['#event1'], + properties => ['hideAttendees'], + }, 'R3'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + $self->assert(exists $res->[1][1]{updated}{Default}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{hideAttendees}); + + xlog "get event as sharee"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => ['participants', 'hideAttendees', 'recurrenceOverrides'], + }, 'R1'], + ]); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{hideAttendees}); + + $self->assert_not_null($res->[0][1]{list}[0]{participants}{cassandane}); + $self->assert_not_null($res->[0][1]{list}[0]{participants}{sharee}); + $self->assert_num_equals(2, scalar keys %{$res->[0][1]{list}[0]{participants}}); + $self->assert_deep_equals({ '2020-01-02T09:00:00' => {} }, + $res->[0][1]{list}[0]{recurrenceOverrides}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_hideattendees_itip b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_hideattendees_itip new file mode 100644 index 0000000000..ab6571a3e4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_hideattendees_itip @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_hideattendees_itip + :needs_component_jmap :min_version_0_0 :max_version_0_0 +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + # clean notification cache + $self->{instance}->getnotify(); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uidlocal', + title => "event1", + start => "2020-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + attendee1 => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee1@example.com', + }, + }, + attendee2 => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee2@example.com', + }, + }, + }, + hideAttendees => JSON::true, + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + my $data = $self->{instance}->getnotify(); + + my $imip = {}; + foreach my $notif (@$data) { + if (not $notif->{METHOD} eq 'imip') { + next; + } + my $msg = decode_json($notif->{MESSAGE}); + $imip->{$msg->{recipient}} = $msg; + } + + $self->assert_num_equals(2, scalar keys %{$imip}); + + $self->assert(not $imip->{'attendee1@example.com'}->{ical} =~ + m/attendee2\@example.com/); + $self->assert($imip->{'attendee1@example.com'}->{ical} =~ + m/attendee1\@example.com/); + + $self->assert(not $imip->{'attendee2@example.com'}->{ical} =~ + m/attendee1\@example.com/); + $self->assert($imip->{'attendee2@example.com'}->{ical} =~ + m/attendee2\@example.com/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_htmldescription b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_htmldescription new file mode 100644 index 0000000000..60df239870 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_htmldescription @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_htmldescription + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "description"=> 'HTML with special chars : and ; and "', + "descriptionContentType" => 'text/html', + "privacy" => "secret", + }; + + # This actually tests that Cyrus doesn't support HTML descriptions! + my $res = $jmap->CallMethods([['CalendarEvent/set', { + create => { "1" => $event, } + }, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notCreated}{"1"}{type}); + $self->assert_str_equals("descriptionContentType", $res->[0][1]{notCreated}{"1"}{properties}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_invalidpatch b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_invalidpatch new file mode 100644 index 0000000000..5f71faddca --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_invalidpatch @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_invalidpatch + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uid', + title => "event1", + description => "", + freeBusyStatus => "busy", + start => "2019-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + }, + } + }, 'R1'] + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'alerts/alert1/trigger/offset' => '-PT5M', + }, + } + }, 'R1'] + ]); + $self->assert_str_equals("invalidPatch", $res->[0][1]{notUpdated}{$eventId}{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_isdraft b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_isdraft new file mode 100644 index 0000000000..1cd3fa8e5a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_isdraft @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_isdraft + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + # Create events as draft and non-draft. + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "draft", + "start"=> "2019-12-05T09:00:00", + "duration"=> "PT5M", + "timeZone"=> "Etc/UTC", + "isDraft" => JSON::true, + }, + 2 => { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "non-draft", + "start"=> "2019-12-05T10:00:00", + "duration"=> "PT5M", + "timeZone"=> "Etc/UTC", + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#1', '#2'], properties => ['isDraft'], + }, 'R2'] + ]); + my $eventDraftId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventDraftId); + my $eventNonDraftId = $res->[0][1]{created}{2}{id}; + $self->assert_not_null($eventNonDraftId); + + my %events = map { $_->{id} => $_ } @{$res->[1][1]{list}}; + $self->assert_equals(JSON::true, $events{$eventDraftId}{isDraft}); + $self->assert_equals(JSON::false, $events{$eventNonDraftId}{isDraft}); + + # Updating an arbitrary property preserves draft flag. + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventDraftId => { + description => "updated", + }, + $eventNonDraftId => { + description => "updated", + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventDraftId, $eventNonDraftId], properties => ['isDraft'], + }, 'R2'] + ]); + $self->assert_not_null($res->[0][1]{updated}{$eventDraftId}); + $self->assert_not_null($res->[0][1]{updated}{$eventNonDraftId}); + + %events = map { $_->{id} => $_ } @{$res->[1][1]{list}}; + $self->assert_equals(JSON::true, $events{$eventDraftId}{isDraft}); + $self->assert_equals(JSON::false, $events{$eventNonDraftId}{isDraft}); + + # Toggle isDraft flags (only allowed from draft to non-draft) + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventDraftId => { + "isDraft" => JSON::false, + }, + $eventNonDraftId => { + "isDraft" => JSON::true, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventDraftId, $eventNonDraftId], properties => ['isDraft'], + }, 'R2'] + ]); + $self->assert_not_null($res->[0][1]{updated}{$eventDraftId}); + $self->assert_not_null($res->[0][1]{notUpdated}{$eventNonDraftId}); + + %events = map { $_->{id} => $_ } @{$res->[1][1]{list}}; + $self->assert_equals(JSON::false, $events{$eventDraftId}{isDraft}); + $self->assert_equals(JSON::false, $events{$eventNonDraftId}{isDraft}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_itip_preserve_partstat b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_itip_preserve_partstat new file mode 100644 index 0000000000..5b4d8f2c37 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_itip_preserve_partstat @@ -0,0 +1,112 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_itip_preserve_partstat + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my ($otherJmap, $otherCalDAV) = $self->create_user('other'); + + xlog 'create event and invite other user'; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uidlocal', + title => 'event1', + start => '2020-01-01T09:00:00', + timeZone => 'Europe/Vienna', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + other => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:other@example.com', + }, + expectReply => JSON::true, + participationStatus => 'needs-action', + }, + }, + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + xlog 'Other user accepts invitation'; + $res = $otherJmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['participants'], + }, 'R1'], + ]); + my $otherId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($otherId); + $self->assert_str_equals('needs-action', + $res->[0][1]{list}[0]{participants}{other}{participationStatus}); + + $res = $otherJmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $otherId => { + 'participants/other/participationStatus' => 'accepted', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$otherId}); + + xlog 'Reschedule event and send to other user'; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['participants'], + }, 'R1'], + ]); + $self->assert_str_equals('accepted', + $res->[0][1]{list}[0]{participants}{other}{participationStatus}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + start => '2020-01-08T09:00:00', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { }, 'R2'], + + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog 'Other user receives updated event, is still accepted'; + $res = $otherJmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['start', 'participants'], + }, 'R1'], + ]); + $self->assert_str_equals('2020-01-08T09:00:00', + $res->[0][1]{list}[0]{start}); + $self->assert_str_equals('accepted', + $res->[0][1]{list}[0]{participants}{other}{participationStatus}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_keywords b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_keywords new file mode 100644 index 0000000000..ccae2d9bf9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_keywords @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_keywords + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "keywords" => { + 'foo' => JSON::true, + 'bar' => JSON::true, + 'baz' => JSON::true, + }, + }; + + my $ret = $self->createandget_event($event); + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_keywords_patch b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_keywords_patch new file mode 100644 index 0000000000..63edee97eb --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_keywords_patch @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_keywords_patch + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "keywords" => { + 'foo' => JSON::true, + 'bar' => JSON::true, + 'baz' => JSON::true, + }, + }; + + my $ret = $self->createandget_event($event); + $self->assert_normalized_event_equals($event, $ret); + my $eventId = $ret->{id}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'keywords/foo' => undef, + 'keywords/bam' => JSON::true, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $ret = $res->[1][1]{list}[0]; + $self->assert_not_null($ret); + + delete $event->{keywords}{foo}; + $event->{keywords}{bam} = JSON::true; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_linkblobid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_linkblobid new file mode 100644 index 0000000000..229fb828e3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_linkblobid @@ -0,0 +1,158 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_linkblobid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + xlog "Upload blob via JMAP"; + my $res = $jmap->Upload('jmapblob', "application/octet-stream"); + my $blobId = $res->{blobId}; + $self->assert_not_null($blobId); + + xlog "Create and assert event with a Link.blobId"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2019-12-10T23:30:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + links => { + link1 => { + rel => 'enclosure', + blobId => $blobId, + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#1'], + properties => ['links', 'x-href'], + }, 'R2'] + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + my $event = $res->[1][1]{list}[0]; + $self->assert_str_equals('enclosure', $event->{links}{link1}{rel}); + $self->assert_str_equals($blobId, $event->{links}{link1}{blobId}); + $self->assert_null($event->{links}{link1}{href}); + + xlog "download blob via CalDAV"; + my $service = $self->{instance}->get_service("http"); + my $href = 'http://' . $service->host() . ':'. $service->port() . + '/dav/calendars/user/cassandane/Attachments/' . + substr $event->{links}{link1}{blobId}, 1; + my $RawRequest = { + headers => { + 'Authorization' => $CalDAV->auth_header(), + }, + }; + $res = $CalDAV->ua->get($href, $RawRequest); + $self->assert_str_equals('jmapblob', $res->{content}); + + xlog "Remove link from event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + links => undef, + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Add attachment via CalDAV"; + $RawRequest = { + headers => { + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment;filename=test', + 'Prefer' => 'return=representation', + 'Authorization' => $CalDAV->auth_header(), + }, + content => 'davattach', + }; + my $URI = $CalDAV->request_url($event->{'x-href'}) . '?action=attachment-add'; + my $RawResponse = $CalDAV->ua->post($URI, $RawRequest); + + warn "CalDAV " . Dumper($RawRequest, $RawResponse); + $self->assert_str_equals('201', $RawResponse->{status}); + + xlog "Download attachment via JMAP"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$event->{id}], + properties => ['links', 'x-href'], + }, 'R1'] + ]); + $event = $res->[0][1]{list}[0]; + my $attachmentBlobId = (values %{$event->{links}})[0]{blobId}; + $self->assert_not_null($attachmentBlobId); + $res = $jmap->Download('cassandane', $attachmentBlobId); + $self->assert_str_equals('davattach', $res->{content}); + + xlog "Delete event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [ + $eventId, + ], + }, 'R1'] + ]); + $self->assert_str_equals($eventId, $res->[0][1]{destroyed}[0]); + + xlog "blobId and href are mutually exclusive"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2019-12-10T23:30:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + links => { + link1 => { + rel => 'enclosure', + blobId => $blobId, + href => 'somehref', + }, + }, + }, + 2 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2019-12-10T23:30:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + links => { + link1 => { + rel => 'enclosure', + }, + }, + }, + }, + }, 'R2'], + ]); + $self->assert_deep_equals(['links/link1/href', 'links/link1/blobId'], + $res->[0][1]{notCreated}{1}{properties}); + $self->assert_deep_equals(['links/link1/href', 'links/link1/blobId'], + $res->[0][1]{notCreated}{2}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_links b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_links new file mode 100644 index 0000000000..77c076b5e5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_links @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_links + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/Vienna", + "showWithoutTime"=> JSON::false, + "description"=> "", + "freeBusyStatus"=> "busy", + "links" => { + "spec" => { + href => "http://jmap.io/spec.html#calendar-events", + title => "the spec", + rel => "enclosure", + }, + "rfc5545" => { + href => "https://tools.ietf.org/html/rfc5545", + rel => "describedby", + }, + "image" => { + href => "https://foo.local/favicon.png", + rel => "icon", + cid => '123456789asd', + display => 'badge', + }, + "attach" => { + href => "http://example.com/some.url", + rel => "enclosure", + }, + }, + }; + + my $ret; + + $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_links_dupids b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_links_dupids new file mode 100644 index 0000000000..06032c98ea --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_links_dupids @@ -0,0 +1,114 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_links_dupids + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $event = { + calendarIds => { + Default => JSON::true, + }, + title => 'event1', + calendarIds => { + Default => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + links => { + link1 => { + href => 'https://local/link1', + title => 'link1', + }, + link2 => { + href => 'https://local/link2', + title => 'link2', + }, + }, + locations => { + loc1 => { + name => 'loc1', + links => { + link1 => { + href => 'https://local/loc1/link1', + title => 'loc1link1', + }, + link2 => { + href => 'https://local/loc1/link2', + title => 'loc1link2', + }, + }, + }, + loc2 => { + name => 'loc2', + links => { + link1 => { + href => 'https://local/loc2/link1', + title => 'loc2link1', + }, + link2 => { + href => 'https://local/loc2/link2', + title => 'loc2link2', + }, + }, + }, + }, + replyTo => { + imip => 'mailto:orga@local', + }, + participants => { + part1 => { + email => 'part1@local', + sendTo => { + imip => 'mailto:part1@local', + }, + roles => { + attendee => JSON::true, + }, + links => { + link1 => { + href => 'https://local/part1/link1', + title => 'part1link1', + }, + link2 => { + href => 'https://local/part1/link2', + title => 'part1link2', + }, + }, + }, + part2 => { + email => 'part2@local', + sendTo => { + imip => 'mailto:part2@local', + }, + roles => { + attendee => JSON::true, + }, + links => { + link1 => { + href => 'https://local/part2/link1', + title => 'part2link1', + }, + link2 => { + href => 'https://local/part2/link2', + title => 'part2link2', + }, + }, + }, + orga => { + email => 'orga@local', + sendTo => { + imip => 'mailto:orga@local', + }, + roles => { + owner => JSON::true, + attendee => JSON::true, + }, + }, + } + }; + my $ret = $self->createandget_event($event); + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_linksurl b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_linksurl new file mode 100644 index 0000000000..c463c66982 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_linksurl @@ -0,0 +1,106 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_linksurl + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <Request('PUT', '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => ['links'], + }, 'R2'], + ]); + my $eventId = $res->[1][1]{list}[0]{id}; + $self->assert_not_null($eventId); + + my $wantLinks = [{ + '@type' => 'Link', + href => 'https://url.example.com', + rel => 'describedby', + }]; + + my @links = values %{$res->[1][1]{list}[0]{links}}; + $self->assert_deep_equals($wantLinks, \@links); + + # Set some property other than links + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'update' + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['links'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + @links = values %{$res->[1][1]{list}[0]{links}}; + $self->assert_deep_equals($wantLinks, \@links); + my $linkId = (keys %{$res->[1][1]{list}[0]{links}})[0]; + $self->assert_not_null($linkId); + + $res = $caldav->Request('GET', '/dav/calendars/user/cassandane/Default/test.ics'); + $ical = $res->{content} =~ s/\r\n[ \t]//rg; + $self->assert($ical =~ /\nURL[^:]*:https:\/\/url\.example\.com/); + + # Even changing rel sticks links to their former iCalendar property + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + "links/$linkId/rel" => 'enclosure', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['links'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $wantLinks->[0]{rel} = 'enclosure'; + + @links = values %{$res->[1][1]{list}[0]{links}}; + $self->assert_deep_equals($wantLinks, \@links); + + $res = $caldav->Request('GET', '/dav/calendars/user/cassandane/Default/test.ics'); + $ical = $res->{content} =~ s/\r\n[ \t]//rg; + $self->assert($ical =~ /\nURL[^:]*:https:\/\/url\.example\.com/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_locations b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_locations new file mode 100644 index 0000000000..64dfa020c5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_locations @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_locations + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $locations = { + # A couple of sparse locations + locA => { + name => "location A", + description => "my great description", + }, + locB => { + name => "location B", + }, + locC => { + coordinates => "geo:48.208304,16.371602", + name => "a place in Vienna", + }, + locD => { + coordinates => "geo:48.208304,16.371602", + }, + locE => { + name => "location E", + links => { + link1 => { + href => 'https://foo.local', + rel => "enclosure", + }, + link2 => { + href => 'https://bar.local', + rel => "enclosure", + }, + }, + }, + # A full-blown location + locG => { + name => "location G", + description => "a description", + timeZone => "Europe/Vienna", + coordinates => "geo:48.2010,16.3695,183", + }, + # A location with name that needs escaping + locH => { + name => "location H,\nhas funny chars.", + description => "some boring\tdescription", + timeZone => "Europe/Vienna", + }, + }; + my $virtualLocations = { + locF => { + name => "location F", + description => "a description", + uri => "https://somewhere.local", + }, + }; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "free", + "locations" => $locations, + "virtualLocations" => $virtualLocations, + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_locations_keep_location b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_locations_keep_location new file mode 100644 index 0000000000..5dd72b8518 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_locations_keep_location @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_locations_keep_location + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "PUT iCalendar event with apple location"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +SUMMARY:test +DTSTART;TZID=American/New_York:20210923T153000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +SEQUENCE:0 +LOCATION:mainloc +X-APPLE-STRUCTURED-LOCATION + ;VALUE=URI + ;X-APPLE-RADIUS=14140.1607181516 + ;X-TITLE="mainloc" + :geo:48.208304,16.371602 +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + xlog "Assert locations in CalendarEvent"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['locations', 'x-href'] + }, 'R1'], + ]); + + my $eventId = $res->[0][1]{list}[0]{id}; + my $locations = $res->[0][1]{list}[0]{locations}; + $self->assert_num_equals(1, scalar values %{$locations}); + $self->assert_deep_equals({ + '@type' => 'Location', + name => 'mainloc', + coordinates => 'geo:48.208304,16.371602', + }, (values %{$locations})[0]); + my $xhref = $res->[0][1]{list}[0]{'x-href'}; + $self->assert_not_null($xhref); + + xlog "Add location but preserve existing one"; + $locations->{'newlocation'} = { + '@type' => 'Location', + name => 'newloc', + coordinates => 'geo:27.175015,78.042155', + }; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + locations => $locations, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + $res = $caldav->Request('GET', $xhref); + + my $vcal = Data::ICal->new(data => $res->{content}); + my %vcomps = map { $_->ical_entry_type() => $_ } @{$vcal->entries()}; + my $vevent = $vcomps{'VEVENT'}; + + my $props = $vevent->property('X-APPLE-STRUCTURED-LOCATION'); + $self->assert_num_equals(1, scalar @{$props}); + $self->assert_not_null($props->[0]->parameters()->{'X-APPLE-RADIUS'}); + $self->assert_str_equals('geo:48.208304,16.371602', $props->[0]->value()); + + $props = $vevent->property('LOCATION'); + $self->assert_num_equals(1, scalar @{$props}); + $self->assert_str_equals('mainloc', $props->[0]->value()); + + $props = $vevent->property('X-JMAP-LOCATION'); + $self->assert_num_equals(1, scalar @{$props}); + $self->assert_str_equals('newloc', $props->[0]->value()); + + xlog "Assert locations in CalendarEvent"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['locations', 'x-href'] + }, 'R1'], + ]); + $self->assert_deep_equals($locations, $res->[0][1]{list}[0]{locations}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinvite_preserve_caldav b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinvite_preserve_caldav new file mode 100644 index 0000000000..d7bfc6af56 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinvite_preserve_caldav @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_mayinvite_preserve_caldav + :min_version_3_5 :needs_component_jmap :JMAPExtensions :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "create event with mayInviteSelf and mayInviteOthers set"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'eventuid', + title => 'test', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + someone => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + expectReply => JSON::true, + participationStatus => 'needs-action', + }, + }, + mayInviteSelf => JSON::true, + mayInviteOthers => JSON::true, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event'], + properties => ['mayInviteSelf', 'mayInviteOthers'], + }, 'R2'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + my $href = $res->[0][1]{created}{event}{'x-href'}; + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mayInviteSelf}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mayInviteOthers}); + + xlog "remove mayInviteSelf via CalDAV"; + my $ical = $caldav->Request('GET', $href)->{content}; + $self->assert($ical =~ m/X-JMAP-MAY-INVITE-SELF;VALUE=BOOLEAN:TRUE/); + + $ical = join("\r\n", + grep { !($_ =~ m/X-JMAP-MAY-INVITE-SELF;VALUE=BOOLEAN:TRUE/) } + split(/\r\n/, $ical) + ); + $ical = join("\r\n", + grep { !($_ =~ m/X-JMAP-MAY-INVITE-OTHERS;VALUE=BOOLEAN:TRUE/) } + split(/\r\n/, $ical) + ); + $res = $caldav->Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar'); + + xlog "assert mayInviteSelf and mayInviteOthers are preserved"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['mayInviteSelf', 'mayInviteOthers'], + }, 'R2'], + ]); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{mayInviteSelf}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{mayInviteOthers}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinviteothers b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinviteothers new file mode 100644 index 0000000000..f9ae1d8d79 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinviteothers @@ -0,0 +1,226 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_mayinviteothers + :needs_component_jmap :JMAPExtensions :NoAltNameSpace :min_version_0_0 :max_version_0_0 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my ($shareeJmap, $shareeCalDAV) = $self->create_user('sharee'); + + xlog "create event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'eventuid', + title => 'test', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + someone => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + + xlog "can not set mayInviteOthers on override"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + recurrenceOverrides => { + '2022-02-03T22:22:22' => { + mayInviteOthers => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_deep_equals({ + type => 'invalidProperties', + properties => ['recurrenceOverrides/2022-02-03T22:22:22/mayInviteOthers'], + }, $res->[0][1]{notUpdated}{$eventId}); + + xlog "assign mayUpdatePrivate and mayRSVP to sharee", + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + 'sharee' => { + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayRSVP => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "sharee can not invite others"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/invitee' => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:invitee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$eventId}{type}); + + xlog "set mayInviteOthers on event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + mayInviteOthers => JSON::true, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['mayInviteOthers'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mayInviteOthers}); + + xlog "sharee still can not invite others"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/invitee' => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:invitee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$eventId}{type}); + + xlog "add sharee to participants"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'participants/sharee' => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:sharee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "sharee can not invite others as attendee and chair"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/invitee' => { + roles => { + 'attendee' => JSON::true, + 'chair' => JSON::true, + }, + sendTo => { + imip => 'mailto:invitee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$eventId}{type}); + + xlog "sharee invites other"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/invitee' => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:invitee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => ['participants'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_not_null($res->[1][1]{list}[0]{participants}{invitee}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinviteself b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinviteself new file mode 100644 index 0000000000..74206cfa93 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayinviteself @@ -0,0 +1,222 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_mayinviteself + :needs_component_jmap :JMAPExtensions :NoAltNameSpace :min_version_0_0 :max_version_0_0 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my ($shareeJmap, $shareeCalDAV) = $self->create_user('sharee'); + + xlog "create event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'eventuid', + title => 'test', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + someone => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + expectReply => JSON::true, + participationStatus => 'needs-action', + }, + }, + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + + xlog "can not set mayInviteSelf on override"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + recurrenceOverrides => { + '2022-02-03T22:22:22' => { + mayInviteSelf => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_deep_equals({ + type => 'invalidProperties', + properties => ['recurrenceOverrides/2022-02-03T22:22:22/mayInviteSelf'], + }, $res->[0][1]{notUpdated}{$eventId}); + + xlog "assign mayUpdatePrivate to sharee", + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + 'sharee' => { + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "sharee can not invite self"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/sharee' => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:sharee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$eventId}{type}); + + xlog "set mayInviteSelf on event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + mayInviteSelf => JSON::true, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['mayInviteSelf'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mayInviteSelf}); + + xlog "sharee can not invite self due to missing mayRSVP permission"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/sharee' => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:sharee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$eventId}{type}); + + xlog "assign mayRSVP to sharee", + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + 'sharee' => { + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayRSVP => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "sharee invites self as attendee and chair"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/sharee' => { + roles => { + 'attendee' => JSON::true, + 'chair' => JSON::true, + }, + sendTo => { + imip => 'mailto:sharee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$eventId}{type}); + + xlog "sharee invites self as attendee"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/sharee' => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:sharee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => ['participants'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_not_null($res->[1][1]{list}[0]{participants}{sharee}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayrsvp b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayrsvp new file mode 100644 index 0000000000..f838773ac0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_mayrsvp @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_mayrsvp + :needs_component_jmap :JMAPExtensions :NoAltNameSpace :min_version_0_0 :max_version_0_0 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my ($shareeJmap, $shareCalDAV) = $self->create_user('sharee'); + + xlog "create and share event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'test', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + sharee => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:sharee@example.com', + }, + expectReply => JSON::true, + participationStatus => 'needs-action', + }, + }, + }, + }, + }, 'R1'], + ['Calendar/set', { + update => { + Default => { + shareWith => { + 'sharee' => { + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + }, + }, + }, + }, + }, 'R2'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + $self->assert(exists $res->[1][1]{updated}{Default}); + + xlog "update as sharee without mayRSVP"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/sharee/participationStatus' => 'accepted', + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$eventId}{type}); + + xlog "assign mayRSVP to sharee", + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + 'sharee' => { + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayRSVP => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "update as sharee with mayRSVP"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + 'participants/sharee/participationStatus' => 'accepted', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_method b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_method new file mode 100644 index 0000000000..8390b966c5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_method @@ -0,0 +1,111 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_method + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "method on main event is rejected"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + start => '2022-01-28T09:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + method => 'request', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + someone => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notCreated}{event}{type}); + $self->assert_deep_equals(['method'], + $res->[0][1]{notCreated}{event}{properties}); + + xlog "method on override event is ignored"; # see RFC8984, section 4.3.5 + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + start => '2022-01-28T09:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + someone => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + }, + }, + recurrenceRules => [{ + frequency => 'daily', + }], + recurrenceOverrides => { + '2022-01-29T09:00:00' => { + title => 'override', + method => 'request', + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event'], + properties => ['title', 'method', 'recurrenceOverrides'], + }, 'R2'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + $self->assert_null($res->[1][1]{list}[0]{method}); + $self->assert_deep_equals({ + '2022-01-29T09:00:00' => { + title => 'override', + }, + }, $res->[1][1]{list}[0]{recurrenceOverrides}); + + xlog "can't set method in /update either"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + method => 'request', + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{$eventId}{type}); + $self->assert_deep_equals(['method'], + $res->[0][1]{notUpdated}{$eventId}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_move b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_move new file mode 100644 index 0000000000..803980f58c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_move @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_move + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create calendars A and B"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { + "1" => { + name => "A", color => "coral", sortOrder => 1, isVisible => JSON::true, + }, + "2" => { + name => "B", color => "blue", sortOrder => 1, isVisible => JSON::true + } + }}, "R1"] + ]); + my $calidA = $res->[0][1]{created}{"1"}{id}; + my $calidB = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "create event in calendar $calidA"; + $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + $calidA => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2015-10-06T00:00:00", + } + }}, "R1"]]); + my $state = $res->[0][1]{newState}; + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get calendar $id"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$id]}, "R1"]]); + my $event = $res->[0][1]{list}[0]; + $self->assert_str_equals($id, $event->{id}); + $self->assert_deep_equals({$calidA => JSON::true}, $event->{calendarIds}); + $self->assert_str_equals($state, $res->[0][1]{state}); + + xlog $self, "move event to unknown calendar"; + $res = $jmap->CallMethods([['CalendarEvent/set', { update => { + $id => { + calendarIds => { + nope => JSON::true, + }, + } + }}, "R1"]]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + + xlog $self, "get calendar $id from untouched calendar $calidA"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$id]}, "R1"]]); + $event = $res->[0][1]{list}[0]; + $self->assert_str_equals($id, $event->{id}); + $self->assert_deep_equals({$calidA => JSON::true}, $event->{calendarIds}); + + xlog $self, "move event to calendar $calidB"; + $res = $jmap->CallMethods([['CalendarEvent/set', { update => { + $id => { + calendarIds => { + $calidB => JSON::true, + }, + } + }}, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $state = $res->[0][1]{newState}; + + xlog $self, "get calendar $id"; + $res = $jmap->CallMethods([['CalendarEvent/get', {ids => [$id]}, "R1"]]); + $event = $res->[0][1]{list}[0]; + $self->assert_str_equals($id, $event->{id}); + $self->assert_deep_equals({$calidB => JSON::true}, $event->{calendarIds}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_no_preserve_iana_timezone b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_no_preserve_iana_timezone new file mode 100644 index 0000000000..2980bbab93 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_no_preserve_iana_timezone @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_no_preserve_iana_timezone + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event with custom IANA timezone via CalDAV"; + my $ical = <Request('PUT', $ics, $ical, 'Content-Type' => 'text/calendar'); + + xlog "Assert timeZone and UTC times are correct"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => ['timeZone', 'utcStart', 'utcEnd'], + }, 'R2'], + ]); + my $eventId = $res->[1][1]{list}[0]{id}; + $self->assert_not_null($eventId); + $self->assert_str_equals('Europe/Vienna', $res->[1][1]{list}[0]{timeZone}); + $self->assert_str_equals('2022-04-12T06:00:00Z', $res->[1][1]{list}[0]{utcStart}); + $self->assert_str_equals('2022-04-12T06:30:00Z', $res->[1][1]{list}[0]{utcEnd}); + + xlog "Update event title, keep timeZone untouched"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'updatedTitle', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert VTIMEZONE got replaced"; + $res = $caldav->Request('GET', $ics); + $self->assert(not $res->{content} =~ m/TZOFFSETFROM:\+1000/); + $self->assert($res->{content} =~ m/TZOFFSETFROM:\+0200/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_notitle b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_notitle new file mode 100644 index 0000000000..c2069e6691 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_notitle @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_notitle + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE314231-some-UID", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + }; + + my $ret = $self->createandget_event($event); + $self->assert_str_equals("", $ret->{title}); + my $eventId= $ret->{id}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'foo', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['title'] + }, 'R2'], + + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_str_equals('foo', $res->[1][1]{list}[0]{title}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participant_links_dir b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participant_links_dir new file mode 100644 index 0000000000..009b9d9250 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participant_links_dir @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_participant_links_dir + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my ($id, $ical) = $self->icalfile('attendeedir'); + + my $icshref = '/dav/calendars/user/cassandane/Default/attendeedir.ics'; + $caldav->Request('PUT', $icshref, $ical, 'Content-Type' => 'text/calendar'); + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + }, 'R1'], + ]); + my $event = $res->[0][1]{list}[0]; + $self->assert_not_null($event); + + # Links generated from DIR parameter loop back to DIR. + + my $linkId = (keys %{$event->{participants}{attendee}{links}})[0]; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $event->{id} => { + 'participants/attendee/links' => { + $linkId => { + href => 'https://local/attendee/dir2', + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{updated}{$event->{id}}); + + $res = $caldav->Request('GET', $icshref); + $self->assert_matches(qr/DIR="https:/, $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participantid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participantid new file mode 100644 index 0000000000..4323b16075 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participantid @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_participantid + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $participants = { + "foo" => { + email => 'foo@local', + roles => { + 'attendee' => JSON::true, + }, + locationId => "locX", + sendTo => { + imip => 'mailto:foo@local', + }, + }, + "you" => { + name => "Cassandane", + email => 'cassandane@example.com', + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + }; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "status" => "confirmed", + "replyTo" => { imip => "mailto:cassandane\@example.com" }, + "participants" => $participants, + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + delete($ret->{participants}{foo}{scheduleStatus}); + + $self->assert_normalized_event_equals($event, $ret); + + # check that we can fetch again a second time and still have the same data + my $res = $jmap->CallMethods([['CalendarEvent/get', { ids => [ $event->{id} ] }, 'R1']]); + $ret = $res->[0][1]{list}[0]; + delete($ret->{participants}{foo}{scheduleStatus}); + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants new file mode 100644 index 0000000000..c0c90bd5d6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants @@ -0,0 +1,142 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_participants + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "status" => "confirmed", + "replyTo" => { + "imip" => "mailto:foo\@local", + "web" => "http://local/rsvp", + + }, + "participants" => { + 'foo' => { + name => 'Foo', + kind => 'individual', + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + 'chair' => JSON::true, + }, + locationId => 'loc1', + participationStatus => 'accepted', + expectReply => JSON::false, + links => { + link1 => { + href => 'https://somelink.local', + rel => "enclosure", + }, + }, + participationComment => 'Sure; see you "soon"!', + sendTo => { + imip => 'mailto:foo@local', + }, + }, + 'bar' => { + name => 'Bar', + kind => 'individual', + roles => { + 'attendee' => JSON::true, + }, + locationId => 'loc2', + participationStatus => 'needs-action', + expectReply => JSON::true, + delegatedTo => { + 'bam' => JSON::true, + }, + memberOf => { + 'group' => JSON::true, + }, + links => { + link1 => { + href => 'https://somelink.local', + rel => "enclosure", + }, + }, + email => 'bar2@local', # different email than sendTo + sendTo => { + imip => 'mailto:bar@local', + }, + invitedBy => 'foo', + }, + 'bam' => { + name => 'Bam', + roles => { + 'attendee' => JSON::true, + }, + delegatedFrom => { + 'bar' => JSON::true, + }, + scheduleSequence => 7, + scheduleUpdated => '2018-07-06T05:03:02Z', + email => 'bam@local', # same email as sendTo + sendTo => { + imip => 'mailto:bam@local', + }, + }, + 'group' => { + name => 'Group', + kind => 'group', + roles => { + 'attendee' => JSON::true, + }, + email => 'group@local', + sendTo => { + 'imip' => 'mailto:groupimip@local', + 'other' => 'tel:+1-123-5555-1234', + }, + }, + 'resource' => { + name => 'Some resource', + kind => 'resource', + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:resource@local', + }, + }, + 'location' => { + name => 'Some location', + kind => 'location', + roles => { + 'attendee' => JSON::true, + }, + locationId => 'loc1', + sendTo => { + imip => 'mailto:location@local', + }, + }, + }, + locations => { + loc1 => { + name => 'location1', + }, + loc2 => { + name => 'location2', + }, + }, + }; + + my $ret = $self->createandget_event($event); + $event->{participants}{foo}{sendTo} = { imip => 'mailto:foo@local' }; + delete $event->{method}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_justorga b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_justorga new file mode 100644 index 0000000000..8d77242f3b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_justorga @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_participants_justorga + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "status" => "confirmed", + "replyTo" => { + "imip" => "mailto:foo\@local", + }, + "participants" => { + 'foo' => { + '@type' => 'Participant', + name => 'Foo', + roles => { + 'owner' => JSON::true, + }, + "sendTo" => { + "imip" => "mailto:foo\@local", + }, + email => 'foo@local', + participationStatus => 'needs-action', + scheduleSequence => 0, + expectReply => JSON::false, + }, + }, + }; + + my $ret = $self->createandget_event($event); + delete $event->{method}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_organame b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_organame new file mode 100644 index 0000000000..77256c4fe0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_organame @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_participants_organame + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "status" => "confirmed", + "replyTo" => { + "imip" => "mailto:foo\@local", + }, + "participants" => { + 'foo' => { + '@type' => 'Participant', + name => 'Foo', + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:foo@local', + }, + }, + 'bar' => { + '@type' => 'Participant', + name => 'Bar', + kind => 'individual', + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:bar@local', + }, + }, + }, + }; + + my $ret = $self->createandget_event($event); + $event->{participants}{bar}{sendTo}{imip} = 'mailto:bar@local'; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_patch b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_patch new file mode 100644 index 0000000000..6bf54a230b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_patch @@ -0,0 +1,73 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_participants_patch + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "status" => "confirmed", + "replyTo" => { + "imip" => "mailto:foo\@local", + }, + "participants" => { + 'bar' => { + name => 'Bar', + roles => { + 'attendee' => JSON::true, + }, + participationStatus => 'needs-action', + expectReply => JSON::true, + sendTo => { + imip => 'mailto:bar@local', + }, + }, + }, + }; + + my $ret = $self->createandget_event($event); + delete $event->{method}; + + # Add auto-generated owner participant for ORGANIZER. + $event->{participants}{'3e6a0e46cc0af22aff762f2e1869f23de7aca482'} = { + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:foo@local', + }, + }; + $self->assert_normalized_event_equals($event, $ret); + my $eventId = $ret->{id}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'participants/bar/participationStatus' => 'accepted', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $event->{participants}{'bar'}{participationStatus} = 'accepted'; + $ret = $res->[1][1]{list}[0]; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_recur b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_recur new file mode 100644 index 0000000000..5782dffe8e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_participants_recur @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_participants_recur + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "recurrenceRules"=> [{ + "frequency"=> "weekly", + }], + "replyTo" => { + "imip" => "mailto:foo\@local", + }, + "participants" => { + 'bar' => { + roles => { + 'attendee' => JSON::true, + }, + expectReply => JSON::true, + sendTo => { + imip => 'mailto:bar@local', + }, + }, + 'bam' => { + email => 'bam@local', + roles => { + 'attendee' => JSON::true, + }, + expectReply => JSON::true, + sendTo => { + imip => 'mailto:bam@local', + }, + }, + }, + }; + + my $ret = $self->createandget_event($event); + my $eventId = $ret->{id}; + $self->assert_not_null($eventId); + + my $barParticipantId; + while (my ($key, $value) = each(%{$ret->{participants}})) { + if ($value->{sendTo}{imip} eq 'mailto:bar@local') { + $barParticipantId = $key; + last; + } + } + $self->assert_not_null($barParticipantId); + + my $recurrenceOverrides = { + "2015-11-14T09:00:00" => { + ('participants/' . $barParticipantId) => undef, + }, + }; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides' => $recurrenceOverrides + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + $self->assert_deep_equals( + $recurrenceOverrides, $res->[1][1]{list}[0]{recurrenceOverrides} + ); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_peruser b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_peruser new file mode 100644 index 0000000000..22440a3fe1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_peruser @@ -0,0 +1,237 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_peruser + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my ($maj, $min) = Cassandane::Instance->get_version(); + + # These properties are set per-user. + my $proplist = [ + 'freeBusyStatus', + 'color', + 'keywords', + 'useDefaultAlerts', + 'alerts', + ]; + + xlog "Create an event and assert default per-user props"; + my $defaultPerUserProps = { + freeBusyStatus => 'busy', + # color omitted by default + keywords => undef, + useDefaultAlerts => JSON::false, + alerts => undef, + }; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2019-12-10T23:30:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + recurrenceRules => [{ + frequency => 'daily', + count => 5, + }], + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#1'], + properties => $proplist, + }, 'R2'] + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + my $event = $res->[1][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($defaultPerUserProps, $event); + + xlog "Create other user and share owner calendar"; + my $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->create_user("other"); + $admintalk->setacl("user.cassandane.#calendars.Default", "other", "lrsiwntex") or die; + my $service = $self->{instance}->get_service("http"); + my $otherJMAPTalk = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJMAPTalk->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/calendars', + 'urn:ietf:params:jmap:calendars', + ]); + + xlog "Set and assert per-user properties for owner"; + my $ownerPerUserProps = { + freeBusyStatus => 'free', + color => 'blue', + keywords => { + 'ownerKeyword' => JSON::true, + }, + useDefaultAlerts => JSON::true, + alerts => { + '639d8761-81ee-404c-84cd-3e419ab6f883' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => "-PT5M", + }, + action => "email", + }, + }, + }; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => $ownerPerUserProps, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => $proplist, + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $event = $res->[1][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($ownerPerUserProps, $event); + + xlog "Assert other user per-user properties for shared event"; + $res = $otherJMAPTalk->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => $proplist, + }, 'R1'] + ]); + $event = $res->[0][1]{list}[0]; + $self->assert_not_null($event); + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals({ + # inherited from owner + color => 'blue', + keywords => { + 'ownerKeyword' => JSON::true, + }, + # not inherited from owner + freeBusyStatus => 'busy', + useDefaultAlerts => JSON::false, + alerts => undef, + }, $event); + + xlog "Update and assert per-user props as other user"; + my $otherPerUserProps = { + keywords => { + 'otherKeyword' => JSON::true, + }, + color => 'red', + freeBusyStatus => 'free', + useDefaultAlerts => JSON::true, + alerts => { + 'ae3ce02e-8ad6-4250-b075-5449c2717c93' => { + '@type' => 'Alert', + trigger => { + '@type' => 'AbsoluteTrigger', + when => "2019-03-04T04:05:06Z", + }, + action => "display", + }, + }, + }; + + $res = $otherJMAPTalk->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => $otherPerUserProps, + }, + }, 'R1'], + ['CalendarEvent/get', { + + accountId => 'cassandane', + ids => [$eventId], + properties => $proplist, + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $event = $res->[1][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($otherPerUserProps, $event); + + xlog "Assert that owner kept their per-user props"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$eventId], + properties => $proplist, + }, 'R1'] + ]); + $event = $res->[0][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($ownerPerUserProps, $event); + + xlog "Remove per-user props as other user"; + $otherPerUserProps = { + keywords => undef, + freeBusyStatus => 'free', + useDefaultAlerts => JSON::true, + alerts => { + 'ae3ce02e-8ad6-4250-b075-5449c2717c93' => { + '@type' => 'Alert', + trigger => { + '@type' => 'AbsoluteTrigger', + when => "2019-03-04T04:05:06Z", + }, + action => "display", + }, + }, + }; + + $res = $otherJMAPTalk->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => { + keywords => undef, + color => undef, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => $proplist, + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $event = $res->[1][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($otherPerUserProps, $event); + + xlog "Assert that owner kept their per-user props"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$eventId], + properties => $proplist, + }, 'R1'] + ]); + $event = $res->[0][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($ownerPerUserProps, $event); + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_peruser_secretary b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_peruser_secretary new file mode 100644 index 0000000000..43cc438f0e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_peruser_secretary @@ -0,0 +1,164 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_peruser_secretary + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog 'Create sharee and share cassandane calendar'; + my $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->create_user('sharee'); + $admintalk->setacl('user.cassandane.#calendars.Default', 'sharee', 'lrsiwntex') or die; + my $service = $self->{instance}->get_service('http'); + my $shareejmap = Mail::JMAPTalk->new( + user => 'sharee', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $shareejmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/calendars', + 'urn:ietf:params:jmap:calendars', + ]); + + xlog 'Set calendar home to secretary mode'; + my $xml = < + + + + secretary + + + +EOF + $caldav->Request('PROPPATCH', "/dav/calendars/user/cassandane", $xml, + 'Content-Type' => 'text/xml'); + + xlog 'Create an event with per-user props as owner'; + my $perUserProps = { + freeBusyStatus => 'free', + color => 'blue', + keywords => { + 'ownerKeyword' => JSON::true, + }, + useDefaultAlerts => JSON::true, + alerts => { + 'd5aad69a-db22-4524-8f2d-0c10a67778d1' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT5M', + }, + action => 'email', + }, + }, + }; + + my @proplist = keys %$perUserProps; + + my $event = { + uid => 'eventuid1', + calendarIds => { + Default => JSON::true, + }, + title => 'event1', + start => '2019-12-10T23:30:00', + duration => 'PT1H', + timeZone => 'Australia/Melbourne', + }; + $event = { %$event, %$perUserProps }; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => $event, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + + xlog 'assert per-user properties for owner and sharee'; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => \@proplist, + }, 'R1'] + ]); + $event = $res->[0][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($perUserProps, $event); + + $res = $shareejmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => \@proplist, + }, 'R1'] + ]); + $event = $res->[0][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($perUserProps, $event); + + xlog 'Update per-user props as sharee'; + $perUserProps = { + freeBusyStatus => 'busy', + color => 'red', + keywords => { + 'shareeKeyword' => JSON::true, + }, + useDefaultAlerts => JSON::false, + alerts => { + 'd5aad69a-db22-4524-8f2d-0c10a67778d1' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => 'start', + offset => '-PT10M', + }, + action => 'display', + }, + }, + }; + + $res = $shareejmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventId => $perUserProps, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog 'assert per-user properties for owner and sharee'; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => \@proplist, + }, 'R1'] + ]); + $event = $res->[0][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($perUserProps, $event); + + $res = $shareejmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + ids => [$eventId], + properties => \@proplist, + }, 'R1'] + ]); + $event = $res->[0][1]{list}[0]; + delete @{$event}{qw/id uid @type/}; + $self->assert_deep_equals($perUserProps, $event); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_class b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_class new file mode 100644 index 0000000000..a41bad0d3c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_class @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_preserve_class + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <Request('PUT', '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + $res = $caldav->Request('GET', '/dav/calendars/user/cassandane/Default/test.ics'); + $self->assert_matches(qr/CLASS:PRIVATE/, $res->{content}); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ]); + my $eventId = $res->[0][1]{ids}[0]; + $self->assert_not_null($eventId); + + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'update' + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + $res = $caldav->Request('GET', '/dav/calendars/user/cassandane/Default/test.ics'); + $self->assert_matches(qr/CLASS:PRIVATE/, $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_icalxprops b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_icalxprops new file mode 100644 index 0000000000..90933a3aa2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_icalxprops @@ -0,0 +1,179 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_preserve_icalxprops + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "Create event with unknown x-properties"; + + my $mainevent_xprop = "X-MAINEVENT-PROP;X-FOO=1:foo"; + my $recurevent_xprop = "X-RECUREVENT-PROP;X-BAR=2:bar"; + my $alarm_xprop = "X-ALARM-PROP;X-BAZ=3:baz"; + + my $ical = <Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => [ + 'cyrusimap.org:iCalProps', + 'alerts', + 'recurrenceOverrides' + ], + }, 'R1'] + ]); + my $eventId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($eventId); + + xlog $self, "Assert iCalProps property"; + $self->assert_str_equals('x-mainevent-prop', + $res->[0][1]{list}[0]{'cyrusimap.org:iCalProps'}[0][0]); + + my @alarmIcalProps = sort { $_->[0] cmp $_->[1] } @{ + $res->[0][1]{list}[0]{alerts + }{'E576FA39-51B6-45FA-AAF3-7CCE1F21802E' + }{'cyrusimap.org:iCalProps'}}; + $self->assert_num_equals(2, scalar @alarmIcalProps); + $self->assert_str_equals('uid', $alarmIcalProps[0][0]); + $self->assert_str_equals('x-alarm-prop', $alarmIcalProps[1][0]); + + $self->assert_str_equals('x-recurevent-prop', + $res->[0][1]{list}[0]{recurrenceOverrides}{'2023-03-10T16:00:00' + }{'cyrusimap.org:iCalProps'}[0][0]); + + + xlog $self, "Update some event property via JMAP"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'test2', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + properties => [ + 'cyrusimap.org:iCalProps', + 'alerts', + 'recurrenceOverrides' + ], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog $self, "Assert iCalProps property"; + $self->assert_str_equals('x-mainevent-prop', + $res->[1][1]{list}[0]{'cyrusimap.org:iCalProps'}[0][0]); + + my @alarmIcalProps = sort { $_->[0] cmp $_->[1] } @{ + $res->[1][1]{list}[0]{alerts + }{'E576FA39-51B6-45FA-AAF3-7CCE1F21802E' + }{'cyrusimap.org:iCalProps'}}; + $self->assert_num_equals(2, scalar @alarmIcalProps); + $self->assert_str_equals('uid', $alarmIcalProps[0][0]); + $self->assert_str_equals('x-alarm-prop', $alarmIcalProps[1][0]); + + $self->assert_str_equals('x-recurevent-prop', + $res->[1][1]{list}[0]{recurrenceOverrides}{'2023-03-10T16:00:00' + }{'cyrusimap.org:iCalProps'}[0][0]); + + xlog $self, "Can not update iCalProps"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'cyrusimap.org:iCalProps' => [ + ['x-mainevent-prop', {}, 'text', 'newval'], + ], + 'alerts/E576FA39-51B6-45FA-AAF3-7CCE1F21802E/cyrusimap.org:iCalProps' => [ + ['x-alarm-prop', {}, 'text', 'newalarmval'], + ], + 'recurrenceOverrides/2023-03-10T16:00:00/cyrusimap.org:iCalProps/0/3' => [ + ['x-recurevent-prop', {}, 'text', 'newrecurval'], + ], + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + properties => [ + 'cyrusimap.org:iCalProps', + 'alerts', + 'recurrenceOverrides' + ], + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{notUpdated}{$eventId}); + + xlog $self, "Assert iCalProps property"; + $self->assert_str_equals('x-mainevent-prop', + $res->[1][1]{list}[0]{'cyrusimap.org:iCalProps'}[0][0]); + + my @alarmIcalProps = sort { $_->[0] cmp $_->[1] } @{ + $res->[1][1]{list}[0]{alerts + }{'E576FA39-51B6-45FA-AAF3-7CCE1F21802E' + }{'cyrusimap.org:iCalProps'}}; + $self->assert_num_equals(2, scalar @alarmIcalProps); + $self->assert_str_equals('uid', $alarmIcalProps[0][0]); + $self->assert_str_equals('x-alarm-prop', $alarmIcalProps[1][0]); + + $self->assert_str_equals('x-recurevent-prop', + $res->[1][1]{list}[0]{recurrenceOverrides}{'2023-03-10T16:00:00' + }{'cyrusimap.org:iCalProps'}[0][0]); + + xlog $self, "Can not create iCalProps"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event2 => { + calendarIds => { + Default => JSON::true, + }, + title => 'event2', + start => '2023-08-03T17:45:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + 'cyrusimap.org:iCalProps' => [ + ['x-prop', {}, 'text', 'foo'], + ] + } + }, + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{notCreated}{event2}); + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_microsoft_timezone b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_microsoft_timezone new file mode 100644 index 0000000000..b3fa407042 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_preserve_microsoft_timezone @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_preserve_microsoft_timezone + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event with Microsoft timezone via CalDAV"; + my $ical = <Request('PUT', $ics, $ical, 'Content-Type' => 'text/calendar'); + + xlog "Assert timeZone is mapped to IANA timezone"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/query', { + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/query', + path => '/ids' + }, + properties => ['timeZone'], + }, 'R2'], + ]); + my $eventId = $res->[1][1]{list}[0]{id}; + $self->assert_not_null($eventId); + $self->assert_str_equals('Australia/Sydney', $res->[1][1]{list}[0]{timeZone}); + + xlog "Update event title, keep timeZone untouched"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'updatedTitle', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert VTIMEZONE is kept"; + $res = $caldav->Request('GET', $ics); + $self->assert($res->{content} =~ + m/DTSTART;TZID=AUS Eastern Standard Time:20220412T080000/); + $self->assert($res->{content} =~ + m/TZID:AUS Eastern Standard Time/); + + xlog "Update event timeZone to IANA identifier"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + timeZone => 'Europe/London', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert VTIMEZONE got replaced"; + $res = $caldav->Request('GET', $ics); + $self->assert(not $res->{content} =~ + m/DTSTART;TZID=AUS Eastern Standard Time:20220412T080000/); + $self->assert(not $res->{content} =~ + m/TZID:AUS Eastern Standard Time/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy new file mode 100644 index 0000000000..c671b1c115 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy @@ -0,0 +1,106 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_privacy + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "share calendar with cassandane user"; + my ($sharerJmap) = $self->create_user('sharer'); + my $res = $sharerJmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + cassandane => { + mayReadItems => JSON::true, + mayWriteAll => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "may only create private event on owned calendar"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'sharer', + create => { + eventShared1 => { + calendarIds => { + 'Default' => JSON::true, + }, + title => 'eventShared1', + start => '2022-01-24T09:00:00', + timeZone => 'America/New_York', + privacy => 'public', + }, + eventShared2 => { + calendarIds => { + 'Default' => JSON::true, + }, + title => 'eventShared2', + start => '2022-01-24T10:00:00', + timeZone => 'America/New_York', + privacy => 'secret', + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + create => { + eventOwned1 => { + calendarIds => { + 'Default' => JSON::true, + }, + title => 'eventOwned1', + start => '2022-01-24T11:00:00', + timeZone => 'America/New_York', + privacy => 'secret', + }, + }, + }, 'R2'], + ]); + + my $eventShared1Id = $res->[0][1]{created}{eventShared1}{id}; + $self->assert_not_null($eventShared1Id); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notCreated}{eventShared2}{type}); + $self->assert_deep_equals(['privacy'], + $res->[0][1]{notCreated}{eventShared2}{properties}); + my $eventOwned1Id = $res->[1][1]{created}{eventOwned1}{id}; + $self->assert_not_null($eventOwned1Id); + + xlog "may not change public privacy on shared calendar"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'sharer', + update => { + $eventShared1Id => { + privacy => 'secret', + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + update => { + $eventOwned1Id => { + privacy => 'private', + }, + }, + }, 'R2'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{$eventShared1Id}{type}); + $self->assert_deep_equals(['privacy'], + $res->[0][1]{notUpdated}{$eventShared1Id}{properties}); + $self->assert(exists $res->[1][1]{updated}{$eventOwned1Id}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_ignore_override b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_ignore_override new file mode 100644 index 0000000000..d683042413 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_ignore_override @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_privacy_ignore_override + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Ignore overriden privacy in CalendarEvent/set"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uidlocal', + title => 'event1', + start => '2020-01-01T09:00:00', + timeZone => 'Europe/Vienna', + duration => 'PT1H', + privacy => 'private', + recurrenceRules => [{ + frequency => 'daily', + count => 3, + }], + recurrenceOverrides => { + '2020-01-02T09:00:00' => { + title => 'event1Override', + privacy => 'secret', + }, + }, + }, + }, + }, 'R1'], + ]); + my $xhref = $res->[0][1]{created}{event1}{'x-href'}; + $self->assert_not_null($xhref); + + $res = $caldav->Request('GET', $xhref); + $self->assert($res->{content} =~ m/X-JMAP-PRIVACY:PRIVATE/); + $self->assert(not $res->{content} =~ m/X-JMAP-PRIVACY:CONFIDENTIAL/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_private_shared b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_private_shared new file mode 100644 index 0000000000..aa089263dc --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_private_shared @@ -0,0 +1,107 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_privacy_private_shared + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "share calendar"; + my ($shareeJmap) = $self->create_user('sharee'); + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + sharee => { + mayReadItems => JSON::true, + mayWriteAll => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "get calendar event state as sharee"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', ids => [] + }, 'R1' ], + ]); + my $state = $res->[0][1]{state}; + + xlog "create private event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + privateEvent => { + calendarIds => { + 'Default' => JSON::true, + }, + start => '2021-01-01T01:00:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + title => 'privateEvent', + privacy => 'private', + }, + }, + }, 'R1'], + ]); + my $privateEventId = $res->[0][1]{created}{privateEvent}{id}; + $self->assert_not_null($privateEventId); + + xlog "sharee sees event"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + properties => ['id'], + }, 'R1' ], + ['CalendarEvent/changes', { + accountId => 'cassandane', + sinceState => $state, + }, 'R1' ], + ['CalendarEvent/query', { + accountId => 'cassandane', + }, 'R2' ], + ]); + $self->assert_str_equals($privateEventId, $res->[0][1]{list}[0]{id}); + $self->assert_deep_equals([$privateEventId], $res->[1][1]{created}); + $self->assert_deep_equals([$privateEventId], $res->[2][1]{ids}); + + xlog "sharee can't update or destroy, or copy"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $privateEventId => { + start => '2022-02-02T02:00:00', + }, + }, + }, 'R1' ], + ['CalendarEvent/set', { + accountId => 'cassandane', + destroy => [ $privateEventId ], + }, 'R2' ], + ['CalendarEvent/copy', { + accountId => 'sharee', + fromAccountId => 'cassandane', + create => { + privateEventCopy => { + id => $privateEventId, + calendarIds => { + 'Default' => JSON::true, + }, + }, + }, + }, 'R3' ], + ]); + $self->assert_str_equals('forbidden', + $res->[0][1]{notUpdated}{$privateEventId}{type}); + $self->assert_str_equals('forbidden', + $res->[1][1]{notDestroyed}{$privateEventId}{type}); + $self->assert_str_equals('forbidden', + $res->[2][1]{notCreated}{privateEventCopy}{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_secret_shared b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_secret_shared new file mode 100644 index 0000000000..47b2340dce --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_privacy_secret_shared @@ -0,0 +1,107 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_privacy_secret_shared + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "share calendar"; + my ($shareeJmap) = $self->create_user('sharee'); + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + sharee => { + mayReadItems => JSON::true, + mayWriteAll => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "get calendar event state as sharee"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', ids => [] + }, 'R1' ], + ]); + my $state = $res->[0][1]{state}; + + xlog "create secret event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + secretEvent => { + calendarIds => { + 'Default' => JSON::true, + }, + start => '2021-01-01T01:00:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + title => 'secretEvent', + privacy => 'secret', + }, + }, + }, 'R1'], + ]); + my $secretEventId = $res->[0][1]{created}{secretEvent}{id}; + $self->assert_not_null($secretEventId); + + xlog "sharee can not see event"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'cassandane', + properties => ['id'], + }, 'R1' ], + ['CalendarEvent/changes', { + accountId => 'cassandane', + sinceState => $state, + }, 'R1' ], + ['CalendarEvent/query', { + accountId => 'cassandane', + }, 'R2' ], + ]); + $self->assert_deep_equals([], $res->[0][1]{list}); + $self->assert_deep_equals([], $res->[1][1]{created}); + $self->assert_deep_equals([], $res->[2][1]{ids}); + + xlog "sharee can't update or destroy, or copy"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $secretEventId => { + start => '2022-02-02T02:00:00', + }, + }, + }, 'R1' ], + ['CalendarEvent/set', { + accountId => 'cassandane', + destroy => [ $secretEventId ], + }, 'R2' ], + ['CalendarEvent/copy', { + accountId => 'sharee', + fromAccountId => 'cassandane', + create => { + secretEventCopy => { + id => $secretEventId, + calendarIds => { + 'Default' => JSON::true, + }, + }, + }, + }, 'R3' ], + ]); + $self->assert_str_equals('notFound', + $res->[0][1]{notUpdated}{$secretEventId}{type}); + $self->assert_str_equals('notFound', + $res->[1][1]{notDestroyed}{$secretEventId}{type}); + $self->assert_str_equals('notFound', + $res->[2][1]{notCreated}{secretEventCopy}{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_prodid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_prodid new file mode 100644 index 0000000000..e2b2b14e52 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_prodid @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_prodid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/Amsterdam", + "showWithoutTime"=> JSON::false, + "description"=> "", + "freeBusyStatus"=> "busy", + }; + + my $ret; + + # assert default prodId + $ret = $self->createandget_event($event); + $self->assert_not_null($ret->{prodId}); + + # assert custom prodId + my $prodId = "my prodId"; + $event->{prodId} = $prodId; + $ret = $self->createandget_event($event); + $self->assert_str_equals($prodId, $ret->{prodId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_readonly b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_readonly new file mode 100644 index 0000000000..e43f83cf6e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_readonly @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_readonly + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + # Assert that calendar ACLs are enforced also for mailbox owner. + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + "1" => { + name => "", + color => "coral", + isVisible => \1 + } + } + }, "R1"], + ['Calendar/get', { + ids => ['#1'], + properties => ['name'], + }, "R2"], + ]); + my $calendarId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($calendarId); + my $name = $res->[1][1]{list}[0]{'name'}; + $self->assert_not_null($name); + + $admintalk->setacl("user.cassandane.#calendars." . $name, "cassandane" => 'lrskxcan9') or die; + + $res = $jmap->CallMethods([ + ['Calendar/get',{ + ids => [$calendarId], + }, "R2"], + ['CalendarEvent/set',{ + create => { + "1" => { + calendarIds => { + $calendarId => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "privacy" => "secret", + "participants" => undef, + "alerts"=> undef, + } + } + }, "R2"], + ]); + + my $calendar = $res->[0][1]{list}[0]; + $self->assert_equals(JSON::true, $calendar->{myRights}->{mayReadFreeBusy}); + $self->assert_equals(JSON::true, $calendar->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::false, $calendar->{myRights}->{mayWriteAll}); + $self->assert_equals(JSON::false, $calendar->{myRights}->{mayWriteOwn}); + $self->assert_equals(JSON::true, $calendar->{myRights}->{mayDelete}); + $self->assert_equals(JSON::true, $calendar->{myRights}->{mayAdmin}); + + $self->assert_not_null($res->[1][1]{notCreated}{1}); + $self->assert_str_equals("invalidProperties", $res->[1][1]{notCreated}{1}{type}); + $self->assert_str_equals("calendarIds", $res->[1][1]{notCreated}{1}{properties}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence new file mode 100644 index 0000000000..bfd5b7112a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrence + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $recurrenceRules = [{ + frequency => "monthly", + interval => 2, + firstDayOfWeek => "su", + count => 1024, + byDay => [{ + day => "mo", + nthOfPeriod => -2, + }, { + day => "sa", + }], + }]; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "recurrenceRules" => $recurrenceRules, + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + $self->assert_normalized_event_equals($event, $ret); + + # Now delete the recurrence rule + my $res = $jmap->CallMethods([ + ['CalendarEvent/set',{ + update => { + $event->{id} => { + recurrenceRules => undef, + }, + }, + }, "R1"], + ['CalendarEvent/get',{ + ids => [$event->{id}], + }, "R2"], + ]); + $self->assert(exists $res->[0][1]{updated}{$event->{id}}); + + delete $event->{recurrenceRules}; + $ret = $res->[1][1]{list}[0]; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_bymonthday b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_bymonthday new file mode 100644 index 0000000000..b6daba4d75 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_bymonthday @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrence_bymonthday + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + "uid" => "90c2697e-acbc-4508-9e72-6b8828e8d9f3", + calendarIds => { + $calid => JSON::true, + }, + "start" => "2019-01-31T09:00:00", + "duration" => "PT1H", + "timeZone" => "Australia/Melbourne", + "\@type" => "Event", + "title" => "Recurrence test", + "description" => "", + "showWithoutTime" => JSON::false, + "recurrenceRules" => [{ + "frequency" => "monthly", + "byMonthDay" => [ + -1 + ] + }], + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_multivalued b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_multivalued new file mode 100644 index 0000000000..4586f30e72 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_multivalued @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrence_multivalued + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $event = { + calendarIds => { + Default => JSON::true, + }, + title => "title", + description => "description", + start => "2015-11-07T09:00:00", + duration => "PT1H", + timeZone => "Europe/London", + showWithoutTime => JSON::false, + freeBusyStatus => "busy", + recurrenceRules => [{ + frequency => 'weekly', + count => 3, + }, { + frequency => 'daily', + count => 4, + }], + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_patch b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_patch new file mode 100644 index 0000000000..be21adf3f6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_patch @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrence_patch + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "Create a recurring event with alert"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + calendarIds => { + Default => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2019-01-01T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "recurrenceRules" => [{ + frequency => 'monthly', + }], + "recurrenceOverrides" => { + '2019-02-01T09:00:00' => { + duration => 'PT2H', + }, + }, + alerts => { + alert1 => { + trigger => { + relativeTo => "start", + offset => "-PT5M", + }, + }, + } + } + } + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + + xlog $self, "Patch alert in a recurrence override"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides/2019-02-01T09:00:00/alerts/alert1/trigger/offset' => '-PT10M', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_until b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_until new file mode 100644 index 0000000000..dbbf3f963d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_until @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrence_until + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + "status" =>"confirmed", + calendarIds => { + $calid => JSON::true, + }, + "showWithoutTime" => JSON::false, + "timeZone" => "America/New_York", + "freeBusyStatus" =>"busy", + "start" =>"2019-01-12T00:00:00", + "useDefaultAlerts" => JSON::false, + "uid" =>"76f46024-7284-4701-b93f-d9cd812f3f43", + "title" =>"timed event with non-zero time until", + "\@type" =>"Event", + "recurrenceRules" => [{ + "frequency" =>"weekly", + "until" =>"2019-04-20T23:59:59" + }], + "description" =>"", + "duration" =>"P1D" + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{recurrenceRules}[0]{until} = '2019-04-20T23:59:59'; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_untilallday b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_untilallday new file mode 100644 index 0000000000..962862d15b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrence_untilallday @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrence_untilallday + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $event = { + "status" =>"confirmed", + calendarIds => { + $calid => JSON::true, + }, + "showWithoutTime" => JSON::false, # for testing + "timeZone" =>undef, + "freeBusyStatus" =>"busy", + "start" =>"2019-01-12T00:00:00", + "useDefaultAlerts" => JSON::false, + "uid" =>"76f46024-7284-4701-b93f-d9cd812f3f43", + "title" =>"allday event with non-zero time until", + "\@type" =>"Event", + "recurrenceRules" => [{ + "frequency" =>"weekly", + "until" =>"2019-04-20T23:59:59" + }], + "description" =>"", + "duration" =>"P1D" + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceinstances b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceinstances new file mode 100644 index 0000000000..11376c1a41 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceinstances @@ -0,0 +1,159 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrenceinstances + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + xlog $self, "create event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + uid => 'event1uid', + title => "event1", + description => "", + freeBusyStatus => "busy", + start => "2019-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'weekly', + count => 5, + }], + }, + } + }, 'R1'] + ]); + my $eventId1 = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId1); + + # This test hard-codes the ids of recurrence instances. + # This might break if we change the id scheme. + + xlog $self, "Override a regular recurrence instance"; + my $overrideId1 = encode_eventid('event1uid','20190115T090000'); + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $overrideId1 => { + title => "override1", + }, + } + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$overrideId1}); + $self->assert_deep_equals({ + '2019-01-15T09:00:00' => { + title => "override1", + }, + }, $res->[1][1]{list}[0]{recurrenceOverrides} + ); + + xlog $self, "Update an existing override"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $overrideId1 => { + title => "override1_updated", + }, + } + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$overrideId1}); + $self->assert_deep_equals({ + '2019-01-15T09:00:00' => { + title => "override1_updated", + }, + }, $res->[1][1]{list}[0]{recurrenceOverrides} + ); + + xlog $self, "Revert an override into a regular recurrence"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $overrideId1 => { + title => "event1", # original title + }, + } + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$overrideId1}); + $self->assert_null($res->[1][1]{list}[0]{recurrenceOverrides}); + + xlog $self, "Set regular recurrence excluded"; + my $overrideId2 = encode_eventid('event1uid','20190108T090000'); + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $overrideId2 => { + excluded => JSON::true, + } + } + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$overrideId2}); + $self->assert_deep_equals({ + '2019-01-08T09:00:00' => { + excluded => JSON::true, + } + }, $res->[1][1]{list}[0]{recurrenceOverrides}); + + xlog $self, "Reset overrides"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId1 => { + recurrenceOverrides => undef, + } + } + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId1}); + $self->assert_null($res->[1][1]{list}[0]{recurrenceOverrides}); + + xlog $self, "Destroy regular recurrence instance"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [$overrideId2], + }, 'R1'], + ['CalendarEvent/get', { + ids => ['event1uid'], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert_str_equals($overrideId2, $res->[0][1]{destroyed}[0]); + $self->assert_deep_equals({ + '2019-01-08T09:00:00' => { + excluded => JSON::true, + } + }, $res->[1][1]{list}[0]{recurrenceOverrides}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceinstances_rdate b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceinstances_rdate new file mode 100644 index 0000000000..0fcd1a25ff --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceinstances_rdate @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrenceinstances_rdate + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $calid = 'Default'; + + xlog $self, "create event with RDATE"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + uid => 'event1uid', + title => "event1", + description => "", + freeBusyStatus => "busy", + start => "2019-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + recurrenceRules => [{ + frequency => 'weekly', + count => 5, + }], + recurrenceOverrides => { + '2019-01-10T14:00:00' => {} + }, + }, + } + }, 'R1'] + ]); + my $eventId1 = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId1); + + xlog $self, "Delete RDATE by setting it excluded"; + my $overrideId1 = encode_eventid('event1uid','20190110T140000'); + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $overrideId1 => { + excluded => JSON::true, + } + } + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$overrideId1}); + $self->assert_null($res->[1][1]{list}[0]{recurrenceOverrides}); + + xlog $self, "Recreate RDATE"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId1 => { + recurrenceOverrides => { + '2019-01-10T14:00:00' => {} + }, + } + } + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId1}); + $self->assert_deep_equals({ + '2019-01-10T14:00:00' => { }, + }, + $res->[1][1]{list}[0]{recurrenceOverrides} + ); + + xlog $self, "Destroy RDATE"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [$overrideId1], + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['recurrenceOverrides'], + }, 'R2'], + ]); + $self->assert_str_equals($overrideId1, $res->[0][1]{destroyed}[0]); + $self->assert_null($res->[1][1]{list}[0]{recurrenceOverrides}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceoverrides b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceoverrides new file mode 100644 index 0000000000..d45572baac --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceoverrides @@ -0,0 +1,81 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrenceoverrides + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $recurrenceRules = [{ + frequency => "monthly", + count => 12, + }]; + + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2016-01-01T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + "freeBusyStatus"=> "busy", + "locations" => { + locA => { + "name" => "location A", + }, + locB => { + "coordinates" => "geo:48.208304,16.371602", + }, + }, + "links" => { + "link1" => { + href => "http://jmap.io/spec.html#calendar-events", + title => "the spec", + rel => 'enclosure', + }, + "link2" => { + href => "https://tools.ietf.org/html/rfc5545", + rel => 'enclosure', + }, + }, + "recurrenceRules" => $recurrenceRules, + "recurrenceOverrides" => { + "2016-02-01T09:00:00" => { excluded => JSON::true }, + "2016-02-03T09:00:00" => {}, + "2016-04-01T10:00:00" => { + "description" => "don't come in without an April's joke!", + "locations/locA/name" => "location A exception", + "links/link2/title" => "RFC 5545", + }, + "2016-05-01T10:00:00" => { + "title" => "Labour Day", + }, + "2016-06-01T10:00:00" => { + freeBusyStatus => "free", + }, + "2016-07-01T09:00:00" => { + "uid" => "foo", + }, + }, + }; + + my $ret = $self->createandget_event($event); + $event->{id} = $ret->{id}; + $event->{calendarIds} = $ret->{calendarIds}; + delete $event->{recurrenceOverrides}{"2016-07-01T09:00:00"}; # ignore patch with 'uid' + $self->assert_normalized_event_equals($event, $ret); + + $ret = $self->updateandget_event({ + id => $event->{id}, + calendarIds => $event->{calendarIds}, + title => "updated title", + }); + $event->{title} = "updated title"; + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceoverrides_mixed_datetypes b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceoverrides_mixed_datetypes new file mode 100644 index 0000000000..694ec37a8c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_recurrenceoverrides_mixed_datetypes @@ -0,0 +1,66 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_recurrenceoverrides_mixed_datetypes + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my ($id, $ical) = $self->icalfile('recurrenceoverrides-mixed-datetypes'); + + my $event = $self->putandget_vevent($id, $ical); + my $wantOverrides = { + "2018-05-01T00:00:00" => { + start => "2018-05-02T17:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + showWithoutTime => JSON::false, + } + }; + + # Validate main event. + $self->assert_str_equals('2016-01-01T00:00:00', $event->{start}); + $self->assert_equals(JSON::true, $event->{showWithoutTime}); + $self->assert_null($event->{timeZone}); + $self->assert_str_equals('P1D', $event->{duration}); + # Validate overrides. + $self->assert_deep_equals($wantOverrides, $event->{recurrenceOverrides}); + my $eventId = $event->{id}; + + # Add recurrenceOverrides with showWithoutTime=true + # and showWithoutTime=false. + $self->assert_not_null($eventId); + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + "recurrenceOverrides/2019-09-01T00:00:00" => { + start => "2019-09-02T00:00:00", + duration => 'P2D', + }, + "recurrenceOverrides/2019-10-01T00:00:00" => { + start => "2019-10-02T15:00:00", + timeZone => "Europe/London", + duration => "PT2H", + showWithoutTime => JSON::false, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { ids => [$eventId] }, 'R2'], + ]); + + $wantOverrides->{'2019-09-01T00:00:00'} = { + start => "2019-09-02T00:00:00", + duration => 'P2D', + }; + $wantOverrides->{'2019-10-01T00:00:00'} = { + start => "2019-10-02T15:00:00", + timeZone => "Europe/London", + duration => "PT2H", + showWithoutTime => JSON::false, + }; + $event = $res->[1][1]{list}[0]; + $self->assert_deep_equals($wantOverrides, $event->{recurrenceOverrides}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_reject_duplicate_uid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_reject_duplicate_uid new file mode 100644 index 0000000000..f51ee05930 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_reject_duplicate_uid @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_reject_duplicate_uid + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventA => { + calendarIds => { + 'Default' => JSON::true, + }, + uid => '123456789', + title => 'eventA', + start => '2021-04-06T12:30:00', + }, + } + }, 'R1'], + ]); + my $eventA = $res->[0][1]{created}{eventA}{id}; + $self->assert_not_null($eventA); + + $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendarB => { + name => 'calendarB', + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + create => { + eventB => { + calendarIds => { + '#calendarB' => JSON::true, + }, + uid => '123456789', + title => 'eventB', + start => '2021-04-06T12:30:00', + }, + } + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}{calendarB}); + $self->assert_str_equals('invalidProperties', + $res->[1][1]{notCreated}{eventB}{type}); + $self->assert_deep_equals(['uid'], + $res->[1][1]{notCreated}{eventB}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_relatedto b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_relatedto new file mode 100644 index 0000000000..4e630bd209 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_relatedto @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_relatedto + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "relatedTo" => { + "uid1" => { relation => { + 'first' => JSON::true, + }}, + "uid2" => { relation => { + 'parent' => JSON::true, + }}, + "uid3" => { relation => { + 'x-unknown1' => JSON::true, + 'x-unknown2' => JSON::true + }}, + "uid4" => { relation => {} }, + }, + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "participants" => undef, + "alerts"=> undef, + }; + + my $ret = $self->createandget_event($event); + $self->assert_normalized_event_equals($event, $ret); + $self->assert_num_equals(42, $event->{sequence}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_replace_standalone_with_destroy b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_replace_standalone_with_destroy new file mode 100644 index 0000000000..16811bc201 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_replace_standalone_with_destroy @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_replace_standalone_with_destroy + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Create standalone instance"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => '2021-01-02T01:01:01', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-01-01T01:01:01', + recurrenceIdTimeZone => 'Europe/London', + }, + }, + }, 'R1'], + ]); + my $instanceId = $res->[0][1]{created}{instance}{id}; + $self->assert_not_null($instanceId); + + xlog "Destroy standalone instance and create main event for same uid"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => '2021-01-01T01:01:01', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceRules => [{ + frequency => 'daily', + count => 5, + }], + }, + }, + destroy => [ $instanceId ], + }, 'R1'], + ['CalendarEvent/get', { + properties => [ 'recurrenceOverrides' ], + }, 'R2'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + $self->assert_deep_equals([ $instanceId ], $res->[0][1]{destroyed}); + $self->assert_null($res->[1][1]{list}[0]{recurrenceOverrides}); + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_reply_partstat_changed b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_reply_partstat_changed new file mode 100644 index 0000000000..fe5dda039b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_reply_partstat_changed @@ -0,0 +1,143 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_reply_partstat_changed + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Clean notifications"; + $self->{instance}->getnotify(); + + xlog "Create scheduled event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'event', + start => '2021-01-01T15:30:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceRules => [{ + frequency => 'daily', + count => 30, + }], + replyTo => { + imip => 'mailto:organizer@example.com', + }, + participants => { + cassandane => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + participationStatus => 'needs-action', + expectReply => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + + xlog "Assert that no iTIP notification is sent"; + my $data = $self->{instance}->getnotify(); + my ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($notif); + + xlog "Clean notifications"; + $self->{instance}->getnotify(); + + xlog "Update participationStatus"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'participants/cassandane/participationStatus' => 'accepted', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert that iTIP notification is sent"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + + xlog "Clean notifications"; + $self->{instance}->getnotify(); + + xlog "Update title"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'updated', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert that no iTIP notification is sent"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($notif); + + xlog "Clean notifications"; + $self->{instance}->getnotify(); + + xlog "Update participationStatus in recurrence override"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + recurrenceOverrides => { + '2021-01-02T15:30:00' => { + 'participants/cassandane/participationStatus' => 'declined', + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert that iTIP notification is sent"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + + xlog "Clean notifications"; + $self->{instance}->getnotify(); + + xlog "Update title in recurrence override"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides/2021-01-03T15:30:00' => { + title => 'updatedOverride', + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert that no iTIP notification is sent"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($notif); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_replyto b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_replyto new file mode 100644 index 0000000000..a4936ed139 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_replyto @@ -0,0 +1,130 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_replyto + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventReplyTo => { + calendarIds => { + 'Default' => JSON::true, + }, + start => '2022-01-28T09:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + replyTo => { + imip => 'mailto:myreplyto@example.com', + }, + participants => { + someone => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + }, + }, + }, + eventNoReplyTo => { + calendarIds => { + 'Default' => JSON::true, + }, + start => '2022-01-28T10:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + participants => { + someone => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + }, + }, + }, + eventReplyToNoParticipants => { + calendarIds => { + 'Default' => JSON::true, + }, + start => '2022-01-28T11:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + eventNoScheduling => { + calendarIds => { + 'Default' => JSON::true, + }, + start => '2022-01-28T12:00:00', + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#eventReplyTo'], + properties => ['replyTo'], + }, 'R2'], + ['CalendarEvent/get', { + ids => ['#eventNoReplyTo'], + properties => ['replyTo'], + }, 'R3'], + ]); + + xlog "Preserve client-set replyTo"; + $self->assert_deep_equals({ + imip => 'mailto:myreplyto@example.com', + }, $res->[1][1]{list}[0]{replyTo}); + + xlog "Use server-set replyTo if not set by client"; + $self->assert_deep_equals({ + imip => 'mailto:cassandane@example.com', + }, $res->[0][1]{created}{eventNoReplyTo}{replyTo}); + $self->assert_deep_equals({ + imip => 'mailto:cassandane@example.com', + }, $res->[2][1]{list}[0]{replyTo}); + + xlog "Reject event with replyTo but no participants"; + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notCreated}{eventReplyToNoParticipants}{type}); + $self->assert_deep_equals(['replyTo', 'participants'], + $res->[0][1]{notCreated}{eventReplyToNoParticipants}{properties}); + + xlog "Use server-set replyTo when participants added in update"; + my $eventId = $res->[0][1]{created}{eventNoScheduling}{id}; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + participants => { + someone => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_deep_equals({ + imip => 'mailto:cassandane@example.com', + }, $res->[0][1]{updated}{$eventId}{replyTo}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_rsvpsequence b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_rsvpsequence new file mode 100644 index 0000000000..c1acd4a164 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_rsvpsequence @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_rsvpsequence + :min_version_3_1 :max_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my ($id, $ical) = $self->icalfile('rsvpsequence'); + + my $event = $self->putandget_vevent($id, $ical); + $self->assert_not_null($event); + $self->assert_num_equals(1, $event->{sequence}); + + my $eventId = $event->{id}; + + # Update a partstat doesn't bump sequence. + my $res = $jmap->CallMethods([ + ['CalendarEvent/set',{ + update => { + $eventId => { + ('participants/me/participationStatus') => 'accepted', + } + } + }, "R1"], + ['CalendarEvent/get',{ + ids => [$eventId], + properties => ['sequence'], + }, "R2"], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_num_equals(1, $res->[1][1]{list}[0]->{sequence}); + + # Neither does setting a per-user property. + $res = $jmap->CallMethods([ + ['CalendarEvent/set',{ + update => { + $eventId => { + color => 'red', + 'alerts/alert1/trigger/offset' => '-PT10M', + }, + } + }, "R1"], + ['CalendarEvent/get',{ + ids => [$eventId], + properties => ['sequence'], + }, "R2"], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_num_equals(1, $res->[1][1]{list}[0]->{sequence}); + + # But setting a property shared by all users does! + $res = $jmap->CallMethods([ + ['CalendarEvent/set',{ + update => { + $eventId => { + title => 'foo', + }, + } + }, "R1"], + ['CalendarEvent/get',{ + ids => [$eventId], + properties => ['sequence'], + }, "R2"], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_num_not_equals(1, $res->[1][1]{list}[0]->{sequence}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_cancel b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_cancel new file mode 100644 index 0000000000..0d1cd8e5b6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_cancel @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedule_cancel + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", color => "coral", sortOrder => 1, isVisible => \1 + }}}, "R1"] + ]); + my $calid = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "send invitation as organizer"; + $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT15M", + "replyTo" => { + imip => "mailto:cassandane\@example.com", + }, + "participants" => { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + "att" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:bugs@example.com', + }, + }, + }, + } + }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($id); + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "cancel event as organizer"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $id => { + status => 'cancelled', + }, + }, + }, 'R1'], + ]); + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("bugs\@example.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:CANCEL"); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_destroy b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_destroy new file mode 100644 index 0000000000..023d9397ad --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_destroy @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedule_destroy + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", color => "coral", sortOrder => 1, isVisible => \1 + }}}, "R1"] + ]); + my $calid = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "send invitation as organizer"; + $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + $calid => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT15M", + "replyTo" => { + imip => "mailto:cassandane\@example.com", + }, + "participants" => { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + "att" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:bugs@example.com', + }, + }, + }, + } + }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($id); + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "cancel event as organizer"; + $res = $jmap->CallMethods([['CalendarEvent/set', { destroy => [$id]}, "R1"]]); + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("bugs\@example.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:CANCEL"); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_omit b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_omit new file mode 100644 index 0000000000..8f62fce247 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_omit @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedule_omit + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create event"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT1H", + "replyTo" => { imip => "mailto:bugs\@example.com" }, + "participants" => { + "org" => { + "name" => "Bugs Bunny", + "email" => "bugs\@example.com", + roles => { + 'owner' => JSON::true, + }, + }, + "att" => { + "name" => "Cassandane", + "email" => "cassandane\@example.com", + roles => { + 'attendee' => JSON::true, + }, + }, + }, + } + }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + # clean notification cache + $self->{instance}->getnotify(); + + # delete event as attendee without setting any partstat. + $res = $jmap->CallMethods([['CalendarEvent/set', { + destroy => [$id], + }, "R1"]]); + + # assert no notification is sent. + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($imip); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_reply b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_reply new file mode 100644 index 0000000000..ea8f890f84 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_reply @@ -0,0 +1,73 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedule_reply + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $participants = { + "org" => { + "name" => "Bugs Bunny", + sendTo => { + imip => 'mailto:bugs@example.com', + }, + roles => { + 'owner' => JSON::true, + }, + }, + "att" => { + "name" => "Cassandane", + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + roles => { + 'attendee' => JSON::true, + }, + }, + }; + + xlog $self, "create event"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT1H", + "replyTo" => { imip => "mailto:bugs\@example.com" }, + "participants" => $participants, + } + }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "send reply as attendee to organizer"; + $participants->{att}->{participationStatus} = "tentative"; + $res = $jmap->CallMethods([['CalendarEvent/set', { update => { + $id => { + replyTo => { imip => "mailto:bugs\@example.com" }, + participants => $participants, + } + }}, "R1"]]); + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("bugs\@example.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REPLY"); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_reply_custom_tz b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_reply_custom_tz new file mode 100644 index 0000000000..13ef733be3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_reply_custom_tz @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedule_reply_custom_tz + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $uid = "xxaaasdfasfhialskdjflaksjfdalskdfja"; + my $ical = <putandget_vevent($uid, $ical); + my $id = $event->{id}; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "send reply as attendee to organizer"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { + sendSchedulingMessages => JSON::true, + update => { + $id => { + 'participants/att/participationStatus' => "tentative", + } + } + }, "R1"]]); + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("bugs\@example.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REPLY"); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_request b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_request new file mode 100644 index 0000000000..716ab12cd1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_request @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedule_request + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $participants = { + "org" => { + "name" => "Cassandane", + roles => { + 'owner' => JSON::true, + }, + sendTo => { + imip => 'cassandane@example.com', + }, + }, + "att" => { + "name" => "Bugs Bunny", + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'bugs@example.com', + }, + }, + }; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "send invitation as organizer to attendee"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT1H", + "replyTo" => { imip => "mailto:cassandane\@example.com"}, + "participants" => $participants, + } + }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("bugs\@example.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REQUEST"); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_request_add_participant b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_request_add_participant new file mode 100644 index 0000000000..ff09d8ad64 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedule_request_add_participant @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedule_request_add_participant + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $participants = { + org => { + name => "Cassandane", + roles => { + attendee => JSON::true, + owner => JSON::true, + }, + sendTo => { + imip => 'cassandane@example.com', + }, + }, + att => { + name => "Bugs Bunny", + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'bugs@looneytunes.com', + }, + }, + }; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "send invitation as organizer to attendee"; + my $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + calendarIds => { + Default => JSON::true, + }, + "title" => "foo", + "description" => "foo's description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::false, + "start" => "2015-10-06T16:45:00", + "timeZone" => "Australia/Melbourne", + "duration" => "PT1H", + "replyTo" => { imip => "mailto:cassandane\@example.com"}, + "participants" => $participants, + } + }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals("bugs\@looneytunes.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REQUEST"); + + xlog $self, "add an attendee"; + $res = $jmap->CallMethods([['CalendarEvent/set', { update => { + $id => { + 'participants/att2' => { + '@type' => "Participant", + email => "rr@looneytunes.com", + expectReply => JSON::true, + kind => "individual", + participationStatus => "needs-action", + name => "Road Runner", + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:rr@looneytunes.com', + } + } + } + }}, "R1"]]); + + $data = $self->{instance}->getnotify(); + ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + $payload = decode_json($imip->{MESSAGE}); + $ical = $payload->{ical}; + + $self->assert_str_equals("rr\@looneytunes.com", $payload->{recipient}); + $self->assert($ical =~ "METHOD:REQUEST"); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedulestatus b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedulestatus new file mode 100644 index 0000000000..e751f41dfc --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedulestatus @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedulestatus + :min_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + title => 'test', + replyTo => { + imip => 'mailto:orga@local', + }, + participants => { + part1 => { + '@type' => 'Participant', + sendTo => { + imip => 'mailto:part1@local', + }, + roles => { + attendee => JSON::true, + }, + scheduleStatus => ['2.0', '2.4'], + }, + }, + start => '2021-01-01T01:00:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event1'], + }, 'R2'], + ]); + $self->assert_deep_equals(['2.0', '2.4'], + $res->[1][1]{list}[0]{participants}{part1}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedulingmessages b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedulingmessages new file mode 100644 index 0000000000..9423fb4ef1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_schedulingmessages @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_schedulingmessages + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + # clean notification cache + $self->{instance}->getnotify(); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uidlocal', + title => "event1", + start => "2020-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + 'owner' => JSON::true, + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, + attendee1 => { + roles => { + 'attendee' => JSON::true, + }, + sendTo => { + imip => 'mailto:attendee1@example.com', + }, + }, + }, + }, + }, + sendSchedulingMessages => JSON::false, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($imip); + + # clean notification cache + $self->{instance}->getnotify(); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => "updatedEvent1", + }, + }, + sendSchedulingMessages => JSON::true, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + $data = $self->{instance}->getnotify(); + ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + my $payload = decode_json($imip->{MESSAGE}); + my $ical = $payload->{ical}; + + $self->assert_str_equals('attendee1@example.com', $payload->{recipient}); + $self->assert($ical =~ "METHOD:REQUEST"); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_sentby b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_sentby new file mode 100644 index 0000000000..5b77c60204 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_sentby @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_sentby + :needs_component_httpd :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uidlocal', + title => "event1", + start => "2020-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + sentBy => 'sender@example.net', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event1', '#event2'], + properties => ['sentBy'], + }, 'R3'], + ]); + $self->assert_str_equals('sender@example.net', $res->[1][1]{list}[0]{sentBy}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_shared b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_shared new file mode 100644 index 0000000000..446ff94c11 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_shared @@ -0,0 +1,228 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + my ($maj, $min) = Cassandane::Instance->get_version(); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + xlog $self, "create calendar"; + my $CalendarId1 = $mantalk->NewCalendar({name => 'Manifold Calendar'}); + $self->assert_not_null($CalendarId1); + + xlog $self, "share $CalendarId1 read-only to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId1", "cassandane" => 'lr') or die; + + my $event = { + calendarIds => { + $CalendarId1 => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "participants" => undef, + "alerts" => { + 'a465d37a-0041-4119-a1e0-0177aabcdf4a' => { + '@type' => 'Alert', + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + action => "email" + } + } + }; + + my $updatedEvent = { + calendarIds => { + $CalendarId1 => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo2", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "participants" => undef, + "alerts" => { + 'a465d37a-0041-4119-a1e0-0177aabcdf4a' => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + action => "email" + } + } + }; + + xlog $self, "create event (should fail)"; + my $res = $jmap->CallMethods([['CalendarEvent/set',{ + accountId => 'manifold', + create => {"1" => $event}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{notCreated}{1}); + + xlog $self, "share $CalendarId1 read-writable to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId1", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "create event"; + $res = $jmap->CallMethods([['CalendarEvent/set',{ + accountId => 'manifold', + create => {"1" => $event}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get calendar event $id"; + $res = $jmap->CallMethods([['CalendarEvent/get', { + accountId => 'manifold', + ids => [$id]}, + "R1"]]); + my $ret = $res->[0][1]{list}[0]; + $self->assert_normalized_event_equals($event, $ret); + + xlog $self, "update event"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + accountId => 'manifold', + update => { + $id => { + calendarIds => { + $CalendarId1 => JSON::true, + }, + "title" => "foo2", + }, + }}, "R1"]]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "get calendar event $id"; + $res = $jmap->CallMethods([['CalendarEvent/get', { + accountId => 'manifold', + ids => [$id]}, + "R1"]]); + $ret = $res->[0][1]{list}[0]; + $self->assert_normalized_event_equals($updatedEvent, $ret); + + xlog $self, "share $CalendarId1 read-only to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId1", "cassandane" => 'lr') or die; + + xlog $self, "update event (should fail)"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + accountId => 'manifold', + update => { + $id => { + calendarIds => { + $CalendarId1 => JSON::true, + }, + "title" => "1(updated)", + }, + }}, "R1"]]); + $self->assert(exists $res->[0][1]{notUpdated}{$id}); + + xlog $self, "share calendar home read-writable to user"; + $admintalk->setacl("user.manifold.#calendars", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "create another calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + accountId => 'manifold', + create => { "2" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1 + }}}, "R1"] + ]); + my $CalendarId2 = $res->[0][1]{created}{"2"}{id}; + $self->assert_not_null($CalendarId2); + + xlog $self, "share $CalendarId1 read-writable to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId1", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "share $CalendarId2 read-only to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId2", "cassandane" => 'lr') or die; + + xlog $self, "move event (should fail)"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + accountId => 'manifold', + update => { + $id => { + calendarIds => { + $CalendarId2 => JSON::true, + }, + "title" => "1(updated)", + }, + }}, "R1"]]); + $self->assert(exists $res->[0][1]{notUpdated}{$id}); + + xlog $self, "share $CalendarId2 read-writable to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId2", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "move event"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + accountId => 'manifold', + update => { + $id => { + calendarIds => { + $CalendarId2 => JSON::true, + }, + "title" => "1(updated)", + }, + }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "share $CalendarId2 read-only to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId2", "cassandane" => 'lr') or die; + + xlog $self, "destroy event (should fail)"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + accountId => 'manifold', + destroy => [ $id ], + }, "R1"]]); + $self->assert(exists $res->[0][1]{notDestroyed}{$id}); + + xlog $self, "share $CalendarId2 read-writable to user"; + $admintalk->setacl("user.manifold.#calendars.$CalendarId2", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "destroy event"; + $res = $jmap->CallMethods([['CalendarEvent/set', { + accountId => 'manifold', + destroy => [ $id ], + }, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_simple b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_simple new file mode 100644 index 0000000000..09c9d2a3b9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_simple @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_simple + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "priority" => 9, + "locale" => "en", + "color" => "turquoise", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "privacy" => "secret", + "participants" => undef, + "alerts"=> undef, + }; + + my $ret = $self->createandget_event($event); + $self->assert_normalized_event_equals($event, $ret); + $self->assert_num_equals(42, $event->{sequence}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instance_floatingtz b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instance_floatingtz new file mode 100644 index 0000000000..4c1cdb319c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instance_floatingtz @@ -0,0 +1,66 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_standalone_instance_floatingtz + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//Fastmail/2020.5/EN +BEGIN:VEVENT +CATEGORIES:CONFERENCE +DESCRIPTION:Be there or be square +DTSTAMP:19960704T120000Z +DTSTART:19960919T143000 +DURATION:PT1H +RECURRENCE-ID:19960919T143000 +SEQUENCE:0 +SUMMARY:Partyx +TRANSP:OPAQUE +UID:889i-uid1@example.com +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => [ + 'recurrenceId', + 'recurrenceIdTimeZone', + ], + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{list}[0]{recurrenceId}); + $self->assert_null($res->[0][1]{list}[0]{recurrenceIdTimeZone}); + my $eventId = $res->[0][1]{list}[0]{id}; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'xxx', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => [ + 'recurrenceId', + 'recurrenceIdTimeZone'], + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{list}[0]{recurrenceId}); + $self->assert_null($res->[0][1]{list}[0]{recurrenceIdTimeZone}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_create b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_create new file mode 100644 index 0000000000..c7bc5f1e9e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_create @@ -0,0 +1,162 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_standalone_instances_create + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Get event state"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => [], + }, 'R2'], + ]); + my $state = $res->[0][1]{state}; + + xlog "Create standalone instance"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-01-01T01:01:01', + recurrenceIdTimeZone => 'Europe/London', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#instance1'], + properties => ['start', 'timeZone', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R2'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R3'], + ]); + my $instance1Id = $res->[0][1]{created}{instance1}{id}; + $self->assert_not_null($instance1Id); + my $xhref1 = $res->[0][1]{created}{instance1}{'x-href'}; + $self->assert_not_null($xhref1); + $self->assert_str_equals('2021-01-01T11:11:11', + $res->[1][1]{list}[0]{start}); + $self->assert_str_equals('Europe/Berlin', + $res->[1][1]{list}[0]{timeZone}); + $self->assert_str_equals('2021-01-01T01:01:01', + $res->[1][1]{list}[0]{recurrenceId}); + $self->assert_str_equals('Europe/London', + $res->[1][1]{list}[0]{recurrenceIdTimeZone}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[1][1]{state}); + $self->assert_str_not_equals($state, $res->[2][1]{newState}); + $self->assert_deep_equals([$instance1Id], $res->[2][1]{created}); + $self->assert_deep_equals([], $res->[2][1]{updated}); + $self->assert_deep_equals([], $res->[2][1]{destroyed}); + $state = $res->[2][1]{newState}; + + xlog "Can't create a new standalone instance with same recurrence id"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance2 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance2', + start => '2021-02-02T22:22:22', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-01-01T01:01:01', + recurrenceIdTimeZone => 'Europe/London', + }, + }, + }, 'R1'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R2'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notCreated}{instance2}{type}); + $self->assert_deep_equals(['uid', 'recurrenceId'], + $res->[0][1]{notCreated}{instance2}{properties}); + + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_str_equals($state, $res->[1][1]{newState}); + $self->assert_deep_equals([], $res->[1][1]{created}); + $self->assert_deep_equals([], $res->[1][1]{updated}); + $self->assert_deep_equals([], $res->[1][1]{destroyed}); + + xlog "Create standalone instance with same uid but different recurrence id"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance2 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance2', + start => '2021-02-02T02:02:02', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-02-02T02:02:02', + recurrenceIdTimeZone => 'Europe/London', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#instance2'], + properties => ['start', 'timeZone', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R2'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R3'], + ]); + my $instance2Id = $res->[0][1]{created}{instance2}{id}; + $self->assert_not_null($instance2Id); + my $xhref2 = $res->[0][1]{created}{instance2}{'x-href'}; + $self->assert_not_null($xhref2); + $self->assert_str_equals('2021-02-02T02:02:02', + $res->[1][1]{list}[0]{start}); + $self->assert_str_equals('Europe/Berlin', + $res->[1][1]{list}[0]{timeZone}); + $self->assert_str_equals('2021-02-02T02:02:02', + $res->[1][1]{list}[0]{recurrenceId}); + $self->assert_str_equals('Europe/London', + $res->[1][1]{list}[0]{recurrenceIdTimeZone}); + + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[1][1]{state}); + $self->assert_str_not_equals($state, $res->[2][1]{newState}); + $self->assert_deep_equals([$instance2Id], $res->[2][1]{created}); + $self->assert_deep_equals([], $res->[2][1]{updated}); + $self->assert_deep_equals([], $res->[2][1]{destroyed}); + $state = $res->[2][1]{newState}; + + xlog "Assert both events exist"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$instance1Id, $instance2Id], + properties => ['title', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{notFound}}); + + xlog "Assert CalDAV resource contains both instances"; + $res = $caldav->Request('GET', $xhref1); + $self->assert($res->{content} =~ m/SUMMARY:instance1/); + $self->assert($res->{content} =~ m/SUMMARY:instance2/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_destroy b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_destroy new file mode 100644 index 0000000000..65f0c56670 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_destroy @@ -0,0 +1,127 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_standalone_instances_destroy + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create standalone instances"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-01-01T01:01:01', + recurrenceIdTimeZone => 'Europe/London', + }, + instance2 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance2', + start => '2021-02-02T02:02:02', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-02-02T02:02:02', + recurrenceIdTimeZone => 'Europe/London', + }, + }, + }, 'R1'], + ]); + my $instance1Id = $res->[0][1]{created}{instance1}{id}; + $self->assert_not_null($instance1Id); + my $instance2Id = $res->[0][1]{created}{instance2}{id}; + $self->assert_not_null($instance2Id); + my $xhref1 = $res->[0][1]{created}{instance1}{'x-href'}; + $self->assert_not_null($xhref1); + my $xhref2 = $res->[0][1]{created}{instance2}{'x-href'}; + $self->assert_not_null($xhref2); + $self->assert_str_equals($xhref1, $xhref2); + my $state = $res->[0][1]{newState}; + + xlog "Destroy first standalone instance"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [ $instance1Id ], + }, 'R1'], + ['CalendarEvent/get', { + ids => [$instance1Id], + properties => ['title', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R2'], + ['CalendarEvent/get', { + ids => [$instance2Id], + properties => ['title', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R3'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R4'], + ]); + $self->assert_deep_equals([$instance1Id], $res->[0][1]{destroyed}); + $self->assert_deep_equals([$instance1Id], $res->[1][1]{notFound}); + $self->assert_str_equals('instance2', $res->[2][1]{list}[0]{title}); + + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[1][1]{state}); + $self->assert_str_not_equals($state, $res->[2][1]{state}); + $self->assert_str_not_equals($state, $res->[3][1]{newState}); + $self->assert_deep_equals([], $res->[3][1]{created}); + $self->assert_deep_equals([], $res->[3][1]{updated}); + $self->assert_deep_equals([$instance1Id], $res->[3][1]{destroyed}); + $state = $res->[3][1]{newState}; + + xlog "Assert CalDAV resource still exists"; + $res = $caldav->Request('GET', $xhref1); + $self->assert(not $res->{content} =~ m/SUMMARY:instance1/); + $self->assert($res->{content} =~ m/SUMMARY:instance2/); + + xlog "Destroy second standalone instance"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [ $instance2Id ], + }, 'R1'], + ['CalendarEvent/get', { + ids => [$instance2Id], + properties => ['title', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R2'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R2'], + ]); + $self->assert_deep_equals([$instance2Id], $res->[0][1]{destroyed}); + $self->assert_deep_equals([$instance2Id], $res->[1][1]{notFound}); + + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[1][1]{state}); + $self->assert_str_not_equals($state, $res->[2][1]{newState}); + $self->assert_deep_equals([], $res->[2][1]{created}); + $self->assert_deep_equals([], $res->[2][1]{updated}); + $self->assert_deep_equals([$instance2Id], $res->[2][1]{destroyed}); + $state = $res->[3][1]{newState}; + + xlog "Assert CalDAV resource is gone"; + # Can't use CalDAV talk for GET on non-existent URLs + my $xml = < + + + +EOF + $res = $caldav->Request('PROPFIND', 'Default', $xml, + 'Content-Type' => 'application/xml', + 'Depth' => '1' + ); + $self->assert_does_not_match(qr{event1uid}, $res); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_move b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_move new file mode 100644 index 0000000000..5514d3ddac --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_move @@ -0,0 +1,100 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_standalone_instances_move + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create standalone instances"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-01-01T01:01:01', + recurrenceIdTimeZone => 'Europe/London', + }, + instance2 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance2', + start => '2021-02-02T02:02:02', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-02-02T02:02:02', + recurrenceIdTimeZone => 'Europe/London', + }, + }, + }, 'R1'], + ['Calendar/set', { + create => { + calendarA => { + name => 'A', + }, + }, + }, 'R2'], + ]); + my $instance1Id = $res->[0][1]{created}{instance1}{id}; + $self->assert_not_null($instance1Id); + my $instance2Id = $res->[0][1]{created}{instance2}{id}; + $self->assert_not_null($instance2Id); + my $state = $res->[0][1]{newState}; + my $calendarAId = $res->[1][1]{created}{calendarA}{id}; + $self->assert_not_null($calendarAId); + + xlog "Move standalone instance to other calendar"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $instance1Id => { + calendarIds => { + $calendarAId => JSON::true, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$instance1Id], + properties => ['calendarIds', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R2'], + ['CalendarEvent/get', { + ids => [$instance2Id], + properties => ['calendarIds', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R3'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R4'], + ]); + $self->assert(exists $res->[0][1]{updated}{$instance1Id}); + $self->assert_deep_equals({$calendarAId => JSON::true }, + $res->[1][1]{list}[0]{calendarIds}); + + xlog "Moving one standalone instance also moves any other instances"; + $self->assert_deep_equals({$calendarAId => JSON::true }, + $res->[2][1]{list}[0]{calendarIds}); + + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[1][1]{state}); + $self->assert_str_not_equals($state, $res->[2][1]{state}); + $self->assert_str_not_equals($state, $res->[3][1]{newState}); + + $self->assert_deep_equals([], $res->[3][1]{created}); + my @wantUpdated = sort ($instance1Id, $instance2Id); + my @haveUpdated = sort @{$res->[3][1]{updated}}; + $self->assert_deep_equals(\@wantUpdated, \@haveUpdated); + $self->assert_deep_equals([], $res->[3][1]{destroyed}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_to_main b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_to_main new file mode 100644 index 0000000000..60488df797 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_to_main @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_standalone_instances_to_main + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create standalone instance"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-01-01T01:01:01', + recurrenceIdTimeZone => 'Europe/London', + }, + }, + }, 'R1'], + ]); + my $instance1Id = $res->[0][1]{created}{instance1}{id}; + $self->assert_not_null($instance1Id); + my $state = $res->[0][1]{newState}; + + xlog "Can't convert a standalone instance to a main event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $instance1Id => { + recurrenceId => undef, + }, + }, + }, 'R1'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R2'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{$instance1Id}{type}); + $self->assert_deep_equals([ + # XXX invalidProperties doesn't deduplicate, + # but we'll only change this when we merged + # this feature branch + 'recurrenceId', 'recurrenceId', 'recurrenceIdTimeZone' + ], $res->[0][1]{notUpdated}{$instance1Id}{properties}); + + $self->assert_str_equals($state, $res->[1][1]{newState}); + $self->assert_deep_equals([], $res->[1][1]{created}); + $self->assert_deep_equals([], $res->[1][1]{updated}); + $self->assert_deep_equals([], $res->[1][1]{destroyed}); + + xlog "Create main event with the same uid"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'mainevent1', + start => '2020-12-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceRules => [{ + '@type' => 'RecurrenceRule', + frequency => 'monthly', + count => 3, + }], + }, + }, + }, 'R1'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R2'], + ]); + my $event1Id = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($event1Id); + + $self->assert_str_not_equals($state, $res->[1][1]{newState}); + $self->assert_deep_equals([$event1Id], $res->[1][1]{created}); + $self->assert_deep_equals([], $res->[1][1]{updated}); + $self->assert_deep_equals([$instance1Id], $res->[1][1]{destroyed}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_update b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_update new file mode 100644 index 0000000000..56471e97fb --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_instances_update @@ -0,0 +1,115 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_standalone_instances_update + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create standalone instances"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => '2021-01-01T11:11:11', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-01-01T01:01:01', + recurrenceIdTimeZone => 'Europe/London', + }, + instance2 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance2', + start => '2021-02-02T02:02:02', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => '2021-02-02T02:02:02', + recurrenceIdTimeZone => 'Europe/London', + }, + }, + }, 'R1'], + ]); + my $instance1Id = $res->[0][1]{created}{instance1}{id}; + $self->assert_not_null($instance1Id); + my $instance2Id = $res->[0][1]{created}{instance2}{id}; + $self->assert_not_null($instance2Id); + my $xhref1 = $res->[0][1]{created}{instance1}{'x-href'}; + $self->assert_not_null($xhref1); + my $xhref2 = $res->[0][1]{created}{instance2}{'x-href'}; + $self->assert_not_null($xhref2); + $self->assert_str_equals($xhref1, $xhref2); + my $state = $res->[0][1]{newState}; + + xlog "Update standalone instance"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $instance1Id => { + title => 'instance1Updated', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$instance1Id], + properties => ['title', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R2'], + ['CalendarEvent/get', { + ids => [$instance2Id], + properties => ['title', 'recurrenceId', 'recurrenceIdTimeZone'], + }, 'R3'], + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R4'], + ]); + $self->assert(exists $res->[0][1]{updated}{$instance1Id}); + $self->assert_str_equals('instance1Updated', $res->[1][1]{list}[0]{title}); + $self->assert_str_equals('instance2', $res->[2][1]{list}[0]{title}); + + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[1][1]{state}); + $self->assert_str_not_equals($state, $res->[2][1]{state}); + $self->assert_str_not_equals($state, $res->[3][1]{newState}); + $self->assert_deep_equals([], $res->[3][1]{created}); + $self->assert_deep_equals([$instance1Id], $res->[3][1]{updated}); + $self->assert_deep_equals([], $res->[3][1]{destroyed}); + $state = $res->[3][1]{newState}; + + xlog "Assert CalDAV resource contains both instances"; + $res = $caldav->Request('GET', $xhref1); + $self->assert($res->{content} =~ m/SUMMARY:instance1Updated/); + $self->assert($res->{content} =~ m/SUMMARY:instance2/); + + xlog "Can't change the recurrenceId or recurrenceIdTimeZone property"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $instance1Id => { + recurrenceId => '2021-03-03T03:03:03', + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + update => { + $instance1Id => { + recurrenceIdTimeZone => 'America/New_York', + }, + }, + }, 'R2'], + ]); + $self->assert_deep_equals(['recurrenceId'], + $res->[0][1]{notUpdated}{$instance1Id}{properties}); + $self->assert_deep_equals(['recurrenceIdTimeZone'], + $res->[1][1]{notUpdated}{$instance1Id}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_itip b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_itip new file mode 100644 index 0000000000..bebd8d8045 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_standalone_itip @@ -0,0 +1,239 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_standalone_itip + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $uid = 'event1uid', + my @details = ( + { start => '2021-01-01T01:01:01', recurid => '20210101T010101' }, + { start => '2022-02-02T02:02:02', recurid => '20220202T020202' }, + { start => '2022-03-03T03:03:03', recurid => '20220303T030303' }, + ); + + xlog "Clear notification cache"; + $self->{instance}->getnotify(); + + xlog "Create scheduled standalone instance"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance1 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => $uid, + title => 'instance1', + start => $details[0]->{start}, + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => $details[0]->{start}, + recurrenceIdTimeZone => 'Europe/London', + replyTo => { + imip => 'mailto:organizer@example.com', + }, + participants => { + cassandane => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + participationStatus => 'tentative', + expectReply => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + my $instance1Id = $res->[0][1]{created}{instance1}{id}; + $self->assert_not_null($instance1Id); + + xlog "Assert that iTIP notification is sent"; + my $data = $self->{instance}->getnotify(); + my ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + my $notif_payload = decode_json($notif->{MESSAGE}); + my $itip = $notif_payload->{ical}; + my $ical = Data::ICal->new(data => $itip); + + my @vevents = grep { $_->ical_entry_type() eq 'VEVENT' } @{$ical->entries()}; + $self->assert_num_equals(1, scalar @vevents); + + my $recurid = $vevents[0]->property('RECURRENCE-ID'); + $self->assert_num_equals(1, scalar @{$recurid}); + $self->assert_str_equals($details[0]->{recurid}, $recurid->[0]->value()); + + my $attendees = $vevents[0]->property('ATTENDEE'); + $self->assert_num_equals(1, scalar @{$attendees}); + $self->assert_str_equals('mailto:cassandane@example.com', + $attendees->[0]->value()); + $self->assert_str_equals('TENTATIVE', + $attendees->[0]->parameters()->{'PARTSTAT'}); + + my $expect_id = encode_eventid($uid, $details[0]->{recurid}); + $self->assert_not_null($notif_payload->{id}); + $self->assert_str_equals($expect_id, $notif_payload->{id}); + $self->assert_str_equals('REPLY', $notif_payload->{method}); + + xlog "Clear notification cache"; + $self->{instance}->getnotify(); + + xlog "Create another standalone instance for that UID"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance2 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance1', + start => $details[1]->{start}, + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => $details[1]->{start}, + recurrenceIdTimeZone => 'Europe/London', + replyTo => { + imip => 'mailto:organizer@example.com', + }, + participants => { + cassandane => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + participationStatus => 'accepted', + expectReply => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + my $instance2Id = $res->[0][1]{created}{instance2}{id}; + $self->assert_not_null($instance2Id); + + xlog "Assert iTIP notification just gets sent for new instance"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + $notif_payload = decode_json($notif->{MESSAGE}); + $itip = $notif_payload->{ical}; + $ical = Data::ICal->new(data => $itip); + + @vevents = grep { $_->ical_entry_type() eq 'VEVENT' } @{$ical->entries()}; + $self->assert_num_equals(1, scalar @vevents); + + $recurid = $vevents[0]->property('RECURRENCE-ID'); + $self->assert_num_equals(1, scalar @{$recurid}); + $self->assert_str_equals($details[1]->{recurid}, $recurid->[0]->value()); + + $attendees = $vevents[0]->property('ATTENDEE'); + $self->assert_num_equals(1, scalar @{$attendees}); + $self->assert_str_equals('mailto:cassandane@example.com', + $attendees->[0]->value()); + $self->assert_str_equals('ACCEPTED', + $attendees->[0]->parameters()->{'PARTSTAT'}); + + $expect_id = encode_eventid($uid, $details[1]->{recurid}); + $self->assert_not_null($notif_payload->{id}); + $self->assert_str_equals($expect_id, $notif_payload->{id}); + $self->assert_str_equals('REPLY', $notif_payload->{method}); + + xlog "Clear notification cache"; + $self->{instance}->getnotify(); + + xlog "Update partstat in a standalone instance"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $instance2Id => { + 'participants/cassandane/participationStatus' => 'declined', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$instance2Id}); + + xlog "Assert iTIP notification only is sent for updated instance"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + $notif_payload = decode_json($notif->{MESSAGE}); + $itip = $notif_payload->{ical}; + $ical = Data::ICal->new(data => $itip); + + @vevents = grep { $_->ical_entry_type() eq 'VEVENT' } @{$ical->entries()}; + $self->assert_num_equals(1, scalar @vevents); + + $recurid = $vevents[0]->property('RECURRENCE-ID'); + $self->assert_num_equals(1, scalar @{$recurid}); + $self->assert_str_equals($details[1]->{recurid}, $recurid->[0]->value()); + + $attendees = $vevents[0]->property('ATTENDEE'); + $self->assert_num_equals(1, scalar @{$attendees}); + $self->assert_str_equals('mailto:cassandane@example.com', + $attendees->[0]->value()); + $self->assert_str_equals('DECLINED', + $attendees->[0]->parameters()->{'PARTSTAT'}); + + $expect_id = encode_eventid($uid, $details[1]->{recurid}); + $self->assert_not_null($notif_payload->{id}); + $self->assert_str_equals($expect_id, $notif_payload->{id}); + $self->assert_str_equals('REPLY', $notif_payload->{method}); + + xlog "Clear notification cache"; + $self->{instance}->getnotify(); + + xlog "Create another standalone instance where PARTSTAT=NEEDS-ACTION"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + instance2 => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => 'event1uid', + title => 'instance3', + start => $details[2]->{start}, + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceId => $details[2]->{start}, + recurrenceIdTimeZone => 'Europe/London', + replyTo => { + imip => 'mailto:organizer@example.com', + }, + participants => { + cassandane => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + participationStatus => 'needs-action', + expectReply => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + my $instance3Id = $res->[0][1]{created}{instance2}{id}; + $self->assert_not_null($instance2Id); + + xlog "Assert no iTIP notification is sent"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_null($notif); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_subseconds b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_subseconds new file mode 100644 index 0000000000..05c22a072c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_subseconds @@ -0,0 +1,97 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_subseconds + :min_version_3_1 :max_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + + # subseconds were deprecated in 3.5 but included as experimental in 3.4 + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + uid => "58ADE31-custom-UID", + title => "subseconds", + start => "2011-12-04T04:05:06.78", + created => "2019-06-29T11:58:12.412Z", + updated => "2019-06-29T11:58:12.412Z", + duration=> "PT5M3.45S", + timeZone=> "Europe/Vienna", + recurrenceRules => [{ + '@type' => 'RecurrenceRule', + frequency => "daily", + until => '2011-12-10T04:05:06.78', + }], + "replyTo" => { + "imip" => 'mailto:foo@local', + }, + "participants" => { + 'foo' => { + '@type' => 'Participant', + name => 'Foo', + email => 'foo@local', + roles => { + owner => JSON::true, + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:foo@local', + }, + scheduleSequence => 1, + scheduleUpdated => '2018-07-06T05:03:02.123Z', + }, + }, + alerts => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M0.7S", + }, + acknowledged => "2015-11-07T08:57:00.523Z", + action => "display", + }, + }, + recurrenceOverrides => { + '2011-12-05T04:05:06.78' => { + title => "overridden event" + }, + '2011-12-06T04:05:06.78' => { + excluded => JSON::true + }, + '2011-12-07T11:00:00.99' => {}, + '2011-12-08T04:05:06.78' => { + title => "overridden event with DTEND", + duration => 'PT1H2.345S', + locations => { + endLocation => { + '@type' => 'Location', + name => 'end location in another timezone', + relativeTo => 'end', + timeZone => 'Europe/London', + } + }, + }, + }, + }; + + my $ret = $self->createandget_event($event); + + # Known regresion: recurrenceRule.until + $self->assert_str_equals('2011-12-10T04:05:06', + $ret->{recurrenceRules}[0]{until}); + $ret->{recurrenceRules}[0]{until} = '2011-12-10T04:05:06.78'; + + # Known regression: participant.scheduleUpdated + $self->assert_str_equals('2018-07-06T05:03:02Z', + $ret->{participants}{foo}{scheduleUpdated}); + $ret->{participants}{foo}{scheduleUpdated} = '2018-07-06T05:03:02.123Z'; + + $self->assert_str_equals($event->{created}, $ret->{created}); + $self->assert_str_equals($event->{updated}, $ret->{updated}); + $self->assert_normalized_event_equals($event, $ret); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_too_large b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_too_large new file mode 100644 index 0000000000..1604ad2664 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_too_large @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_too_large + :min_version_3_5 :needs_component_jmap :iCalendarMaxSize10k +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { + "1" => { + name => "A", color => "coral", sortOrder => 1, isVisible => JSON::true + } + }}, "R1"]]); + my $calid = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "create event in calendar"; + $res = $jmap->CallMethods([['CalendarEvent/set', { create => { + "1" => { + "calendarIds" => { + $calid => JSON::true, + }, + "title" => "foo", + "description" => ('x' x 100000), + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2015-10-06T00:00:00", + "duration" => "P1D", + "timeZone" => undef, + } + }}, "R1"]]); + $self->assert_str_equals('tooLarge', $res->[0][1]{notCreated}{1}{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_type b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_type new file mode 100644 index 0000000000..d92c91150a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_type @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_type + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "privacy" => "secret", + "participants" => undef, + "alerts"=> undef, + }; + + # Setting no type is OK, we'll just assume jsevent + my $res = $jmap->CallMethods([['CalendarEvent/set', { + create => { + "1" => $event, + } + }, "R1"]]); + $self->assert_not_null($res->[0][1]{created}{"1"}); + + # Setting any type other jsevent type is NOT OK + $event->{q{@type}} = 'jstask'; + $event->{uid} = '58ADE31-custom-UID-2'; + $res = $jmap->CallMethods([['CalendarEvent/set', { + create => { + "1" => $event, + } + }, "R1"]]); + $self->assert_not_null($res->[0][1]{notCreated}{"1"}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_uid b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_uid new file mode 100644 index 0000000000..f930345bf1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_uid @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_uid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Etc/UTC", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "privacy" => "secret", + "participants" => undef, + "alerts"=> undef, + }; + + # An empty UID generates a random uid. + my $ret = $self->createandget_event($event); + my($filename, $dirs, $suffix) = fileparse($ret->{"x-href"}, ".ics"); + $self->assert_not_null($ret->{id}); + $self->assert_str_equals(encode_eventid($ret->{uid}), $ret->{id}); + $self->assert_str_equals(encode_eventid($filename), $ret->{id}); + + # A sane UID maps to both the JMAP id and the DAV resource. + $event->{uid} = "458912982-some_UID"; + delete $event->{id}; + $ret = $self->createandget_event($event); + ($filename, $dirs, $suffix) = fileparse($ret->{"x-href"}, ".ics"); + $self->assert_str_equals($event->{uid}, $filename); + $self->assert_str_equals(encode_eventid($event->{uid}), $ret->{id}); + + # A non-pathsafe UID maps to the JMAP id but not the DAV resource. + $event->{uid} = "a/bogus/path#uid"; + delete $event->{id}; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => $event, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + $jmap->{CreatedIds} = undef; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$eventId], + }, 'R1'], + ]); + $ret = $res->[0][1]{list}[0]; + ($filename, $dirs, $suffix) = fileparse($ret->{"x-href"}, ".ics"); + $self->assert_not_null($filename); + $self->assert_str_not_equals($event->{uid}, $filename); + $self->assert_str_equals("EB-", substr($ret->{id}, 0, 3)); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated new file mode 100644 index 0000000000..59989f0310 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_updated + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $t = DateTime->now(); + $t->set_time_zone('Etc/UTC'); + my $start = $t->strftime('%Y-%m-%dT%H:%M:%S'); + $t->add(DateTime::Duration->new(days => -2)); + my $past = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + start => $start, + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + created => $past, + updated => $past, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ '#event' ], + properties => ['created', 'updated', 'title'], + }, 'R2'], + ]); + + $self->assert_str_equals($past, $res->[1][1]{list}[0]{created}); + my $updated = $res->[1][1]{list}[0]{updated}; + $self->assert($past lt $updated); + $self->assert_str_equals($updated, $res->[0][1]{created}{event}{updated}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated_scheduled_not_source b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated_scheduled_not_source new file mode 100644 index 0000000000..d4121f8ee1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated_scheduled_not_source @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_updated_scheduled_not_source + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $t = DateTime->now(); + $t->set_time_zone('Etc/UTC'); + my $start = $t->strftime('%Y-%m-%dT%H:%M:%S'); + my $now= $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + $t->add(DateTime::Duration->new(days => -2)); + my $past = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + + + xlog "Create event where cassandane is invitee"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + start => $start, + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + created => $past, + updated => $past, + replyTo => { + imip => 'mailto:someone@example.com', + }, + participants => { + cassandane => { + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + expectReply => JSON::true, + participationStatus => 'accepted', + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ '#event' ], + properties => ['updated'], + }, 'R2'], + ]); + $self->assert_str_equals($past, $res->[1][1]{list}[0]{updated}); + my $eventId = $res->[1][1]{list}[0]{id}; + + xlog "Change partstat of cassandane"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'participants/cassandane/participationStatus' => 'tentative', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ '#event' ], + properties => ['updated'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_str_equals($past, $res->[1][1]{list}[0]{updated}); + + xlog "Client updates updated property themselves"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + updated => $now, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ '#event' ], + properties => ['updated'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_str_equals($now, $res->[1][1]{list}[0]{updated}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated_scheduled_source b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated_scheduled_source new file mode 100644 index 0000000000..c54ea16429 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_updated_scheduled_source @@ -0,0 +1,97 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_updated_scheduled_source + :needs_component_httpd :needs_component_jmap :min_version_3_7 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $t = DateTime->now(); + $t->set_time_zone('Etc/UTC'); + my $start = $t->strftime('%Y-%m-%dT%H:%M:%S'); + my $now= $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + $t->add(DateTime::Duration->new(days => -2)); + my $past = $t->strftime('%Y-%m-%dT%H:%M:%SZ'); + + xlog "Create event where cassandane is owner"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + start => $start, + timeZone => 'Etc/UTC', + duration => 'PT1H', + title => 'event', + created => $past, + updated => $past, + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + someone => { + sendTo => { + imip => 'mailto:someone@example.com', + }, + expectReply => JSON::true, + participationStatus => 'needs-action', + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ '#event' ], + properties => ['updated'], + }, 'R2'], + ]); + my $updated = $res->[1][1]{list}[0]{updated}; + $self->assert($past lt $updated); + my $eventId = $res->[1][1]{list}[0]{id}; + + sleep(1); + + xlog "Invite someone else"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'participants/someoneelse' => { + sendTo => { + imip => 'mailto:someoneelse@example.com', + }, + expectReply => JSON::true, + participationStatus => 'needs-action', + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ '#event' ], + properties => ['updated'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert($updated lt $res->[1][1]{list}[0]{updated}); + $updated = $res->[1][1]{list}[0]{updated}; + + xlog "Client updates updated property themselves"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + updated => $past, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [ '#event' ], + properties => ['updated'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_date_matches($updated, $res->[1][1]{list}[0]{updated}, 2); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_utcstart b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_utcstart new file mode 100644 index 0000000000..20f49671ab --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_utcstart @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_utcstart + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Assert event creation. + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + utcStart => "2019-12-10T23:30:00Z", + duration => "PT1H", + timeZone => "Australia/Melbourne", + }, + 2 => { + uid => 'eventuid2local', + calendarIds => { + Default => JSON::true, + }, + title => "event2", + utcStart => "2019-12-10T23:30:00Z", + duration => "PT1H", + timeZone => undef, # floating + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#1'], + properties => ['start', 'utcStart', 'utcEnd', 'timeZone', 'duration'], + }, 'R2'], + ['CalendarEvent/get', { + ids => ['#2'], + properties => ['start', 'utcStart', 'utcEnd', 'timeZone', 'duration'], + }, 'R3'] + ]); + my $eventId1 = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId1); + my $eventId2 = $res->[0][1]{created}{2}{id}; + $self->assert_not_null($eventId2); + + my $event1 = $res->[1][1]{list}[0]; + $self->assert_str_equals('2019-12-11T10:30:00', $event1->{start}); + $self->assert_str_equals('2019-12-10T23:30:00Z', $event1->{utcStart}); + $self->assert_str_equals('2019-12-11T00:30:00Z', $event1->{utcEnd}); + $self->assert_str_equals('Australia/Melbourne', $event1->{timeZone}); + $self->assert_str_equals('PT1H', $event1->{duration}); + + my $event2 = $res->[2][1]{list}[0]; + $self->assert_str_equals('2019-12-10T23:30:00', $event2->{start}); + $self->assert_str_equals('2019-12-10T23:30:00Z', $event2->{utcStart}); + $self->assert_str_equals('2019-12-11T00:30:00Z', $event2->{utcEnd}); + $self->assert_str_equals('Etc/UTC', $event2->{timeZone}); + $self->assert_str_equals('PT1H', $event2->{duration}); + + # Assert event updates. + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId1 => { + utcStart => "2019-12-11T01:30:00Z", + }, + $eventId2 => { + utcStart => "2019-12-10T11:30:00Z", + duration => 'PT30M', + timeZone => 'America/New_York', + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['start', 'utcStart', 'utcEnd', 'timeZone', 'duration'], + }, 'R2'], + ['CalendarEvent/get', { + ids => [$eventId2], + properties => ['start', 'utcStart', 'utcEnd', 'timeZone', 'duration'], + }, 'R3'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId1}); + + $event1 = $res->[1][1]{list}[0]; + $self->assert_str_equals('2019-12-11T12:30:00', $event1->{start}); + $self->assert_str_equals('2019-12-11T01:30:00Z', $event1->{utcStart}); + $self->assert_str_equals('2019-12-11T02:30:00Z', $event1->{utcEnd}); + $self->assert_str_equals('Australia/Melbourne', $event1->{timeZone}); + $self->assert_str_equals('PT1H', $event1->{duration}); + + $event2 = $res->[2][1]{list}[0]; + $self->assert_str_equals('2019-12-10T06:30:00', $event2->{start}); + $self->assert_str_equals('2019-12-10T11:30:00Z', $event2->{utcStart}); + $self->assert_str_equals('2019-12-10T12:00:00Z', $event2->{utcEnd}); + $self->assert_str_equals('America/New_York', $event2->{timeZone}); + $self->assert_str_equals('PT30M', $event2->{duration}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_utcstart_recur b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_utcstart_recur new file mode 100644 index 0000000000..2188352f65 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_utcstart_recur @@ -0,0 +1,185 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_utcstart_recur + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $proplist = [ + 'start', + 'utcStart', + 'utcEnd', + 'timeZone', + 'duration', + 'recurrenceOverrides', + 'title' + ]; + + # Assert event creation. + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + utcStart => "2019-12-10T23:30:00Z", + duration => "PT1H", + timeZone => "Australia/Melbourne", + recurrenceRules => [{ + frequency => 'daily', + count => 5, + }], + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#1'], + properties => $proplist, + }, 'R2'] + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + + my $event = $res->[1][1]{list}[0]; + $self->assert_str_equals('2019-12-11T10:30:00', $event->{start}); + $self->assert_str_equals('2019-12-10T23:30:00Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-11T00:30:00Z', $event->{utcEnd}); + $self->assert_str_equals('Australia/Melbourne', $event->{timeZone}); + $self->assert_str_equals('PT1H', $event->{duration}); + + # Updating utcStart on a recurring event with no overrides is OK. + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + utcStart => "2019-12-11T01:30:00Z", + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => $proplist, + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + $event = $res->[1][1]{list}[0]; + $self->assert_str_equals('2019-12-11T12:30:00', $event->{start}); + $self->assert_str_equals('2019-12-11T01:30:00Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-11T02:30:00Z', $event->{utcEnd}); + $self->assert_str_equals('Australia/Melbourne', $event->{timeZone}); + $self->assert_str_equals('PT1H', $event->{duration}); + + # Updating utcStart on an expanded recurrence instance is OK. + my $eventInstanceId = encode_eventid('eventuid1local', '20191213T123000'); + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventInstanceId => { + utcStart => "2019-12-13T03:30:00Z", + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventInstanceId], + properties => $proplist, + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventInstanceId}); + + $event = $res->[1][1]{list}[0]; + $self->assert_str_equals('2019-12-13T14:30:00', $event->{start}); + $self->assert_str_equals('2019-12-13T03:30:00Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-13T04:30:00Z', $event->{utcEnd}); + $self->assert_str_equals('Australia/Melbourne', $event->{timeZone}); + $self->assert_str_equals('PT1H', $event->{duration}); + + # Now the event has a recurrenceOverride + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$eventId], + properties => $proplist, + }, 'R2'] + ]); + $event = $res->[0][1]{list}[0]; + + # Main event times are unchanged. + $self->assert_str_equals('2019-12-11T12:30:00', $event->{start}); + $self->assert_str_equals('2019-12-11T01:30:00Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-11T02:30:00Z', $event->{utcEnd}); + $self->assert_str_equals('Australia/Melbourne', $event->{timeZone}); + $self->assert_str_equals('PT1H', $event->{duration}); + + # Overriden instance times have changed. + my $override = $event->{recurrenceOverrides}{'2019-12-13T12:30:00'}; + $self->assert_str_equals('2019-12-13T14:30:00', $override->{start}); + $self->assert_str_equals('2019-12-13T03:30:00Z', $override->{utcStart}); + $self->assert_str_equals('2019-12-13T04:30:00Z', $override->{utcEnd}); + + # It's OK to loop back a recurring event with overrides and UTC times. + $event->{title} = 'updated title'; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => $event, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => $proplist, + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_deep_equals($event, $res->[1][1]{list}[0]); + + # But it is not OK to update UTC times in a recurring event with overrides. + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + utcStart => '2021-01-01T11:00:00Z', + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + update => { + $eventId => { + recurrenceOverrides => { + '2019-12-13T12:30:00' => { + utcStart => '2021-01-01T11:00:00Z', + }, + }, + }, + }, + }, 'R2'], + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides/2019-12-13T12:30:00' => { + utcStart => '2021-01-01T11:00:00Z', + }, + }, + }, + }, 'R3'], + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides/2019-12-13T12:30:00/utcStart' => '2021-01-01T11:00:00Z', + }, + }, + }, 'R4'], + ['CalendarEvent/get', { + ids => [$eventId], + properties => $proplist, + }, 'R5'] + ]); + $self->assert_not_null($res->[0][1]{notUpdated}{$eventId}); + $self->assert_not_null($res->[1][1]{notUpdated}{$eventId}); + $self->assert_not_null($res->[2][1]{notUpdated}{$eventId}); + $self->assert_not_null($res->[3][1]{notUpdated}{$eventId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_virtuallocation_xprop b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_virtuallocation_xprop new file mode 100644 index 0000000000..682c2d3e5e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_virtuallocation_xprop @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_virtuallocation_xprop + :needs_component_httpd :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $virtualLocations = { + vl1 => { + '@type' => 'VirtualLocation', + "example.com:foo" => "bar", + "example.com:bar" => { + "baz" => JSON::true, + }, + uri => 'https://example.com/v/e1ea21ce03a9', + }, + }; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + '@type' => 'Event', + title => 'test', + start => '2024-04-29T09:00:00', + timeZone => 'Europe/Berlin', + virtualLocations => $virtualLocations, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event1'], + properties => ['virtualLocations'], + }, 'R2'], + ]); + + $self->assert_deep_equals($virtualLocations, + $res->[1][1]{list}[0]{virtualLocations}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_writeown b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_writeown new file mode 100644 index 0000000000..fff2e6b927 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_writeown @@ -0,0 +1,175 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_writeown + :needs_component_jmap :min_version_0_0 :max_version_0_0 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Create sharee user"; + my $admin = $self->{adminstore}->get_client(); + $self->{instance}->create_user("sharee"); + my $service = $self->{instance}->get_service("http"); + my $shareeJmap = Mail::JMAPTalk->new( + user => 'sharee', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $shareeJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/calendars', + 'urn:ietf:params:jmap:calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + sharee => { + mayReadItems => JSON::true, + mayWriteOwn => JSON::true, + mayUpdatePrivate => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Create events"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventCassOwner => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + title => 'eventCassOwner', + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + part1 => { + '@type' => 'Participant', + sendTo => { + imip => 'mailto:part1@local', + }, + roles => { + attendee => JSON::true, + }, + }, + }, + start => '2021-01-01T01:00:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + eventShareeOwner => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + title => 'eventShareeOwner', + replyTo => { + imip => 'mailto:sharee@example.com', + }, + participants => { + part1 => { + '@type' => 'Participant', + sendTo => { + imip => 'mailto:part1@local', + }, + roles => { + attendee => JSON::true, + }, + }, + }, + start => '2021-01-01T01:00:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + eventNoOwner => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + title => 'eventNoOwner', + start => '2021-01-02T01:00:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + my $eventCassOwner = $res->[0][1]{created}{eventCassOwner}{id}; + $self->assert_not_null($eventCassOwner); + my $eventShareeOwner = $res->[0][1]{created}{eventShareeOwner}{id}; + $self->assert_not_null($eventShareeOwner); + my $eventNoOwner = $res->[0][1]{created}{eventNoOwner}{id}; + $self->assert_not_null($eventNoOwner); + + xlog "Update private event properties as sharee"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventCassOwner => { + color => 'pink', + }, + $eventShareeOwner => { + color => 'pink', + }, + $eventNoOwner => { + color => 'pink', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventCassOwner}); + $self->assert(exists $res->[0][1]{updated}{$eventShareeOwner}); + $self->assert(exists $res->[0][1]{updated}{$eventNoOwner}); + + xlog "Update non-private event properties as sharee"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + update => { + $eventCassOwner => { + title => 'eventCassOwnerUpdated', + }, + $eventShareeOwner => { + title => 'eventShareeOwnerUpdated', + }, + $eventNoOwner => { + title => 'eventNoOwnerUpdated', + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', + $res->[0][1]{notUpdated}{$eventCassOwner}{type}); + $self->assert(exists $res->[0][1]{updated}{$eventShareeOwner}); + $self->assert(exists $res->[0][1]{updated}{$eventNoOwner}); + + xlog "Destroy events as sharee"; + $res = $shareeJmap->CallMethods([ + ['CalendarEvent/set', { + accountId => 'cassandane', + destroy => [ + $eventCassOwner, + $eventShareeOwner, + $eventNoOwner, + ], + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', + $res->[0][1]{notDestroyed}{$eventCassOwner}{type}); + $self->assert(grep /$eventShareeOwner/, @{$res->[0][1]{destroyed}}); + $self->assert(grep /$eventNoOwner/, @{$res->[0][1]{destroyed}}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_writeown_caldav b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_writeown_caldav new file mode 100644 index 0000000000..363032a31f --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_set_writeown_caldav @@ -0,0 +1,229 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_set_writeown_caldav + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create sharee user"; + my $admin = $self->{adminstore}->get_client(); + $self->{instance}->create_user("sharee"); + my $service = $self->{instance}->get_service("http"); + my $shareeCaldav = Net::CalDAVTalk->new( + user => "sharee", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + sharee => { + mayReadItems => JSON::true, + mayWriteOwn => JSON::true, + mayUpdatePrivate => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Create event with cassandane owner"; + my $cassOwnerIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DURATION:PT1H +UID:40d6fe3c-6a51-489e-823e-3ea22f427a3e +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:cassowner +ORGANIZER:mailto:cassandane@example.com +ATTENDEE:mailto:attendee@example.com +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $res = $caldav->Request('PUT', + '/dav/calendars/user/cassandane/Default/cassowner.ics', + $cassOwnerIcal, 'Content-Type' => 'text/calendar'); + + xlog "Create event with sharee owner"; + my $shareeOwnerIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20161028T160000 +DURATION:PT1H +UID:7e55d2c1-d197-4e51-b9b6-a78c8a38fd78 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:shareeowner +ORGANIZER:mailto:sharee@example.com +ATTENDEE:mailto:attendee@example.com +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/sharee/cassandane.Default/shareeowner.ics', + $shareeOwnerIcal, 'Content-Type' => 'text/calendar'); + + xlog "Create event with no owner"; + my $noOwnerIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20161128T160000 +DURATION:PT1H +UID:80cdbc93-c602-4591-a8d2-f67a804e6acf +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:noowner +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', + '/dav/calendars/user/sharee/cassandane.Default/noowner.ics', + $noOwnerIcal, 'Content-Type' => 'text/calendar'); + + xlog "Update event with sharee owner as sharee"; + $shareeOwnerIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20161028T160000 +DURATION:PT1H +UID:7e55d2c1-d197-4e51-b9b6-a78c8a38fd78 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:shareeownerUpdated +ORGANIZER:mailto:sharee@example.com +ATTENDEE:mailto:attendee@example.com +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $shareeCaldav->Request('PUT', + '/dav/calendars/user/sharee/cassandane.Default/shareeowner.ics', + $shareeOwnerIcal, 'Content-Type' => 'text/calendar'); + + xlog "Update event with no owner as sharee"; + $noOwnerIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20161128T160000 +DURATION:PT1H +UID:80cdbc93-c602-4591-a8d2-f67a804e6acf +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:noowner +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $shareeCaldav->Request('PUT', + '/dav/calendars/user/sharee/cassandane.Default/noowner.ics', + $noOwnerIcal, 'Content-Type' => 'text/calendar'); + + xlog "Update per-user property as sharee"; + $cassOwnerIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DURATION:PT1H +UID:40d6fe3c-6a51-489e-823e-3ea22f427a3e +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:cassowner +COLOR:pink +ORGANIZER:mailto:cassandane@example.com +ATTENDEE;SCHEDULE-STATUS=1.1:mailto:attendee@example.com +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $shareeCaldav->Request('PUT', + '/dav/calendars/user/sharee/cassandane.Default/cassowner.ics', + $cassOwnerIcal, 'Content-Type' => 'text/calendar'); + + xlog "Update property as sharee"; + $cassOwnerIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DURATION:PT1H +UID:40d6fe3c-6a51-489e-823e-3ea22f427a3e +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:cassownerUpdated +ORGANIZER:mailto:cassandane@example.com +ATTENDEE;SCHEDULE-STATUS=1.1:mailto:attendee@example.com +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + # annoyingly CalDAV talk aborts for HTTP status >= 400 + my $href = '/dav/calendars/user/sharee/cassandane.Default/cassowner.ics'; + my $rawResponse = $shareeCaldav->{ua}->request('PUT', + $shareeCaldav->request_url($href), { + content => $cassOwnerIcal, + headers => { + 'Content-Type' => 'text/calendar', + 'Authorization' => $shareeCaldav->auth_header(), + }, + }, + ); + $self->assert_num_equals(403, $rawResponse->{status}); + + xlog "Delete event with sharee owner as sharee"; + $shareeCaldav->Request('DELETE', + '/dav/calendars/user/sharee/cassandane.Default/shareeowner.ics'); + + xlog "Delete event with no owner as sharee"; + $shareeCaldav->Request('DELETE', + '/dav/calendars/user/sharee/cassandane.Default/noowner.ics'); + + xlog "Delete event with cassandane owner as sharee"; + $href = '/dav/calendars/user/sharee/cassandane.Default/cassowner.ics'; + $rawResponse = $shareeCaldav->{ua}->request('DELETE', + $shareeCaldav->request_url($href), { + headers => { + 'Authorization' => $shareeCaldav->auth_header(), + }, + }, + ); + $self->assert_num_equals(403, $rawResponse->{status}); + + +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarevent_utcstart_customtz b/cassandane/tiny-tests/JMAPCalendars/calendarevent_utcstart_customtz new file mode 100644 index 0000000000..c7064a217b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarevent_utcstart_customtz @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarevent_utcstart_customtz + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + # Set custom calendar timezone. DST starts on December 1 at 2am. + my $CalendarId = $CalDAV->NewCalendar({name => 'mycalendar'}); + $self->assert_not_null($CalendarId); + my $proppatchXml = < + + + + +BEGIN:VCALENDAR +PRODID:-//Example Corp.//CalDAV Client//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Test +LAST-MODIFIED:19870101T000000Z +BEGIN:STANDARD +DTSTART:19670601T020000 +RRULE:FREQ=YEARLY;BYMONTHDAY=1;BYMONTH=6 +TZOFFSETFROM:-0700 +TZOFFSETTO:-0800 +TZNAME:TST +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19871201T020000 +RRULE:FREQ=YEARLY;BYMONTHDAY=1;BYMONTH=12 +TZOFFSETFROM:-0800 +TZOFFSETTO:-0700 +TZNAME:TST +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR + + + + +EOF + $CalDAV->Request('PROPPATCH', "/dav/calendars/user/cassandane/Default", + $proppatchXml, 'Content-Type' => 'text/xml'); + + # Create floating time event. + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2019-11-30T23:30:00", + duration => "PT6H", + timeZone => undef, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#1'], + properties => ['utcStart', 'utcEnd', 'timeZone'], + }, 'R2'] + ]); + my $eventId1 = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId1); + my $event = $res->[1][1]{list}[0]; + $self->assert_not_null($event); + + # Floating event time falls back to custom calendar time zone. + $self->assert_str_equals('2019-12-01T07:30:00Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-01T12:30:00Z', $event->{utcEnd}); + + # Assert event updates. + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId1 => { + utcStart => "2019-12-01T06:30:00Z", + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId1], + properties => ['start', 'utcStart', 'utcEnd', 'timeZone', 'duration'], + }, 'R2'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId1}); + + $event = $res->[1][1]{list}[0]; + $self->assert_str_equals('2019-11-30T22:30:00', $event->{start}); + $self->assert_str_equals('2019-12-01T06:30:00Z', $event->{utcStart}); + $self->assert_str_equals('2019-12-01T11:30:00Z', $event->{utcEnd}); + $self->assert_null($event->{timeZone}); + $self->assert_str_equals('PT6H', $event->{duration}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_aclcheck b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_aclcheck new file mode 100644 index 0000000000..b914f9fbb7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_aclcheck @@ -0,0 +1,111 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_aclcheck + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + sharedCalendar => { + name => 'sharedCalendar', + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayWriteOwn => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + unsharedCalendar => { + name => 'unsharedCalendar', + }, + }, + }, 'R1'], + ]); + my $sharedCalendarId = $res->[0][1]{created}{sharedCalendar}{id}; + $self->assert_not_null($sharedCalendarId); + my $unsharedCalendarId = $res->[0][1]{created}{unsharedCalendar}{id}; + $self->assert_not_null($unsharedCalendarId); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_deep_equals([], $res->[0][1]{list}); + my $state = $res->[0][1]{state}; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + sharedEvent => { + title => 'sharedEvent', + calendarIds => { + $sharedCalendarId => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + }, + unsharedEvent => { + title => 'unsharedEvent', + calendarIds => { + $unsharedCalendarId => JSON::true, + }, + start => '2012-02-02T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + my $sharedEventId = $res->[0][1]{created}{sharedEvent}{id}; + $self->assert_not_null($sharedEventId); + my $unsharedEventId = $res->[0][1]{created}{unsharedEvent}{id}; + $self->assert_not_null($unsharedEventId); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + properties => ['calendarEventId'], + }, 'R1'], + ['CalendarEventNotification/query', { + accountId => 'cassandane', + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals($sharedEventId, $res->[0][1]{list}[0]{calendarEventId}); + my $notifId = $res->[0][1]{list}[0]{id}; + $self->assert_deep_equals([$notifId], $res->[1][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_caldav b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_caldav new file mode 100644 index 0000000000..cb719c3c44 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_caldav @@ -0,0 +1,207 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_caldav + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayWriteOwn => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + + xlog "User creates an event"; + + my $ical = <Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('created', $res->[0][1]{list}[0]{type}); + $self->assert_str_equals('cassandane', + $res->[0][1]{list}[0]{changedBy}{calendarPrincipalId}); + $self->assert_not_null($res->[0][1]{list}[0]{event}); + + xlog "User updates an event"; + + $ical = <Request('PUT', + '/dav/calendars/user/cassandane/Default/test.ics', + $ical, 'Content-Type' => 'text/calendar'); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + my %notifs = map { $_->{type} => $_ } @{$res->[0][1]{list}}; + $self->assert_not_null($notifs{'updated'}->{event}); + $self->assert_not_null($notifs{'updated'}->{eventPatch}); + $self->assert_str_equals('cassandane', + $notifs{'updated'}->{changedBy}{calendarPrincipalId}); + + xlog "User deletes an event"; + + $caldav->Request('DELETE', + '/dav/calendars/user/cassandane/Default/test.ics'); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('destroyed', $res->[0][1]{list}[0]{type}); + $self->assert_str_equals('cassandane', + $res->[0][1]{list}[0]{changedBy}{calendarPrincipalId}); + $self->assert_not_null($res->[0][1]{list}[0]{event}); + + xlog "iTIP handler creates an event"; + + $ical = <Request('PUT', + '/dav/calendars/user/cassandane/Default/testitip.ics', + $ical, 'Content-Type' => 'text/calendar', + 'Schedule-Sender-Address' => 'itipsender@local', + 'Schedule-Sender-Name' => '=?utf-8?q?iTIP_=E2=98=BA_Sender?=', + ); + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/get', { + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('created', $res->[0][1]{list}[0]{type}); + $self->assert_str_equals('itipsender@local', + $res->[0][1]{list}[0]{changedBy}{email}); + $self->assert_str_equals("iTIP \N{WHITE SMILING FACE} Sender", # assert RFC0247 support + $res->[0][1]{list}[0]{changedBy}{name}); + $self->assert_null($res->[0][1]{list}[0]{changedBy}{calendarPrincipalId}); + + xlog "iTIP handler deletes an event"; + + $caldav->Request('DELETE', + '/dav/calendars/user/cassandane/Default/testitip.ics', + undef, + 'Schedule-Sender-Address' => 'itipdeleter@local', + 'Schedule-Sender-Name' => 'iTIP Deleter'); + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('destroyed', $res->[0][1]{list}[0]{type}); + $self->assert_str_equals('itipdeleter@local', + $res->[0][1]{list}[0]{changedBy}{email}); + $self->assert_str_equals('iTIP Deleter', + $res->[0][1]{list}[0]{changedBy}{name}); + $self->assert_null($res->[0][1]{list}[0]{changedBy}{calendarPrincipalId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_changes b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_changes new file mode 100644 index 0000000000..4d2fc03b2a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_changes @@ -0,0 +1,138 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_changes + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + # Need to share calendar, otherwise no notification is created + + my $admin = $self->{adminstore}->get_client(); + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayWriteOwn => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/get', { + }, 'R1'], + ]); + $self->assert_deep_equals([], $res->[0][1]{list}); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/changes', { + sinceState => $state, + }, 'R1'], + ]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + xlog "create notification that cassandane will see"; + + my $ical = <Request('PUT', + '/dav/calendars/user/cassandane/Default/testitip.ics', + $ical, 'Content-Type' => 'text/calendar', + 'Schedule-Sender-Address' => 'itipsender@local', + 'Schedule-Sender-Name' => 'iTIP Sender', + ); + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/changes', { + sinceState => $state, + }, 'R1'], + ]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + my $notifId = $res->[0][1]{created}[0]; + my $oldState = $state; + $state = $res->[0][1]{newState}; + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/set', { + destroy => [$notifId], + }, 'R1'], + ]); + $self->assert_deep_equals([$notifId], $res->[0][1]{destroyed}); + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/changes', { + sinceState => $state, + }, 'R1'], + ]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([$notifId], $res->[0][1]{destroyed}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_changes_shared b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_changes_shared new file mode 100644 index 0000000000..48294be102 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_changes_shared @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_changes_shared + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayWriteOwn => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'] + ]); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + + # This should work, but it currently doesn't. + # At least we can check for the correct error. + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/queryChanges', { + accountId => 'cassandane', + sinceQueryState => $state, + }, 'R1'] + ]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_get b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_get new file mode 100644 index 0000000000..d2bd6b7fe0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_get @@ -0,0 +1,135 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_get + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + xlog "Create event"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayWriteOwn => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + create => { + event1 => { + title => 'event1', + calendarIds => { + Default => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R2'], + ['CalendarEventNotification/get', { + }, 'R3'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + my $eventId = $res->[1][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + # Event creator is not notified. + $self->assert_num_equals(0, scalar @{$res->[2][1]{list}}); + + # Event sharee is notified. + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('created', $res->[0][1]{list}[0]{type}); + my $notif1 = $res->[0][1]{list}[0]{id}; + + xlog "Update event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'event1updated', + }, + }, + }, 'R1'], + ['CalendarEventNotification/get', { + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + # Event updater is not notified. + $self->assert_num_equals(0, scalar @{$res->[1][1]{list}}); + # Event sharee is notified. + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + + my %notifs = map { $_->{type} => $_ } @{$res->[0][1]{list}}; + $self->assert_str_equals($notif1, $notifs{created}{id}); + my $notif2 = $notifs{updated}{id}; + $self->assert_str_not_equals($notif2, $notif1); + + xlog "Destroy event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [$eventId], + }, 'R1'], + ['CalendarEventNotification/get', { + }, 'R2'], + ]); + $self->assert_deep_equals([$eventId], $res->[0][1]{destroyed}); + # Event destroyer is not notified. + $self->assert_num_equals(0, scalar @{$res->[2][1]{list}}); + + # Event sharee only sees destroy notification. + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_not_equals($notif1, $res->[0][1]{list}[0]{id}); + $self->assert_str_not_equals($notif2, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals('destroyed', $res->[0][1]{list}[0]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_get_no_sharee b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_get_no_sharee new file mode 100644 index 0000000000..e2579c4b51 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_get_no_sharee @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_get_no_sharee + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create('user.cassandane.#jmapnotification') or die; + $admin->setacl('user.cassandane.#jmapnotification', + 'cassandane' => 'lrswipkxtecdan') or die; + + xlog "Create event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + title => 'event1', + calendarIds => { + Default => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + $self->assert_num_equals(0, + $admin->message_count('user.cassandane.#jmapnotification')); + + xlog "Update event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + title => 'event1Updated', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + $self->assert_num_equals(0, + $admin->message_count('user.cassandane.#jmapnotification')); + + xlog "Destroy event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + destroy => [ $eventId ], + }, 'R1'], + ]); + $self->assert_deep_equals([$eventId], $res->[0][1]{destroyed}); + + $self->assert_num_equals(0, + $admin->message_count('user.cassandane.#jmapnotification')); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_imip b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_imip new file mode 100644 index 0000000000..4368c898e5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_imip @@ -0,0 +1,186 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_imip + :needs_component_sieve :needs_component_httpd :needs_component_jmap :min_version_3_5 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <6de280c9-edff-4019-8ebd-cfebc73f8201@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: 6de280c9-edff-4019-8ebd-cfebc73f8201 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { properties => ['id'] }, 'R1'], + ['CalendarEventNotification/get', { }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + + $self->assert_str_equals('sender@example.net', + $res->[1][1]{list}[0]{changedBy}{email}); + $self->assert_str_equals('Sally Sender', + $res->[1][1]{list}[0]{changedBy}{name}); + $self->assert_str_equals('created', $res->[1][1]{list}[0]{type}); + + my $state = $res->[1][1]{state}; + $self->assert_not_null($state); + + # UPDATE + + $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <6de280c9-edff-4019-8ebd-cfebc73f8201@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: 6de280c9-edff-4019-8ebd-cfebc73f8201 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An updated event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP update"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { properties => ['id'] }, 'R1'], + ['CalendarEventNotification/changes', { + sinceState => $state }, + 'R2'], + ['CalendarEventNotification/get', { + '#ids' => { + resultOf => 'R2', + name => 'CalendarEventNotification/changes', + path => '/created' + }, + }, 'R3'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + + $self->assert_num_equals(1, scalar @{$res->[2][1]{list}}); + + $self->assert_str_equals('sender@example.net', + $res->[2][1]{list}[0]{changedBy}{email}); + $self->assert_str_equals('Sally Sender', + $res->[2][1]{list}[0]{changedBy}{name}); + $self->assert_str_equals('updated', $res->[2][1]{list}[0]{type}); + + $state = $res->[2][1]{state}; + $self->assert_not_null($state); + + # DELETE + + $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 10:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <6de280c9-edff-4019-8ebd-cfebc73f8202@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: 6de280c9-edff-4019-8ebd-cfebc73f8201 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:CANCEL +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:6de280c9-edff-4019-8ebd-cfebc73f8201 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:3 +ORGANIZER;CN=Test User:MAILTO:foo@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP cancellation"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id'] + }, 'R1'], + ['CalendarEventNotification/changes', { + sinceState => $state }, + 'R2'], + ['CalendarEventNotification/get', { + '#ids' => { + resultOf => 'R2', + name => 'CalendarEventNotification/changes', + path => '/created' + }, + }, 'R3'], + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{list}}); + + $self->assert_str_equals('sender@example.net', + $res->[2][1]{list}[0]{changedBy}{email}); + $self->assert_str_equals('Sally Sender', + $res->[2][1]{list}[0]{changedBy}{name}); + $self->assert_str_equals('destroyed', $res->[2][1]{list}[0]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_prune b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_prune new file mode 100644 index 0000000000..80317d3276 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_prune @@ -0,0 +1,128 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_prune + :min_version_3_9 :needs_component_jmap :JmapMaxCalendarEventNotifs +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $jmap_max_calendareventnotifs = $self->{instance}->{ + config}->get('jmap_max_calendareventnotifs'); + $self->assert_not_null($jmap_max_calendareventnotifs); + + my ($manJmap) = $self->create_user('manifold'); + $manJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + xlog $self, "Share calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayWriteOwn => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog $self, "Create event notification"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + title => 'event1', + calendarIds => { + Default => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{created}{event1}); + + xlog $self, "Get event notification"; + $res = $manJmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + my $notif1Id = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($notif1Id); + + xlog $self, "Create maximum count of allowed notifications"; + for my $i (2 .. $jmap_max_calendareventnotifs) { + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + "event$i" => { + title => "event$i", + calendarIds => { + Default => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{created}{"event$i"}); + } + + xlog $self, "Get event notifications"; + $res = $manJmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + properties => ['id'], + }, 'R1'], + ]); + $self->assert_num_equals($jmap_max_calendareventnotifs, scalar @{$res->[0][1]{list}}); + + xlog $self, "Assert first event notification exists"; + $self->assert_equals(1, scalar grep { $_->{id} eq $notif1Id } @{$res->[0][1]{list}}); + + xlog $self, "Create one more event notification"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + eventX => { + title => 'eventX', + calendarIds => { + Default => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{created}{eventX}); + + xlog $self, "Get event notifications"; + $res = $manJmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + properties => ['id'], + }, 'R1'], + ]); + $self->assert_num_equals($jmap_max_calendareventnotifs, scalar @{$res->[0][1]{list}}); + + xlog $self, "Assert first event notification does not exist"; + $self->assert_equals(0, scalar grep { $_->{id} eq $notif1Id } @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_query b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_query new file mode 100644 index 0000000000..dd02dbbe3b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_query @@ -0,0 +1,159 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_query + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayWriteOwn => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + xlog "Create notifications"; + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + title => 'event1', + calendarIds => { + Default => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R2'], + ]); + my $event1Id = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($event1Id); + + sleep(1); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $event1Id => { + title => 'event1updated', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$event1Id}); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + my %notifs = map { $_->{type} => $_ } @{$res->[0][1]{list}}; + my $notif1 = $notifs{created}; + $self->assert_not_null($notif1); + my $notif2 = $notifs{updated}; + $self->assert_not_null($notif2); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/query', { + accountId => 'cassandane', + filter => { + type => 'created', + }, + }, 'R1'], + ['CalendarEventNotification/query', { + accountId => 'cassandane', + filter => { + type => 'updated', + }, + }, 'R2'], + ['CalendarEventNotification/query', { + accountId => 'cassandane', + filter => { + before => $notif2->{created}, + }, + }, 'R3'], + ['CalendarEventNotification/query', { + accountId => 'cassandane', + filter => { + after => $notif2->{created}, + }, + }, 'R4'], + ]); + $self->assert_deep_equals([$notif1->{id}], $res->[0][1]{ids}); + $self->assert_deep_equals([$notif2->{id}], $res->[1][1]{ids}); + $self->assert_deep_equals([$notif1->{id}], $res->[2][1]{ids}); + $self->assert_deep_equals([$notif2->{id}], $res->[3][1]{ids}); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event2 => { + title => 'event2', + calendarIds => { + Default => JSON::true, + }, + start => '2012-02-02T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R2'], + ]); + my $event2Id = $res->[0][1]{created}{event2}{id}; + $self->assert_not_null($event2Id); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/query', { + accountId => 'cassandane', + filter => { + calendarEventIds => [$event2Id], + }, + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_not_equals($notif1->{id}, $res->[0][1]{ids}[0]); + $self->assert_str_not_equals($notif2->{id}, $res->[0][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_querychanges b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_querychanges new file mode 100644 index 0000000000..0d26a667ed --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_querychanges @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_querychanges + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEventNotification/queryChanges', { + sinceQueryState => 'whatever', + }, 'R1'] + ]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_set b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_set new file mode 100644 index 0000000000..5fb7371d2b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_set @@ -0,0 +1,121 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_set + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + xlog "Create event"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayWriteOwn => JSON::true, + mayUpdatePrivate => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + create => { + event1 => { + title => 'event1', + calendarIds => { + Default => JSON::true, + }, + start => '2011-01-01T04:05:06', + duration => 'PT1H', + }, + }, + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + my $eventId = $res->[1][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R2'], + ]); + + my $notif = $res->[0][1]{list}[0]; + my $notifId = $notif->{id}; + $self->assert_not_null($notifId); + delete($notif->{id}); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/set', { + accountId => 'cassandane', + create => { + newnotif => $notif, + }, + update => { + $notifId => $notif, + }, + }, "R1"] + ]); + $self->assert_str_equals('forbidden', + $res->[0][1]{notCreated}{newnotif}{type}); + $self->assert_str_equals('forbidden', + $res->[0][1]{notUpdated}{$notifId}{type}); + $self->assert_not_null($res->[0][1]{newState}); + my $state = $res->[0][1]{newState}; + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/set', { + accountId => 'cassandane', + destroy => [$notifId, 'unknownId'], + }, "R1"] + ]); + $self->assert_deep_equals([$notifId], $res->[0][1]{destroyed}); + $self->assert_str_equals('notFound', + $res->[0][1]{notDestroyed}{unknownId}{type}); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + ids => [$notifId], + }, 'R1'] + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_deep_equals([$notifId], $res->[0][1]{notFound}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_set_destroy b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_set_destroy new file mode 100644 index 0000000000..c647b79009 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendareventnotification_set_destroy @@ -0,0 +1,255 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendareventnotification_set_destroy + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayUpdatePrivate => JSON::true, + mayWriteOwn => JSON::true, + mayAdmin => JSON::false + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/get', { + }, 'R1'], + ]); + my $cassState = $res->[0][1]{state}; + $self->assert_not_null($cassState); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ]); + my $manState = $res->[0][1]{state}; + $self->assert_not_null($manState); + + xlog "create a notification that both cassandane and manifold will see"; + + my $ical = <Request('PUT', + '/dav/calendars/user/cassandane/Default/testitip.ics', + $ical, 'Content-Type' => 'text/calendar', + 'Schedule-Sender-Address' => 'itipsender@local', + 'Schedule-Sender-Name' => 'iTIP Sender', + ); + + xlog "fetch notifications"; + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/get', { + }, 'R1'], + ['CalendarEventNotification/query', { + }, 'R2'], + ['CalendarEventNotification/changes', { + sinceState => $cassState, + }, 'R3'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{created}}); + $cassState = $res->[2][1]{newState}; + + my $notifId = $res->[1][1]{ids}[0]; + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ['CalendarEventNotification/query', { + accountId => 'cassandane', + }, 'R2'], + ['CalendarEventNotification/changes', { + accountId => 'cassandane', + sinceState => $manState, + }, 'R3'], + ]); + $self->{instance}->getsyslog(); # ignore seen.db DBERROR + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{created}}); + $manState = $res->[2][1]{newState}; + + xlog "destroy notification as cassandane user"; + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/set', { + destroy => [$notifId], + }, 'R1'], + ]); + $self->assert_deep_equals([$notifId], $res->[0][1]{destroyed}); + + xlog "refetch notifications"; + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/get', { + }, 'R1'], + ['CalendarEventNotification/query', { + }, 'R2'], + ['CalendarEventNotification/changes', { + sinceState => $cassState, + }, 'R3'], + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{destroyed}}); + $cassState = $res->[2][1]{newState}; + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ['CalendarEventNotification/query', { + accountId => 'cassandane', + }, 'R2'], + ['CalendarEventNotification/changes', { + accountId => 'cassandane', + sinceState => $manState, + }, 'R3'], + ]); + $self->{instance}->getsyslog(); # ignore seen.db DBERROR + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{destroyed}}); + $manState = $res->[2][1]{newState}; + + xlog "destroy notification as sharee"; + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/set', { + accountId => 'cassandane', + destroy => [$notifId], + }, 'R1'], + ]); + $self->assert_deep_equals([$notifId], $res->[0][1]{destroyed}); + + xlog "refetch notifications"; + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/get', { + }, 'R1'], + ['CalendarEventNotification/query', { + }, 'R2'], + ['CalendarEventNotification/changes', { + sinceState => $cassState, + }, 'R3'], + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{created}}); + # XXX this should be 0 but we err on the safe side and report duplicate destroys + $self->assert_num_equals(1, scalar @{$res->[2][1]{destroyed}}); + $cassState = $res->[2][1]{newState}; + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ['CalendarEventNotification/query', { + accountId => 'cassandane', + }, 'R2'], + ['CalendarEventNotification/changes', { + accountId => 'cassandane', + sinceState => $manState, + }, 'R3'], + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{destroyed}}); + $manState = $res->[2][1]{newState}; + + $res = $jmap->CallMethods([ + ['CalendarEventNotification/get', { + }, 'R1'], + ['CalendarEventNotification/query', { + }, 'R2'], + ['CalendarEventNotification/changes', { + sinceState => $cassState, + }, 'R3'], + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{destroyed}}); + + $res = $manjmap->CallMethods([ + ['CalendarEventNotification/get', { + accountId => 'cassandane', + }, 'R1'], + ['CalendarEventNotification/query', { + accountId => 'cassandane', + }, 'R2'], + ['CalendarEventNotification/changes', { + accountId => 'cassandane', + sinceState => $manState, + }, 'R3'], + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[2][1]{destroyed}}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_defaultcalendar b/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_defaultcalendar new file mode 100644 index 0000000000..1944ea32f6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_defaultcalendar @@ -0,0 +1,233 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarpreferences_defaultcalendar + :min_version_3_7 :needs_component_jmap :needs_component_sieve + :CalDAVNoDefaultCalendar +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admin = $self->{adminstore}->get_client(); + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<NewCalendar({ id => 'Default' }); + + my $res = $jmap->CallMethods([ + ['Calendar/get', { }, 'R1'], + ]); + $self->assert_str_equals('Default', $res->[0][1]{list}[0]{id}); + + xlog "No defaultCalendar set"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/get', { }, 'R1'], + ]); + $self->assert_null($res->[0][1]{list}[0]{defaultCalendarId}); + + xlog "Get CalendarEvent state"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { }, 'R1'], + ]); + $self->assert_deep_equals([], $res->[0][1]{list}); + my $state = $res->[0][1]{state}; + + xlog "Deliver message"; + $self->deliver_imip(); + + xlog "Message should go into hard-coded Default calendar"; + $res = $jmap->CallMethods([ + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/changes', + path => '/created' + }, + properties => ['calendarIds'], + }, 'R2'], + ]); + $self->assert_deep_equals({ + Default => JSON::true + }, $res->[1][1]{list}[0]{calendarIds}); + $state = $res->[1][1]{state}; + + xlog "Create calendars A, B and C"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendarA => { + name => 'A', + }, + calendarB => { + name => 'B', + }, + calendarC => { + name => 'C', + }, + }, + }, 'R1'], + ]); + my $calendarA = $res->[0][1]{created}{calendarA}{id}; + $self->assert_not_null($calendarA); + my $calendarB = $res->[0][1]{created}{calendarB}{id}; + $self->assert_not_null($calendarB); + my $calendarC = $res->[0][1]{created}{calendarC}{id}; + $self->assert_not_null($calendarC); + + xlog "Make calendar C read-only to owner"; + $admin->setacl("user.cassandane.#calendars.$calendarC", cassandane => 'lrs') or die; + + xlog "Set calendarA as default"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/set', { + update => { + singleton => { + defaultCalendarId => $calendarA, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{singleton}); + + xlog "Deliver message"; + $self->deliver_imip(); + + xlog "Message should go into calendar A"; + $res = $jmap->CallMethods([ + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/changes', + path => '/created' + }, + properties => ['calendarIds'], + }, 'R2'], + ]); + $self->assert_deep_equals({ + $calendarA => JSON::true + }, $res->[1][1]{list}[0]{calendarIds}); + $state = $res->[1][1]{state}; + + xlog "Destroying calendar A picks Default as new default"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + destroy => [$calendarA], + onDestroyRemoveEvents => JSON::true, + }, 'R1'], + ['CalendarPreferences/get', { + }, 'R2'], + ]); + $self->assert_deep_equals([$calendarA], $res->[0][1]{destroyed}); + $self->assert_str_equals('Default', $res->[1][1]{list}[0]{defaultCalendarId}); + + xlog "Can set defaultCalendarId to null, but new one gets picked immediately"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/set', { + update => { + singleton => { + defaultCalendarId => undef, + }, + }, + }, 'R1'], + ['CalendarPreferences/get', { + }, 'R2'], + ]); + $self->assert_str_equals($res->[0][1]{updated}{singleton}{defaultCalendarId}, + $res->[1][1]{list}[0]{defaultCalendarId}); + + xlog "Destroy special calendar Default, new default is calendar B"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + destroy => [ 'Default' ], + onDestroyRemoveEvents => JSON::true, + }, 'R1'], + ['CalendarPreferences/get', { + }, 'R2'], + ]); + $self->assert_deep_equals(['Default'], $res->[0][1]{destroyed}); + $self->assert_str_equals($calendarB, $res->[1][1]{list}[0]{defaultCalendarId}); + + xlog "Get CalendarEvent state"; + $res = $jmap->CallMethods([ + ['Calendar/get', { + properties => ['id'], + }, 'R0'], + ['CalendarEvent/get', { + properties => ['id', 'calendarIds'], + }, 'R1'], + ['Calendar/get', { + properties => ['id'], + }, 'R2'], + ]); + $state = $res->[1][1]{state}; + + xlog "Deliver message"; + $self->deliver_imip(); + + xlog "Message should go into writable calendar B"; + $res = $jmap->CallMethods([ + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/changes', + path => '/created' + }, + properties => ['calendarIds'], + }, 'R2'], + ]); + $self->assert_deep_equals({ + $calendarB => JSON::true + }, $res->[1][1]{list}[0]{calendarIds}); + $state = $res->[1][1]{state}; + + xlog "Destroy calendar B"; + $res = $jmap->CallMethods([ + ['Calendar/set', { + destroy => [ $calendarB ], + onDestroyRemoveEvents => JSON::true, + }, 'R1'], + ['CalendarPreferences/get', { + }, 'R2'], + ]); + $self->assert_deep_equals([$calendarB], $res->[0][1]{destroyed}); + + xlog "Read-only calendar C does not get picked as default"; + $self->assert_null($res->[1][1]{list}[0]{defaultCalendarId}); + + xlog "Cannot set read-only calendar as default calendar"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/set', { + update => { + singleton => { + defaultCalendarId => $calendarC, + }, + }, + }, 'R1'], + ['CalendarPreferences/get', { + }, 'R2'], + ]); + $self->assert_deep_equals(['defaultCalendarId'], + $res->[0][1]{notUpdated}{singleton}{properties}); + $self->assert_null($res->[1][1]{list}[0]{defaultCalendarId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_participantidentity b/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_participantidentity new file mode 100644 index 0000000000..44cc4adbf9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_participantidentity @@ -0,0 +1,172 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarpreferences_participantidentity + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "No defaultParticipantIdentityId set"; + my $res = $jmap->CallMethods([ + ['CalendarPreferences/get', { }, 'R1'], + ]); + $self->assert_null($res->[0][1]{list}[0]{defaultParticipantIdentityId}); + + xlog 'Cyrus selects owner participant'; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + participants => { + someone => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + }, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_deep_equals({ + imip => 'mailto:cassandane@example.com', + }, $res->[0][1]{created}{event1}{replyTo}); + + xlog "Set scheduling addresses via CalDAV"; + my $xml = <<'EOF'; + + + + + + mailto:alias1@example.com + mailto:alias2@example.com + mailto:alias3@example.com + + + + +EOF + $caldav->Request('PROPPATCH', "/dav/principals/user/cassandane", + $xml, 'Content-Type' => 'text/xml'); + + xlog "No defaultParticipantIdentityId set"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/get', { }, 'R1'], + ]); + $self->assert_null($res->[0][1]{list}[0]{defaultParticipantIdentityId}); + + xlog "Get participant identities"; + $res = $jmap->CallMethods([ + ['ParticipantIdentity/get', { }, 'R1'], + ]); + my $participantId = (grep {$_->{sendTo}{imip} eq 'mailto:alias2@example.com'} + @{$res->[0][1]{list}})[0]{id}; + $self->assert_not_null($participantId); + + xlog "Set participant identity as default"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/set', { + update => { + singleton => { + defaultParticipantIdentityId => $participantId, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{singleton}); + + xlog 'Cyrus uses default participant'; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event2 => { + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2020-01-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + participants => { + someone => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + }, + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => ['#event2'], + properties => ['replyTo'], + }, 'R2'], + ]); + $self->assert_deep_equals({ + imip => 'mailto:alias2@example.com', + }, $res->[0][1]{created}{event2}{replyTo}); + + xlog "Updated scheduling addresses keep default participant"; + $xml = <<'EOF'; + + + + + + mailto:alias1@example.com + mailto:alias2@example.com + mailto:alias3@example.com + mailto:alias4@example.com + + + + +EOF + $caldav->Request('PROPPATCH', "/dav/principals/user/cassandane", + $xml, 'Content-Type' => 'text/xml'); + + $res = $jmap->CallMethods([ + ['CalendarPreferences/get', { }, 'R1'] + ]); + $self->assert_str_equals($participantId, + $res->[0][1]{list}[0]{defaultParticipantIdentityId}); + + xlog "Removed default scheduling address reset default id"; + $xml = <<'EOF'; + + + + + + mailto:alias4@example.com + mailto:alias5@example.com + + + + +EOF + $caldav->Request('PROPPATCH', "/dav/principals/user/cassandane", + $xml, 'Content-Type' => 'text/xml'); + + $res = $jmap->CallMethods([ + ['CalendarPreferences/get', { }, 'R1'] + ]); + $self->assert_null($res->[0][1]{list}[0]{defaultParticipantIdentityId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_set b/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_set new file mode 100644 index 0000000000..1c96622b57 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarpreferences_set @@ -0,0 +1,102 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarpreferences_set + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + # omit debug properties, so don't use debug extension + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'urn:ietf:params:jmap:calendars:preferences', + 'https://cyrusimap.org/ns/jmap/calendars' + ]; + + xlog "Create calendar"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + calendar => { + name => 'Test', + }, + } + }, 'R1'], + ], $using); + my $calendarId = $res->[0][1]{created}{calendar}{id}; + $self->assert_not_null($calendarId); + + xlog "Fetch participant identities"; + $res = $jmap->CallMethods([ + ['ParticipantIdentity/get', { + }, 'R1'], + ], $using); + my $participantId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($participantId); + + xlog "Fetch preferences"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/get', { + }, 'R1'], + ], $using); + $self->assert_deep_equals([{ + id => 'singleton', + defaultCalendarId => undef, + defaultParticipantIdentityId => undef, + }], $res->[0][1]{list}); + my $state = $res->[0][1]{state}; + + xlog "Set preferences"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/set', { + update => { + singleton => { + defaultCalendarId => $calendarId, + defaultParticipantIdentityId => $participantId, + }, + }, + }, 'R1'], + ], $using); + $self->assert(exists $res->[0][1]{updated}{singleton}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + + xlog "Fetch preferences by id"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/get', { + ids => ['singleton'], + }, 'R1'], + ], $using); + $self->assert_deep_equals([{ + id => 'singleton', + defaultCalendarId => $calendarId, + defaultParticipantIdentityId => $participantId, + }], $res->[0][1]{list}); + + xlog "Unset preferences"; + $res = $jmap->CallMethods([ + ['CalendarPreferences/set', { + update => { + singleton => { + defaultCalendarId => undef, + defaultParticipantIdentityId => undef, + }, + }, + }, 'R1'], + ['CalendarPreferences/get', { + ids => ['singleton'], + }, 'R2'], + ], $using); + xlog "Setting defaultCalendarId to null assigns a new default calendar"; + $self->assert_not_null($res->[0][1]{updated}{singleton}{defaultCalendarId}); + $self->assert_deep_equals([{ + id => 'singleton', + defaultCalendarId => $res->[0][1]{updated}{singleton}{defaultCalendarId}, + defaultParticipantIdentityId => undef, + }], $res->[1][1]{list}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_changes b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_changes new file mode 100644 index 0000000000..ea381fd267 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_changes @@ -0,0 +1,15 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarprincipal_changes + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Principal/changes', { + }, 'R1'] + ]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_get b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_get new file mode 100644 index 0000000000..da6d1e96cd --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_get @@ -0,0 +1,109 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarprincipal_get + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $CalDAV = $self->{caldav}; + + # Set timezone + my $proppatchXml = < + + + + +BEGIN:VCALENDAR +PRODID:-//CyrusIMAP.org//Cyrus 1.0//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Berlin +COMMENT:[DE] Germany (most areas) +LAST-MODIFIED:20200820T145616Z +X-LIC-LOCATION:Europe/Berlin +X-PROLEPTIC-TZNAME:LMT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+005328 +TZOFFSETTO:+0100 +DTSTART:18930401T000000 +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +END:VCALENDAR + + + + +EOF + $CalDAV->Request('PROPPATCH', "/dav/calendars/user/cassandane", + $proppatchXml, 'Content-Type' => 'text/xml'); + + # Set description + $proppatchXml = < + + + +A description + + + +EOF + $CalDAV->Request('PROPPATCH', "/dav/calendars/user/cassandane", + $proppatchXml, 'Content-Type' => 'text/xml'); + + # Set name + $proppatchXml = < + + + +Cassandane User + + + +EOF + $CalDAV->Request('PROPPATCH', "/dav/principals/user/cassandane", + $proppatchXml, 'Content-Type' => 'text/xml'); + + + my $res = $jmap->CallMethods([ + ['Principal/get', { + ids => ['cassandane', 'nope'], + }, 'R1'] + ]); + my $p = $res->[0][1]{list}[0]; + + $self->assert_not_null($p->{account}); + delete ($p->{account}); + $self->assert_deep_equals({ + id => 'cassandane', + name => 'Cassandane User', + description => 'A description', + email => 'cassandane@example.com', + type => 'individual', + timeZone => 'Europe/Berlin', + mayGetAvailability => JSON::true, + accountId => 'cassandane', + sendTo => { + imip => 'mailto:cassandane@example.com', + }, + }, $p); + $self->assert_deep_equals(['nope'], $res->[0][1]{notFound}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_getavailability_merged b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_getavailability_merged new file mode 100644 index 0000000000..a84aa878c2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_getavailability_merged @@ -0,0 +1,141 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarprincipal_getavailability_merged + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + # 09:00 to 10:30: Two events adjacent to each other. + 'event-0900-1000' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-0900-1000", + start => "2020-08-01T09:00:00", + timeZone => "Etc/UTC", + duration => "PT1H", + }, + 'event-1000-1030' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-1000-1030", + start => "2020-08-01T10:00:00", + timeZone => "Etc/UTC", + duration => "PT30M", + }, + # 05:00 to 08:00: One event completely overlapping the other. + 'event-0500-0800' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-0500-0800", + start => "2020-08-01T05:00:00", + timeZone => "Etc/UTC", + duration => "PT3H", + }, + 'event-0600-0700' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-06:00-07:00", + start => "2020-08-01T06:00:00", + timeZone => "Etc/UTC", + duration => "PT1H", + }, + # 01:00 to 03:00: One event partially overlapping the other. + 'event-0100-0200' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-0100-0200", + start => "2020-08-01T01:00:00", + timeZone => "Etc/UTC", + duration => "PT1H", + }, + 'event-0130-0300' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-0130-0300", + start => "2020-08-01T01:30:00", + timeZone => "Etc/UTC", + duration => "PT1H30M", + }, + # 12:00 to 13:30: Overlapping events with differing busyStatus. + 'event-1200-1300-tentative' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-1200-1300-tentative", + start => "2020-08-01T12:00:00", + timeZone => "Etc/UTC", + duration => "PT1H", + status => 'tentative', + }, + 'event-1200-1330-confirmed' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-1200-1330-confirmed", + start => "2020-08-01T12:00:00", + timeZone => "Etc/UTC", + duration => "PT1H30M", + status => 'confirmed', + }, + 'event-1200-1230-unavailable' => { + calendarIds => { + Default => JSON::true, + }, + title => "event-1200-1330-unavailable", + start => "2020-08-01T12:00:00", + timeZone => "Etc/UTC", + duration => "PT30M", + }, + }, + }, 'R1'], + ['Principal/getAvailability', { + id => 'cassandane', + utcStart => '2020-08-01T00:00:00Z', + utcEnd => '2020-09-01T00:00:00Z', + }, 'R2'], + ]); + $self->assert_num_equals(9, scalar keys %{$res->[0][1]{created}}); + + $self->assert_deep_equals([{ + utcStart => "2020-08-01T01:00:00Z", + utcEnd => "2020-08-01T03:00:00Z", + busyStatus => 'unavailable', + event => undef, + }, { + utcStart => "2020-08-01T05:00:00Z", + utcEnd => "2020-08-01T08:00:00Z", + busyStatus => 'unavailable', + event => undef, + }, { + utcStart => "2020-08-01T09:00:00Z", + utcEnd => "2020-08-01T10:30:00Z", + busyStatus => 'unavailable', + event => undef, + }, { + utcStart => "2020-08-01T12:00:00Z", + utcEnd => "2020-08-01T13:30:00Z", + busyStatus => 'confirmed', + event => undef, + }, { + utcStart => "2020-08-01T12:00:00Z", + utcEnd => "2020-08-01T12:30:00Z", + busyStatus => 'unavailable', + event => undef, + }, { + utcStart => "2020-08-01T12:00:00Z", + utcEnd => "2020-08-01T13:00:00Z", + busyStatus => 'tentative', + event => undef, + }], $res->[1][1]{list}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_getavailability_showdetails b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_getavailability_showdetails new file mode 100644 index 0000000000..22a6cca156 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_getavailability_showdetails @@ -0,0 +1,149 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarprincipal_getavailability_showdetails + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + invisible => { + name => 'invisibleCalendar', + includeInAvailability => 'none', + }, + }, + }, 'R1'], + ]); + my $invisibleCalendarId = $res->[0][1]{created}{invisible}{id}; + $self->assert_not_null($invisibleCalendarId); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event1uid', + title => "event1", + start => "2020-07-01T09:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + status => 'confirmed', + recurrenceRules => [{ + frequency => 'weekly', + count => 12, + }], + recurrenceOverrides => { + "2020-08-26T09:00:00" => { + start => "2020-08-26T13:00:00", + }, + }, + }, + event2 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event2uid', + title => "event2", + start => "2020-08-07T11:00:00", + timeZone => "Europe/Vienna", + duration => "PT3H", + }, + event3 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event3uid', + title => "event3", + start => "2020-08-10T13:00:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + freeBusyStatus => 'free', + }, + event4 => { + calendarIds => { + Default => JSON::true, + }, + uid => 'event4uid', + title => "event4", + start => "2020-08-12T09:30:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + status => 'tentative', + }, + event5 => { + calendarIds => { + $invisibleCalendarId => JSON::true, + }, + uid => 'event5uid', + title => "event5", + start => "2020-08-14T15:30:00", + timeZone => "Europe/Vienna", + duration => "PT1H", + }, + }, + }, 'R1'], + ['Principal/getAvailability', { + id => 'cassandane', + utcStart => '2020-08-01T00:00:00Z', + utcEnd => '2020-09-01T00:00:00Z', + showDetails => JSON::true, + eventProperties => ['start', 'title'], + }, 'R2'], + ]); + $self->assert_num_equals(5, scalar keys %{$res->[0][1]{created}}); + + $self->assert_deep_equals([{ + utcStart => "2020-08-05T07:00:00Z", + utcEnd => "2020-08-05T08:00:00Z", + busyStatus => 'confirmed', + event => { + start => "2020-08-05T09:00:00", + title => 'event1', + }, + }, { + utcStart => "2020-08-07T09:00:00Z", + utcEnd => "2020-08-07T12:00:00Z", + busyStatus => 'unavailable', + event => { + start => "2020-08-07T11:00:00", + title => 'event2', + }, + }, { + utcStart => "2020-08-12T07:00:00Z", + utcEnd => "2020-08-12T08:00:00Z", + busyStatus => 'confirmed', + event => { + start => "2020-08-12T09:00:00", + title => 'event1', + }, + }, { + utcStart => "2020-08-12T07:30:00Z", + utcEnd => "2020-08-12T08:30:00Z", + busyStatus => 'tentative', + event => { + start => "2020-08-12T09:30:00", + title => 'event4', + }, + }, { + utcStart => "2020-08-19T07:00:00Z", + utcEnd => "2020-08-19T08:00:00Z", + busyStatus => 'confirmed', + event => { + start => "2020-08-19T09:00:00", + title => 'event1', + }, + }, { + utcStart => "2020-08-26T11:00:00Z", + utcEnd => "2020-08-26T12:00:00Z", + busyStatus => 'confirmed', + event => { + start => "2020-08-26T13:00:00", + title => 'event1', + }, + }], $res->[1][1]{list}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_query b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_query new file mode 100644 index 0000000000..35bd463b1a --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_query @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarprincipal_query + :min_version_3_3 :needs_component_jmap :JMAPExtensions :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + + $self->{instance}->create_user("manifold"); + # Trigger creation of default calendar + my $http = $self->{instance}->get_service("http"); + Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + $admintalk->setacl("user.manifold", "cassandane", "lr") or die; + $admintalk->setacl("user.manifold.#calendars", "cassandane", "lr") or die; + $admintalk->setacl("user.manifold.#calendars.Default", "cassandane" => 'lr') or die; + + xlog "test filters"; + my $res = $jmap->CallMethods([ + ['Principal/query', { + filter => { + name => 'Test', + email => 'cassandane@example.com', + text => 'User', + }, + }, 'R1'], + ]); + $self->assert_deep_equals(['cassandane'], $res->[0][1]{ids}); + + xlog "test sorting"; + $res = $jmap->CallMethods([ + ['Principal/query', { + sort => [{ + property => 'id', + }], + }, 'R1'], + ['Principal/query', { + sort => [{ + property => 'id', + isAscending => JSON::false, + }], + }, 'R2'], + ]); + $self->assert_deep_equals(['cassandane', 'manifold'], $res->[0][1]{ids}); + $self->assert_deep_equals(['manifold', 'cassandane'], $res->[1][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_querychanges b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_querychanges new file mode 100644 index 0000000000..2f11613ca3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_querychanges @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarprincipal_querychanges + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Principal/queryChanges', { + sinceQueryState => 'whatever', + }, 'R1'] + ]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_set b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_set new file mode 100644 index 0000000000..ccd73b92d4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarprincipal_set @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarprincipal_set + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Principal/set', { + create => { + principal1 => { + timeZone => 'America/New_York', + }, + }, + update => { + cassandane => { + name => 'Xyz', + }, + principal2 => { + timeZone => 'Europe/Berlin', + }, + }, + destroy => ['principal3'], + }, 'R1'] + ]); + + $self->assert_str_equals('forbidden', + $res->[0][1]{notCreated}{principal1}{type}); + $self->assert_str_equals('forbidden', + $res->[0][1]{notUpdated}{principal2}{type}); + $self->assert_str_equals('forbidden', + $res->[0][1]{notDestroyed}{principal3}{type}); + + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{cassandane}{type}); + $self->assert_deep_equals(['name'], + $res->[0][1]{notUpdated}{cassandane}{properties}); + + $res = $jmap->CallMethods([ + ['Principal/get', { + ids => ['cassandane'], + properties => ['timeZone'], + }, 'R1'], + ['Principal/set', { + update => { + cassandane => { + timeZone => 'Australia/Melbourne', + }, + }, + }, 'R2'], + ['Principal/get', { + ids => ['cassandane'], + properties => ['timeZone'], + }, 'R3'] + ]); + $self->assert_null($res->[0][1]{list}[0]{timeZone}); + $self->assert_deep_equals({}, $res->[1][1]{updated}{cassandane}); + $self->assert_str_equals('Australia/Melbourne', + $res->[2][1]{list}[0]{timeZone}); + + $self->assert_not_null($res->[1][1]{oldState}); + $self->assert_not_null($res->[1][1]{newState}); + $self->assert_str_not_equals($res->[1][1]{oldState}, $res->[1][1]{newState}); + + my $oldState = $res->[1][1]{oldState}; + $res = $jmap->CallMethods([ + ['Principal/set', { + ifInState => $oldState, + update => { + cassandane => { + timeZone => 'Asia/Tokyo', + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_changes b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_changes new file mode 100644 index 0000000000..dc5a9177b4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_changes @@ -0,0 +1,135 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarsharenotification_changes + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Create sharee + my $admin = $self->{adminstore}->get_client(); + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $manjmap->CallMethods([ + ['ShareNotification/get', { + }, 'R1'] + ]); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + + $res = $manjmap->CallMethods([ + ['ShareNotification/changes', { + sinceState => $state, + }, 'R1'] + ]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + "shareWith/manifold" => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + }, + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + $res = $manjmap->CallMethods([ + ['ShareNotification/changes', { + sinceState => $state, + }, 'R1'] + ]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + my $notifId = $res->[0][1]{created}[0]; + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]{newState}; + + $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + 1 => { + name => 'someCalendar', + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + }, + }, + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{created}{1}); + + $res = $manjmap->CallMethods([ + ['ShareNotification/set', { + destroy => [$notifId], + }, "R1"] + ]); + $self->assert_deep_equals([$notifId], $res->[0][1]{destroyed}); + + $res = $manjmap->CallMethods([ + ['ShareNotification/changes', { + sinceState => $state, + maxChanges => 1, + }, 'R1'] + ]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]{newState}; + + $res = $manjmap->CallMethods([ + ['ShareNotification/changes', { + sinceState => $state, + }, 'R1'] + ]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([$notifId], $res->[0][1]{destroyed}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_get b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_get new file mode 100644 index 0000000000..d40c9d6ebe --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_get @@ -0,0 +1,105 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarsharenotification_get + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Create sharee + my $admin = $self->{adminstore}->get_client(); + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $manjmap->CallMethods([ + ['ShareNotification/get', { + }, 'R1'] + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + my $state = $res->[0][1]{state}; + + $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + name => 'myname', + "shareWith/manifold" => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + }, + }, + }, + }, "R1"] + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + $res = $manjmap->CallMethods([ + ['ShareNotification/get', { + }, 'R1'] + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + + my $notif = $res->[0][1]{list}[0]; + # Assert dynamically generated values. + my $notifId = $notif->{id}; + $self->assert_not_null($notifId); + delete($notif->{id}); + $self->assert_not_null($notif->{created}); + delete($notif->{created}); + # Assert remaining values. + $self->assert_deep_equals({ + changedBy => { + name => 'Test User', + email => 'cassandane@example.com', + principalId => 'cassandane', + }, + objectType => 'Calendar', + objectAccountId => 'cassandane', + objectId => 'Default', + oldRights => undef, + newRights => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + mayWriteAll => JSON::false, + mayRSVP => JSON::false, + mayDelete => JSON::false, + mayAdmin => JSON::false, + mayUpdatePrivate => JSON::false, + mayWriteOwn => JSON::false, + }, + }, $notif); + + $res = $manjmap->CallMethods([ + ['ShareNotification/get', { + ids => [$notifId, 'nope'], + }, 'R1'] + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_deep_equals(['nope'], $res->[0][1]{notFound}); + $self->assert_str_not_equals($state, $res->[0][1]{state}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_query b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_query new file mode 100644 index 0000000000..3fe14c3730 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_query @@ -0,0 +1,132 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarsharenotification_query + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Create sharee + my $admin = $self->{adminstore}->get_client(); + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + A => { + name => 'A', + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + }, + }, + }, + }, + }, 'R1'] + ]); + $self->assert_not_null($res->[0][1]{created}{A}); + + sleep(1); + + $res = $jmap->CallMethods([ + ['Calendar/set', { + create => { + B => { + name => 'B', + shareWith => { + manifold => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + }, + }, + }, + }, + }, 'R1'] + ]); + $self->assert_not_null($res->[0][1]{created}{B}); + + $res = $manjmap->CallMethods([ + ['ShareNotification/query', { + }, 'R1'], + ['ShareNotification/query', { + sort => [{ + property => 'created', + isAscending => JSON::false, + }], + }, 'R2'], + ['ShareNotification/get', { + properties => ['created'], + }, 'R3'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[1][1]{ids}}); + + my %notifTimestamps = map { $_->{id} => $_->{created} } @{$res->[2][1]{list}}; + $self->assert($notifTimestamps{$res->[0][1]{ids}[0]} lt + $notifTimestamps{$res->[0][1]{ids}[1]}); + $self->assert($notifTimestamps{$res->[1][1]{ids}[0]} gt + $notifTimestamps{$res->[1][1]{ids}[1]}); + + my $notifIdT1 = $res->[0][1]{ids}[0]; + my $timestampT1 = $notifTimestamps{$notifIdT1}; + + my $notifIdT2 = $res->[0][1]{ids}[1]; + my $timestampT2 = $notifTimestamps{$notifIdT2}; + + $res = $manjmap->CallMethods([ + ['ShareNotification/query', { + filter => { + before => $timestampT2, + }, + }, 'R1'], + ['ShareNotification/query', { + filter => { + after => $timestampT2, + }, + }, 'R2'], + ['ShareNotification/query', { + position => 1, + }, 'R3'], + ['ShareNotification/query', { + anchor => $notifIdT2, + anchorOffset => -1, + limit => 1, + }, 'R3'], + ]); + $self->assert_deep_equals([$notifIdT1], $res->[0][1]{ids}); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_deep_equals([$notifIdT2], $res->[1][1]{ids}); + $self->assert_num_equals(1, $res->[1][1]{total}); + $self->assert_deep_equals([$notifIdT2], $res->[2][1]{ids}); + $self->assert_num_equals(2, $res->[2][1]{total}); + $self->assert_deep_equals([$notifIdT1], $res->[3][1]{ids}); + $self->assert_num_equals(2, $res->[2][1]{total}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_querychanges b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_querychanges new file mode 100644 index 0000000000..52681e53bd --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_querychanges @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarsharenotification_querychanges + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['ShareNotification/queryChanges', { + sinceQueryState => 'whatever', + }, 'R1'] + ]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_set b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_set new file mode 100644 index 0000000000..c950590aa7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/calendarsharenotification_set @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_calendarsharenotification_set + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Create sharee + my $admin = $self->{adminstore}->get_client(); + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $mantalk = Net::CalDAVTalk->new( + user => "manifold", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $jmap->CallMethods([ + ['Calendar/set', { + update => { + Default => { + name => 'myname', + "shareWith/manifold" => { + mayReadFreeBusy => JSON::true, + mayReadItems => JSON::true, + }, + }, + }, + }, "R1"] + ]); + $self->assert(exists $res->[0][1]{updated}{Default}); + + $res = $manjmap->CallMethods([ + ['ShareNotification/get', { + }, 'R1'] + ]); + my $notif = $res->[0][1]{list}[0]; + my $notifId = $notif->{id}; + $self->assert_not_null($notifId); + delete($notif->{id}); + + $res = $manjmap->CallMethods([ + ['ShareNotification/set', { + create => { + newnotif => $notif, + }, + update => { + $notifId => $notif, + }, + }, "R1"] + ]); + $self->assert_str_equals('forbidden', + $res->[0][1]{notCreated}{newnotif}{type}); + $self->assert_str_equals('forbidden', + $res->[0][1]{notUpdated}{$notifId}{type}); + $self->assert_not_null($res->[0][1]{newState}); + my $state = $res->[0][1]{newState}; + + $res = $manjmap->CallMethods([ + ['ShareNotification/set', { + destroy => [$notifId, 'unknownId'], + }, "R1"] + ]); + $self->assert_deep_equals([$notifId], $res->[0][1]{destroyed}); + $self->assert_str_equals('notFound', + $res->[0][1]{notDestroyed}{unknownId}{type}); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + + $res = $manjmap->CallMethods([ + ['ShareNotification/get', { + ids => [$notifId], + }, 'R1'] + ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_deep_equals([$notifId], $res->[0][1]{notFound}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/crasher20191227 b/cassandane/tiny-tests/JMAPCalendars/crasher20191227 new file mode 100644 index 0000000000..b9e8c2cee1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/crasher20191227 @@ -0,0 +1,66 @@ +#!perl +use Cassandane::Tiny; + +sub test_crasher20191227 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event1 => { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "title", + "description"=> "description", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT2H", + "timeZone" => "Europe/London", + "showWithoutTime"=> JSON::false, + recurrenceRules => [{ + frequency => 'weekly', + }], + recurrenceOverrides => { + '2015-11-14T09:00:00' => { + title => 'foo', + }, + }, + "freeBusyStatus"=> "busy", + "status" => "confirmed", + "alerts" => { + alert1 => { + trigger => { + '@type' => 'OffsetTrigger', + relativeTo => "start", + offset => "-PT5M", + }, + acknowledged => "2015-11-07T08:57:00Z", + action => "email", + }, + }, + "useDefaultAlerts" => JSON::true, + }, + }, + }, 'R1'] + ]); + my $eventId = $res->[0][1]{created}{event1}{id}; + $self->assert_not_null($eventId); + + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'recurrenceOverrides/2015-11-14T09:00:00' => { + alerts => undef, + } + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/itip_ignore_invalid_timezone b/cassandane/tiny-tests/JMAPCalendars/itip_ignore_invalid_timezone new file mode 100644 index 0000000000..79a24af51c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/itip_ignore_invalid_timezone @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; +use Data::UUID; + +sub test_itip_ignore_invalid_timezone + :min_version_3_9 :needs_component_jmap :needs_component_sieve +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< 'UTC', # can handle as 'Etc/UTC' + wantAdded => 1, + }, { + tzid => 'Europe/Vienna', # already is IANA name + wantAdded => 1, + }, { + tzid => 'Pacific Standard Time', # can map to IANA name + wantAdded => 1, + }, { + tzid => 'Foo', # can't map to IANA, reject + wantAdded => 0, + }); + + for my $tc (@testCases) { + + my $uid = (Data::UUID->new)->create_str(); + + xlog $self, "Send iMIP message with invalid VTIMEZONE having name $tc->{tzid}"; + my $imip = <<"EOF"; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <$uid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VTIMEZONE +TZID:$tc->{tzid} +X-LIC-LOCATION:$tc->{tzid} +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uid +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=$tc->{tzid}:20210923T153000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER:MAILTO:organizer\@example.net +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [encode_eventid($uid)] + }, 'R1'] + ]); + + if ($tc->{wantAdded}) { + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + } else { + $self->assert_num_equals(1, scalar @{$res->[0][1]{notFound}}); + } + } +} diff --git a/cassandane/tiny-tests/JMAPCalendars/itip_remove_privacy_property b/cassandane/tiny-tests/JMAPCalendars/itip_remove_privacy_property new file mode 100644 index 0000000000..b1427795c3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/itip_remove_privacy_property @@ -0,0 +1,151 @@ +#!perl +use Cassandane::Tiny; + +sub test_itip_remove_privacy_property + :min_version_3_7 :needs_component_jmap :needs_component_sieve +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <7e017102-0caf-490a-bbdf-422141d34e75@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:7e017102-0caf-490a-bbdf-422141d34e75 +TRANSP:OPAQUE +SUMMARY:test +X-JMAP-PRIVACY:PRIVATE +DTSTART;TZID=American/New_York:20210923T153000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +RRULE:FREQ=DAILY;COUNT=3 +SEQUENCE:0 +ORGANIZER:MAILTO:organizer@example.net +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;X-JMAP-ID=cassandane:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog $self, "Assert privacy property is reset to default"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'participants', 'privacy', 'x-href'], + }, 'R1'], + ]); + my $eventId = $res->[0][1]{list}[0]{id}; + $self->assert_null($res->[0][1]{list}[0]->{privacy}); + my $xhref = $res->[0][1]{list}[0]{'x-href'}; + $self->assert_not_null($xhref); + + xlog $self, "Assert privacy property is not present in iCalendar"; + my $caldavResponse = $caldav->Request('GET', $xhref); + $self->assert($caldavResponse->{content} !~ /X-JMAP-PRIVACY:PRIVATE/); + + xlog $self, "Clear notifications"; + $self->{instance}->getnotify(); + + xlog $self, "Set privacy to 'secret' and accept invitation"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'participants/cassandane/participationStatus' => 'accepted', + 'privacy' => 'secret', + }, + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog $self, "Assert that iMIP message is sent"; + my $data = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($imip); + + xlog $self, "Assert privacy property is not present in iTIP REPLY"; + my $msg = decode_json($imip->{MESSAGE}); + $self->assert($msg->{ical} !~ /X-JMAP-PRIVACY:PRIVATE/); + + xlog $self, "Organizer updates invite with recurrence override"; + + my $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Sally Sender +To: Cassandane +Message-ID: <7e017102-0caf-490a-bbdf-422141d34e75@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:7e017102-0caf-490a-bbdf-422141d34e75 +TRANSP:OPAQUE +SUMMARY:test +X-JMAP-PRIVACY:PRIVATE +DTSTART;TZID=American/New_York:20210923T153000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +RRULE:FREQ=DAILY;COUNT=3 +SEQUENCE:0 +ORGANIZER:MAILTO:organizer@example.net +ATTENDEE;RSVP=TRUE;PARTSTAT=ACCEPTED;X-JMAP-ID=cassandane:MAILTO:cassandane@example.com +END:VEVENT +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:7e017102-0caf-490a-bbdf-422141d34e75 +TRANSP:OPAQUE +SUMMARY:test +X-JMAP-PRIVACY:PRIVATE +RECURRENCE-ID;TZID=American/New_York:20210924T153000 +DTSTART;TZID=American/New_York:20210924T163000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +RRULE:FREQ=DAILY;COUNT=3 +SEQUENCE:0 +ORGANIZER:MAILTO:organizer@example.net +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;X-JMAP-ID=cassandane:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog $self, "Assert user-set privacy property is preserved"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [$eventId], + properties => ['id', 'privacy', 'recurrenceOverrides'], + }, 'R1'], + ]); + $self->assert_str_equals('secret', $res->[0][1]{list}[0]->{privacy}); + + xlog $self, "Assert privacy property on override matches main event"; + $self->assert_null($res->[0][1]{list}[0]->{ + recurrenceOverrides}{'2021-09-24T15:30:00'}{privacy}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/itip_request_tzid_change b/cassandane/tiny-tests/JMAPCalendars/itip_request_tzid_change new file mode 100644 index 0000000000..1152976aff --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/itip_request_tzid_change @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_itip_request_tzid_change + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $uid = 'event1uid', + + xlog "Clear notifications"; + $self->{instance}->getnotify(); + + xlog "Create scheduled event"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + event => { + calendarIds => { + 'Default' => JSON::true, + }, + '@type' => 'Event', + uid => $uid, + title => 'event', + start => '2021-01-01T15:30:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + recurrenceRules => [{ + frequency => 'daily', + count => 30, + }], + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + cassandane => { + roles => { + attendee => JSON::true, + }, + sendTo => { + imip => 'mailto:someone@example.com', + }, + participationStatus => 'needs-action', + expectReply => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{event}{id}; + $self->assert_not_null($eventId); + + xlog "Assert that iTIP notification is sent"; + my $data = $self->{instance}->getnotify(); + my ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + my $notif_payload = decode_json($notif->{MESSAGE}); + my $expect_id = encode_eventid($uid); + $self->assert_str_equals($expect_id, $notif_payload->{id}); + $self->assert_str_equals('REQUEST', $notif_payload->{method}); + + xlog "Clear notifications"; + $self->{instance}->getnotify(); + + xlog "Update time zone of scheduled event"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + timeZone => 'America/New_York', + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert that iTIP notification is sent"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + $notif_payload = decode_json($notif->{MESSAGE}); + $self->assert_str_equals($expect_id, $notif_payload->{id}); + $self->assert_str_equals('REQUEST', $notif_payload->{method}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/itip_rsvp_organizer_change b/cassandane/tiny-tests/JMAPCalendars/itip_rsvp_organizer_change new file mode 100644 index 0000000000..2306114936 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/itip_rsvp_organizer_change @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_itip_rsvp_organizer_change + :min_version_3_7 :needs_component_jmap :needs_component_sieve +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $uid = '7e017102-0caf-490a-bbdf-422141d34e75'; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <7e017102-0caf-490a-bbdf-422141d34e75@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:7e017102-0caf-490a-bbdf-422141d34e75 +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User;X-JMAP-ID=organizerA:MAILTO:organizerA@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane:MAILTO:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog "Clear notifications"; + $self->{instance}->getnotify(); + + xlog "Accept invitation in JMAP"; + my $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + properties => ['id', 'participants'], + }, 'R1'], + ]); + my $eventId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($eventId); + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + 'participants/cassandane/participationStatus' => 'accepted', + }, + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + + xlog "Assert that iTIP notification is sent"; + my $data = $self->{instance}->getnotify(); + my ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + my $notif_payload = decode_json($notif->{MESSAGE}); + my $expect_id = encode_eventid($uid); + $self->assert_str_equals($expect_id, $notif_payload->{id}); + $self->assert_str_equals('REPLY', $notif_payload->{method}); + + xlog "Clear notifications"; + $self->{instance}->getnotify(); + + xlog "Change organizer in JMAP"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + replyTo => { + imip => 'mailto:organizerB@example.net', + }, + 'participants/organizerA' => undef, + }, + } + }, 'R1'], + ['CalendarEvent/get', { + ids => [ $eventId ], + properties => [ 'replyTo' ], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $self->assert_deep_equals({ + imip => 'mailto:organizerB@example.net', + }, $res->[1][1]{list}[0]{replyTo}); + + xlog "Assert that iTIP notification is sent"; + $data = $self->{instance}->getnotify(); + ($notif) = grep { $_->{METHOD} eq 'imip' } @$data; + $self->assert_not_null($notif); + $notif_payload = decode_json($notif->{MESSAGE}); + $self->assert_str_equals($expect_id, $notif_payload->{id}); + $self->assert_str_equals('REPLY', $notif_payload->{method}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/misc_creationids b/cassandane/tiny-tests/JMAPCalendars/misc_creationids new file mode 100644 index 0000000000..5870f6d9f2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/misc_creationids @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_creationids + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create and get calendar and event"; + my $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "c1" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1, + }}}, 'R1'], + ['CalendarEvent/set', { create => { "e1" => { + calendarIds => { + '#c1' => JSON::true, + }, + "title" => "bar", + "description" => "description", + "freeBusyStatus" => "busy", + "showWithoutTime" => JSON::true, + "start" => "2015-10-06T00:00:00", + }}}, "R2"], + ['CalendarEvent/get', {ids => ["#e1"]}, "R3"], + ['Calendar/get', {ids => ["#c1"]}, "R4"], + ]); + my $event = $res->[2][1]{list}[0]; + $self->assert_str_equals("bar", $event->{title}); + + my $calendar = $res->[3][1]{list}[0]; + $self->assert_str_equals("foo", $calendar->{name}); + + $self->assert_deep_equals({$calendar->{id} => JSON::true}, $event->{calendarIds}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/misc_timezone_expansion b/cassandane/tiny-tests/JMAPCalendars/misc_timezone_expansion new file mode 100644 index 0000000000..d2515f5e55 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/misc_timezone_expansion @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_timezone_expansion + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "uid" => "58ADE31-custom-UID", + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT5M", + "sequence"=> 42, + "timeZone"=> "Europe/Vienna", + "showWithoutTime"=> JSON::false, + "locale" => "en", + "status" => "tentative", + "description"=> "", + "freeBusyStatus"=> "busy", + "privacy" => "secret", + "participants" => undef, + "alerts"=> undef, + "recurrenceRules" => [{ + frequency => "weekly", + }], + }; + + my $ret = $self->createandget_event($event); + + my $CalDAV = $self->{caldav}; + $ret = $CalDAV->Request('GET', $ret->{"x-href"}, undef, 'CalDAV-Timezones' => 'T'); + + # Assert that we get two RRULEs, one for DST and one for leaving DST + $ret->{content} =~ /.*(BEGIN:VTIMEZONE\r\n.*END:VTIMEZONE).*/s; + my $rrulecount = () = $1 =~ /RRULE/gi; + $self->assert_num_equals(2, $rrulecount); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/no_shared_calendar b/cassandane/tiny-tests/JMAPCalendars/no_shared_calendar new file mode 100644 index 0000000000..d239e7a920 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/no_shared_calendar @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_no_shared_calendar + :min_version_3_5 :needs_component_jmap :JMAPExtensions :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create other user"; + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user.other'); + $admintalk->setacl('user.other', admin => 'lrswipkxtecdan') or die; + $admintalk->setacl('user.other', other => 'lrswipkxtecdn') or die; + + my $service = $self->{instance}->get_service("http"); + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $res = $otherJmap->CallMethods([ + ['Calendar/get', { + properties => ['id'], + }, 'R1'], + ]); + my $otherCalendarId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($otherCalendarId); + $admintalk->setacl('user.other.#calendars', cassandane => 'lr') or die; + + $res = $jmap->ua->get($jmap->uri(), { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }); + $self->assert_str_equals('200', $res->{status}); + my $session = eval { decode_json($res->{content}) }; + my $capabilities = $session->{accounts}{other}{accountCapabilities}; + $self->assert_not_null($capabilities->{'https://cyrusimap.org/ns/jmap/calendars'}); + + $res = $jmap->CallMethods([ + ['Calendar/get', { + accountId => 'other', + }, 'R1'], + ['Calendar/changes', { + accountId => 'other', + sinceState => '0', + }, 'R2'], + ['Calendar/set', { + accountId => 'other', + create => { + calendar1 => { + name => 'test', + }, + }, + update => { + $otherCalendarId => { + name => 'test', + }, + }, + destroy => [$otherCalendarId], + }, 'R3'], + ['CalendarEvent/get', { + accountId => 'other', + }, 'R4'], + ]); + $self->assert_deep_equals([], $res->[0][1]{list}); + $self->assert_deep_equals([], $res->[1][1]{created}); + $self->assert_deep_equals([], $res->[1][1]{updated}); + $self->assert_deep_equals([], $res->[1][1]{destroyed}); + $self->assert_str_equals('accountReadOnly', + $res->[2][1]{notCreated}{calendar1}{type}); + $self->assert_str_equals('notFound', + $res->[2][1]{notUpdated}{$otherCalendarId}{type}); + $self->assert_str_equals('notFound', + $res->[2][1]{notDestroyed}{$otherCalendarId}{type}); + $self->assert_deep_equals([], $res->[3][1]{list}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/participantidentity_changes b/cassandane/tiny-tests/JMAPCalendars/participantidentity_changes new file mode 100644 index 0000000000..6fa6af328f --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/participantidentity_changes @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_participantidentity_changes + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['ParticipantIdentity/changes', { + sinceState => '0', + }, 'R1'] + ]); + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/participantidentity_get b/cassandane/tiny-tests/JMAPCalendars/participantidentity_get new file mode 100644 index 0000000000..2c2832d1a4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/participantidentity_get @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_participantidentity_get + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $res = $jmap->CallMethods([ + ['ParticipantIdentity/get', { + }, 'R1'], + ]); + + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_deep_equals({ + imip => 'mailto:cassandane@example.com', + }, $res->[0][1]{list}[0]{sendTo}); + my $partId1 = $res->[0][1]{list}[0]{id}; + + $caldav->Request( + 'PROPPATCH', + '', + x('D:propertyupdate', $caldav->NS(), + x('D:set', + x('D:prop', + x('C:calendar-user-address-set', + x('D:href', 'mailto:cassandane@example.com'), + x('D:href', 'mailto:foo@local'), + ) + ) + ) + ) + ); + + $res = $jmap->CallMethods([ + ['ParticipantIdentity/get', { + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + + $res = $jmap->CallMethods([ + ['ParticipantIdentity/get', { + ids => [$partId1, 'nope'], + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_deep_equals({ + imip => 'mailto:cassandane@example.com', + }, $res->[0][1]{list}[0]{sendTo}); + $self->assert_deep_equals(['nope'], $res->[0][1]{notFound}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/participantidentity_set b/cassandane/tiny-tests/JMAPCalendars/participantidentity_set new file mode 100644 index 0000000000..d3ed1af41e --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/participantidentity_set @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_participantidentity_set + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['ParticipantIdentity/set', { + create => { + partid1 => { + sendTo => { + imip => 'mailto:foo@local', + }, + }, + }, + update => { + partid2 => { + name => 'bar', + }, + }, + destroy => ['partid3'], + }, 'R1'] + ]); + + $self->assert_str_equals('forbidden', + $res->[0][1]{notCreated}{partid1}{type}); + $self->assert_str_equals('forbidden', + $res->[0][1]{notUpdated}{partid2}{type}); + $self->assert_str_equals('forbidden', + $res->[0][1]{notDestroyed}{partid3}{type}); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_binary_itip_caldav b/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_binary_itip_caldav new file mode 100644 index 0000000000..9b850c7b92 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_binary_itip_caldav @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_rewrite_webdav_attachment_binary_itip_caldav + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150806T234327Z +ORGANIZER:cassandane@example.com +ATTENDEE:attendee@local +UID:123456789 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=Australia/Melbourne:20160831T153000 +DURATION:PT1H +DTSTAMP:20150806T234327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $rawIcal, + 'Content-Type' => 'text/calendar'); + my $eventHref = '/dav/calendars/user/cassandane/Default/test.ics'; + + xlog "Add attachment via CalDAV"; + my $url = $caldav->request_url($eventHref) . '?action=attachment-add'; + my $res = $caldav->ua->post($url, { + headers => { + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment;filename=test', + 'Prefer' => 'return=representation', + 'Authorization' => $caldav->auth_header(), + }, + content => 'someblob', + }); + $self->assert_str_equals('201', $res->{status}); + + # Now we have a blob "someblob" (c29tZWJsb2I=) in managed attachments. + + xlog "Create event via CalDAV"; + $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150806T234327Z +ORGANIZER:cassandane@example.com +ATTENDEE;PARTSTAT=DECLINED:attendee@local +UID:123456789 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=Australia/Melbourne:20160831T153000 +DURATION:PT1H +DTSTAMP:20150806T234327Z +ATTACH;VALUE=BINARY:c29tZWJsb2I= +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $rawIcal, + 'Schedule-Sender-Address' => 'attendee@local', + 'Content-Type' => 'text/calendar'); + + my $caldavResponse = $caldav->Request('GET', $eventHref); + my $ical = Data::ICal->new(data => $caldavResponse->{content}); + my %entries = map { $_->ical_entry_type() => $_ } @{$ical->entries()}; + my $event = $entries{'VEVENT'}; + $self->assert_not_null($event); + + xlog "Assert BINARY ATTACH got rewritten to managed attachment URI"; + my $attach = $event->property('ATTACH'); + $self->assert_num_equals(1, scalar @{$attach}); + $self->assert_not_null($attach->[0]->parameters()->{'MANAGED-ID'}); + $self->assert_null($attach->[0]->parameters()->{VALUE}); + my $webdavAttachURI = + $self->{instance}->{config}->get('webdav_attachments_baseurl') . + '/dav/calendars/user/cassandane/Attachments/'; + $self->assert($attach->[0]->value() =~ /^$webdavAttachURI.+/); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_url_itip_caldav b/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_url_itip_caldav new file mode 100644 index 0000000000..8615a12a32 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_url_itip_caldav @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_rewrite_webdav_attachment_url_itip_caldav + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150806T234327Z +ORGANIZER:cassandane@example.com +ATTENDEE:attendee@local +UID:123456789 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=Australia/Melbourne:20160831T153000 +DURATION:PT1H +DTSTAMP:20150806T234327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $rawIcal, + 'Content-Type' => 'text/calendar'); + my $eventHref = '/dav/calendars/user/cassandane/Default/test.ics'; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog "Add attachment via CalDAV"; + my $url = $caldav->request_url($eventHref) . '?action=attachment-add'; + my $res = $caldav->ua->post($url, { + headers => { + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment;filename=test', + 'Prefer' => 'return=representation', + 'Authorization' => $caldav->auth_header(), + }, + content => 'someblob', + }); + $self->assert_str_equals('201', $res->{status}); + + $self->assert_rewrite_webdav_attachment_url_itip($eventHref); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_url_itip_jmap b/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_url_itip_jmap new file mode 100644 index 0000000000..e16788531d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/rewrite_webdav_attachment_url_itip_jmap @@ -0,0 +1,66 @@ +#!perl +use Cassandane::Tiny; + +sub test_rewrite_webdav_attachment_url_itip_jmap + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Upload blob via JMAP"; + my $res = $jmap->Upload('someblob', "application/octet-stream"); + my $blobId = $res->{blobId}; + $self->assert_not_null($blobId); + + # clean notification cache + $self->{instance}->getnotify(); + + xlog "Create event with a Link.blobId"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + create => { + 1 => { + uid => 'eventuid1local', + calendarIds => { + Default => JSON::true, + }, + title => "event1", + start => "2019-12-10T23:30:00", + duration => "PT1H", + timeZone => "Australia/Melbourne", + links => { + link1 => { + rel => 'enclosure', + blobId => $blobId, + contentType => 'image/jpg', + }, + }, + replyTo => { + imip => 'mailto:cassandane@example.com', + }, + participants => { + part1 => { + '@type' => 'Participant', + sendTo => { + imip => 'mailto:part1@local', + }, + roles => { + attendee => JSON::true, + }, + }, + }, + start => '2021-01-01T01:00:00', + timeZone => 'Europe/Berlin', + duration => 'PT1H', + }, + }, + }, 'R1'], + ]); + my $eventId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($eventId); + my $eventHref = $res->[0][1]{created}{1}{'x-href'}; + $self->assert_not_null($eventHref); + + $self->assert_rewrite_webdav_attachment_url_itip($eventHref); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/rscale_in_jmap_hidden_in_caldav b/cassandane/tiny-tests/JMAPCalendars/rscale_in_jmap_hidden_in_caldav new file mode 100644 index 0000000000..fc700e73e4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/rscale_in_jmap_hidden_in_caldav @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_rscale_in_jmap_hidden_in_caldav + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admin = $self->{adminstore}->get_client(); + + my $calid = "Default"; + my $event = { + calendarIds => { + $calid => JSON::true, + }, + "title"=> "foo", + "start"=> "2015-11-07T09:00:00", + "duration"=> "PT1H", + "timeZone" => "Europe/London", + "locations" => { + "loc1" => { + "timeZone" => "Europe/Berlin", + "relativeTo" => "end", + }, + }, + "showWithoutTime"=> JSON::false, + "description"=> "", + "freeBusyStatus"=> "busy", + "prodId" => "foo", + "recurrenceRules" => [{ + "frequency" => "monthly", + count => 12, + }], + }; + + my $ret = $self->createandget_event($event); + $self->assert_normalized_event_equals($event, $ret); + my $eventId = $ret->{id}; + + # Overide one event, this causes rscale to get added + my $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $eventId => { + "recurrenceOverrides" => { + "2015-12-07T09:00:00" => { + excluded => JSON::true, + } + }, + }, + }, + }, 'R1'], + ['CalendarEvent/get', { + ids => [$eventId], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$eventId}); + $ret = $res->[1][1]{list}[0]; + $self->assert_not_null($ret); + + # rscale should now be in jmap + $self->assert_deep_equals([ + { + '@type' => 'RecurrenceRule', + count => 12, + firstDayOfWeek => 'mo', + frequency => 'monthly', + interval => 1, + rscale => 'gregorian', + skip => 'omit' + }], + $ret->{recurrenceRules}, + ); + + # FIXME Net-CalDAV talk needs to update + # Make sure we have no rscale through caldav, most clients can't + # handle it + my $events = $caldav->GetEvents("$calid"); + $self->assert_deep_equals( + { + count => 12, + frequency => 'monthly', + }, + $events->[0]->{recurrenceRule}, + ); +} diff --git a/cassandane/tiny-tests/JMAPCalendars/session_capability_isrfc b/cassandane/tiny-tests/JMAPCalendars/session_capability_isrfc new file mode 100644 index 0000000000..c4ed90fcf0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCalendars/session_capability_isrfc @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_session_capability_isrfc + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + my $session = eval { decode_json($RawResponse->{content}) }; + $self->assert_not_null($session); + + $self->assert_deep_equals( + $session->{capabilities}{'https://cyrusimap.org/ns/jmap/calendars'}, + { isRFC => JSON::true }); +} diff --git a/cassandane/tiny-tests/JMAPContacts/account_get_capabilities b/cassandane/tiny-tests/JMAPContacts/account_get_capabilities new file mode 100644 index 0000000000..fab5765717 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/account_get_capabilities @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_account_get_capabilities + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $http = $self->{instance}->get_service("http"); + my $admintalk = $self->{adminstore}->get_client(); + + xlog "Get session object"; + + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + my $session = eval { decode_json($RawResponse->{content}) }; + $self->assert_not_null($session); + + my $capas = $session->{accounts}{cassandane}{accountCapabilities}{'urn:ietf:params:jmap:contacts'}; + $self->assert_not_null($capas); + + $self->assert_equals(JSON::true, $capas->{mayCreateAddressBook}); + +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_changes b/cassandane/tiny-tests/JMAPContacts/addressbook_changes new file mode 100644 index 0000000000..a3a7006401 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_changes @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_changes + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + xlog $self, "get current state"; + my $res = $jmap->CallMethods([['AddressBook/get', {ids => undef}, "R1"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "create addressbooks"; + my $id1 = $carddav->NewAddressBook("foo"); + my $id2 = $carddav->NewAddressBook("bar"); + + xlog $self, "get addressbook updates"; + $res = $jmap->CallMethods([['AddressBook/changes', { + "sinceState" => $state + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $state = $res->[0][1]{newState}; + + xlog $self, "get addressbook updates without changes"; + $res = $jmap->CallMethods([['AddressBook/changes', { + "sinceState" => $state + }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals(0, scalar @{$res->[0][1]{destroyed}}); + + my $basepath = $carddav->{basepath}; + xlog $self, "update name of addressbook $id1, destroy addressbook $id2"; + $carddav->UpdateAddressBook($basepath . "/" . $id1, name => "foo (upd)"); + $carddav->DeleteAddressBook($basepath . "/" . $id2); + + xlog $self, "get addressbook updates"; + $res = $jmap->CallMethods([['AddressBook/changes', { + "sinceState" => $state + }, "R1"]]); + $self->assert_str_equals("AddressBook/changes", $res->[0][0]); + $self->assert_str_equals("R1", $res->[0][2]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id1, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{destroyed}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get empty addressbook updates"; + $res = $jmap->CallMethods([['AddressBook/changes', { + "sinceState" => $state + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_get b/cassandane/tiny-tests/JMAPContacts/addressbook_get new file mode 100644 index 0000000000..718c7497ff --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_get @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_get + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + my $id = $carddav->NewAddressBook("bookname"); + my $unknownId = "foo"; + + xlog $self, "get existing addressbook"; + my $res = $jmap->CallMethods([['AddressBook/get', {ids => [$id]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('AddressBook/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{list}})); + $self->assert_str_equals($id, $res->[0][1]{list}[0]{id}); + + xlog $self, "get existing addressbook with select properties"; + $res = $jmap->CallMethods([['AddressBook/get', { ids => [$id], properties => ["name"] }, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{list}})); + $self->assert_str_equals($id, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals("bookname", $res->[0][1]{list}[0]{name}); + $self->assert_null($res->[0][1]{list}[0]{isSubscribed}); + + xlog $self, "get unknown addressbook"; + $res = $jmap->CallMethods([['AddressBook/get', {ids => [$unknownId]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(0, scalar(@{$res->[0][1]{list}})); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{notFound}})); + $self->assert_str_equals($unknownId, $res->[0][1]{notFound}[0]); + + xlog $self, "get all addressbooks"; + $res = $jmap->CallMethods([['AddressBook/get', {ids => undef}, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(2, scalar(@{$res->[0][1]{list}})); + $res = $jmap->CallMethods([['AddressBook/get', {}, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(2, scalar(@{$res->[0][1]{list}})); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_get_default b/cassandane/tiny-tests/JMAPContacts/addressbook_get_default new file mode 100644 index 0000000000..c2e50f2670 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_get_default @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_get_default + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # XXX - A previous CardDAV test might have created the default + # addressbook already. To make this test self-sufficient, we need + # to create a test user just for this test. How? + xlog $self, "get default addressbook"; + my $res = $jmap->CallMethods([['AddressBook/get', {ids => ["Default"]}, "R1"]]); + $self->assert_str_equals("Default", $res->[0][1]{list}[0]{id}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_get_shared b/cassandane/tiny-tests/JMAPContacts/addressbook_get_shared new file mode 100644 index 0000000000..b76928168d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_get_shared @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_get_shared + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + xlog $self, "create addressbook"; + my $ABookId = $mantalk->NewAddressBook('Manifold Addressbook'); + $self->assert_not_null($ABookId); + + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.$ABookId", "cassandane" => 'lr') or die; + + xlog $self, "get addressbook"; + my $res = $jmap->CallMethods([['AddressBook/get', {accountId => 'manifold'}, "R1"]]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert_str_equals("Manifold Addressbook", $res->[0][1]{list}[0]->{name}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]->{myRights}->{mayRead}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]->{myRights}{mayWrite}); + my $id = $res->[0][1]{list}[0]->{id}; + + xlog $self, "refetch addressbook"; + $res = $jmap->CallMethods([['AddressBook/get', {accountId => 'manifold', ids => [$id]}, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{list}[0]->{id}); + + xlog $self, "create another shared addressbook"; + my $ABookId2 = $mantalk->NewAddressBook('Manifold Addressbook 2'); + $self->assert_not_null($ABookId2); + $admintalk->setacl("user.manifold.#addressbooks.$ABookId2", "cassandane" => 'lr') or die; + + xlog $self, "remove access rights to addressbook"; + $admintalk->setacl("user.manifold.#addressbooks.$ABookId", "cassandane" => '') or die; + + xlog $self, "refetch addressbook (should fail)"; + $res = $jmap->CallMethods([['AddressBook/get', {accountId => 'manifold', ids => [$id]}, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{notFound}[0]); + + xlog $self, "remove access rights to all shared addressbooks"; + $admintalk->setacl("user.manifold.#addressbooks.$ABookId2", "cassandane" => '') or die; + + xlog $self, "refetch addressbook (should fail)"; + $res = $jmap->CallMethods([['AddressBook/get', {accountId => 'manifold', ids => [$id]}, "R1"]]); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("accountNotFound", $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set b/cassandane/tiny-tests/JMAPContacts/addressbook_set new file mode 100644 index 0000000000..47f4beeee2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create addressbook"; + my $res = $jmap->CallMethods([ + ['AddressBook/set', { create => { "1" => { + name => "foo" + }}}, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('AddressBook/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{created}); + + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get addressbook $id"; + $res = $jmap->CallMethods([['AddressBook/get', {ids => [$id]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{list}})); + $self->assert_str_equals($id, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals('foo', $res->[0][1]{list}[0]{name}); + + xlog $self, "update addressbook $id"; + $res = $jmap->CallMethods([ + ['AddressBook/set', {update => {"$id" => { + name => "bar" + }}}, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "get addressbook $id"; + $res = $jmap->CallMethods([['AddressBook/get', {ids => [$id]}, "R1"]]); + $self->assert_str_equals('bar', $res->[0][1]{list}[0]{name}); + + xlog $self, "destroy addressbook $id"; + $res = $jmap->CallMethods([['AddressBook/set', {destroy => ["$id"]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{destroyed}); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); + + xlog $self, "get addressbook $id"; + $res = $jmap->CallMethods([['AddressBook/get', {ids => [$id]}, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_badname b/cassandane/tiny-tests/JMAPContacts/addressbook_set_badname new file mode 100644 index 0000000000..286f4fc596 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_badname @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_badname + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create addressbook with excessively long name"; + # Exceed the maximum allowed 256 byte length by 1. + my $badname = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum tincidunt risus quis urna aliquam sollicitudin. Pellentesque aliquet nisl ut neque viverra pellentesque. Donec tincidunt eros at ante malesuada porta. Nam sapien arcu, vehicula non posuere."; + + my $res = $jmap->CallMethods([ + ['AddressBook/set', { create => { "1" => { + name => $badname + }}}, "R1"] + ]); + $self->assert_not_null($res); + my $errType = $res->[0][1]{notCreated}{"1"}{type}; + my $errProp = $res->[0][1]{notCreated}{"1"}{properties}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals(["name"], $errProp); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_destroy_contents b/cassandane/tiny-tests/JMAPContacts/addressbook_set_destroy_contents new file mode 100644 index 0000000000..6716d6cd71 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_destroy_contents @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_destroy_contents + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + xlog "Create addressbook and contact"; + my $abookId = $carddav->NewAddressBook("foo"); + + my $card = <new_fromstring($card); + my $cardId = basename($carddav->NewContact($abookId, $VCard), '.vcf'); + + xlog "Destroy addressbook (with and without onDestroyRemoveContents)"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + destroy => [$abookId], + }, 'R1'], +# XXX Change to ContactCard/get once implemented + ['Contact/get', { + ids => [$cardId], + properties => ['id'], + }, 'R2'], + ['AddressBook/set', { + destroy => [$abookId], + onDestroyRemoveContents => JSON::true, + }, 'R3'], + ['Contact/get', { + ids => [$cardId], + properties => ['id'], + }, 'R2'], + ]); + $self->assert_str_equals('addressBookHasContents', + $res->[0][1]{notDestroyed}{$abookId}{type}); + $self->assert_str_equals($cardId, $res->[1][1]{list}[0]{id}); + $self->assert_deep_equals([$abookId], $res->[2][1]{destroyed}); + $self->assert_deep_equals([$cardId], $res->[3][1]{notFound}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_destroy_default b/cassandane/tiny-tests/JMAPContacts/addressbook_set_destroy_default new file mode 100644 index 0000000000..57f4201f42 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_destroy_default @@ -0,0 +1,22 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_destroy_default + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + my $defaultId = 'Default'; + + xlog "Attempt to destroy default addressbook"; + my $res = $jmap->CallMethods([ + ['AddressBook/set', { + destroy => [$defaultId], + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', + $res->[0][1]{notDestroyed}{$defaultId}{type}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_error b/cassandane/tiny-tests/JMAPContacts/addressbook_set_error new file mode 100644 index 0000000000..0de1dd2bf8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_error @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_error + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create addressbook with missing mandatory attributes"; + my $res = $jmap->CallMethods([ + ['AddressBook/set', { create => { "1" => {}}}, "R1"] + ]); + $self->assert_not_null($res); + my $errType = $res->[0][1]{notCreated}{"1"}{type}; + my $errProp = $res->[0][1]{notCreated}{"1"}{properties}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals([ "name" ], $errProp); + + xlog $self, "create addressbook with invalid optional attributes"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { create => { "1" => { + name => "foo", + myRights => { + mayRead => \0, mayWrite => \0, + mayDelete => \0 + } + }}}, "R1"] + ]); + $errType = $res->[0][1]{notCreated}{"1"}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals(['myRights'], $res->[0][1]{notCreated}{"1"}{properties}); + + xlog $self, "update unknown addressbook"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { update => { "unknown" => { + name => "foo" + }}}, "R1"] + ]); + $errType = $res->[0][1]{notUpdated}{"unknown"}{type}; + $self->assert_str_equals("notFound", $errType); + + xlog $self, "create addressbook"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { create => { "1" => { + name => "foo" + }}}, "R1"] + ]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "update addressbook with immutable optional attributes"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { update => { $id => { + myRights => { + mayRead => \0, mayWrite => \0, + mayDelete => \0 + } + }}}, "R1"] + ]); + $errType = $res->[0][1]{notUpdated}{$id}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals(['myRights'], $res->[0][1]{notUpdated}{$id}{properties}); + + xlog $self, "destroy unknown addressbook"; + $res = $jmap->CallMethods([ + ['AddressBook/set', {destroy => ["unknown"]}, "R1"] + ]); + $errType = $res->[0][1]{notDestroyed}{"unknown"}{type}; + $self->assert_str_equals("notFound", $errType); + + xlog $self, "destroy addressbook $id"; + $res = $jmap->CallMethods([['AddressBook/set', {destroy => ["$id"]}, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_issubscribed b/cassandane/tiny-tests/JMAPContacts/addressbook_set_issubscribed new file mode 100644 index 0000000000..3085f459e1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_issubscribed @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_issubscribed + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Create addressbook + my $res = $jmap->CallMethods([ + ['AddressBook/set', { + create => { + '1' => { + name => 'A' + } + }, + }, 'R1'], + ['AddressBook/get', { + ids => ['#1'], + properties => ['isSubscribed'] + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{created}{1}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{isSubscribed}); + my $id = $res->[0][1]{created}{"1"}{id}; + + # Can't unsubscribe own addressbooks + $res = $jmap->CallMethods([ + ['AddressBook/set', + { update => { + $id => { + isSubscribed => JSON::false, + } + } + }, "R1"], + ['AddressBook/get', { + ids => [$id], + properties => ['isSubscribed'] + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{notUpdated}{$id}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{isSubscribed}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_issubscribed_shared b/cassandane/tiny-tests/JMAPContacts/addressbook_set_issubscribed_shared new file mode 100644 index 0000000000..9a2ff4e1b6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_issubscribed_shared @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_issubscribed_shared + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.other"); + + $admintalk->setacl("user.other", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.other", other => 'lrswipkxtecdn'); + + xlog $self, "create and share default addressbook"; + my $othertalk = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + $admintalk->setacl('user.other.#addressbooks.Default', "cassandane" => 'lr') or die; + + # Get addressbook + my $res = $jmap->CallMethods([ + ['AddressBook/get', { + accountId => 'other', + properties => ['isSubscribed'] + }, 'R!'], + ]); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]{isSubscribed}); + my $id = $res->[0][1]{list}[0]{id}; + + # Toggle isSubscribed on read-only shared addressbook + $res = $jmap->CallMethods([ + ['AddressBook/set', { + accountId => 'other', + update => { + $id => { + isSubscribed => JSON::true, + } + } + }, "R1"], + ['AddressBook/get', { + accountId => 'other', + ids => [$id], + properties => ['isSubscribed'] + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$id}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{isSubscribed}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_shared b/cassandane/tiny-tests/JMAPContacts/addressbook_set_shared new file mode 100644 index 0000000000..f689e19396 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_shared @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_shared + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + + my $service = $self->{instance}->get_service("http"); + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + # Call CardDAV once to create manifold's addressbook home #addressbooks + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "share addressbook home read-only to user"; + $admintalk->setacl("user.manifold.#addressbooks", cassandane => 'lr') or die; + + xlog $self, "create addressbook (should fail)"; + my $res = $jmap->CallMethods([ + ['AddressBook/set', { + accountId => 'manifold', + create => { "1" => { + name => "foo" + }}}, "R1"] + ]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert_str_equals("accountReadOnly", $res->[0][1]{notCreated}{1}{type}); + + xlog $self, "share addressbook home read-writable to user"; + $admintalk->setacl("user.manifold.#addressbooks", cassandane => 'lrswipkxtecdn') or die; + + xlog $self, "create addressbook"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + accountId => 'manifold', + create => { "1" => { + name => "foo" + }}}, "R1"] + ]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + my $AddressBookId = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($AddressBookId); + + xlog $self, "share addressbook read-only to user"; + $admintalk->setacl("user.manifold.#addressbooks.$AddressBookId", "cassandane" => 'lr') or die; + + xlog $self, "update addressbook"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + accountId => 'manifold', + update => {$AddressBookId => { + name => "bar" + }}}, "R1"] + ]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert(exists $res->[0][1]{updated}{$AddressBookId}); + + xlog $self, "destroy addressbook $AddressBookId (should fail)"; + $res = $jmap->CallMethods([['AddressBook/set', {accountId => 'manifold', destroy => [$AddressBookId]}, "R1"]]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert_str_equals("accountReadOnly", $res->[0][1]{notDestroyed}{$AddressBookId}{type}); + + xlog $self, "share read-writable to user"; + $admintalk->setacl("user.manifold.#addressbooks.$AddressBookId", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "destroy addressbook $AddressBookId"; + $res = $jmap->CallMethods([['AddressBook/set', {accountId => 'manifold', destroy => [$AddressBookId]}, "R1"]]); + $self->assert_str_equals('manifold', $res->[0][1]{accountId}); + $self->assert_str_equals($AddressBookId, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_sharewith b/cassandane/tiny-tests/JMAPContacts/addressbook_set_sharewith new file mode 100644 index 0000000000..a731bff023 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_sharewith @@ -0,0 +1,142 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_sharewith + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.master"); + + my $mastalk = Net::CardDAVTalk->new( + user => "master", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.master", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.master", master => 'lrswipkxtecdn'); + + xlog $self, "create addressbook"; + my $AddressBookId = $mastalk->NewAddressBook('Shared', name => 'Shared AddressBook'); + $self->assert_not_null($AddressBookId); + + xlog $self, "share to user with permission to share"; + $admintalk->setacl("user.master.#addressbooks.$AddressBookId", "cassandane" => 'lrswipkxtecdan') or die; + + xlog $self, "create third account"; + $admintalk->create("user.manifold"); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + + xlog $self, "and a forth"; + $admintalk->create("user.paraphrase"); + + $admintalk->setacl("user.paraphrase", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.paraphrase", paraphrase => 'lrswipkxtecdn'); + + # Call CardDAV once to create manifold's addressbook home #addressbooks + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + # Call CardDAV once to create paraphrase's addressbook home #addressbooks + my $partalk = Net::CardDAVTalk->new( + user => "paraphrase", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "sharee gives third user access to shared addressbook"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + accountId => 'master', + update => { "$AddressBookId" => { + "shareWith/manifold" => { + mayRead => JSON::true + }, + "shareWith/paraphrase" => { + mayRead => JSON::true, + mayWrite => JSON::true, + }, + }}}, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('AddressBook/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "check ACL on JMAP upload folder"; + $acl = $admintalk->getacl("user.master.#jmap"); + %map = @$acl; + $self->assert_str_equals('lrswitedn', $map{cassandane}); + $self->assert_str_equals('lrw', $map{manifold}); + $self->assert_str_equals('lrswitedn', $map{paraphrase}); + + xlog $self, "Update sharewith just for manifold"; + $jmap->CallMethods([ + ['AddressBook/set', { + accountId => 'master', + update => { "$AddressBookId" => { + "shareWith/manifold/mayWrite" => JSON::true, + }}}, "R1"] + ]); + + xlog $self, "check ACL on JMAP upload folder"; + $acl = $admintalk->getacl("user.master.#jmap"); + %map = @$acl; + $self->assert_str_equals('lrswitedn', $map{cassandane}); + $self->assert_str_equals('lrswitedn', $map{manifold}); + $self->assert_str_equals('lrswitedn', $map{paraphrase}); + + xlog $self, "Remove the access for paraphrase"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + accountId => 'master', + update => { "$AddressBookId" => { + "shareWith/paraphrase" => undef, + }}}, "R1"] + ]); + + $self->assert_not_null($res); + $self->assert_str_equals('AddressBook/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "check ACL"; + $acl = $admintalk->getacl("user.master.#addressbooks.$AddressBookId"); + %map = @$acl; + $self->assert_str_equals('lrswipkxtecdan', $map{cassandane}); + $self->assert_str_equals('lrswitedn', $map{manifold}); + $self->assert_null($map{paraphrase}); + + xlog $self, "check ACL on JMAP upload folder"; + $acl = $admintalk->getacl("user.master.#jmap"); + %map = @$acl; + $self->assert_str_equals('lrswitedn', $map{cassandane}); + $self->assert_str_equals('lrswitedn', $map{manifold}); + $self->assert_null($map{paraphrase}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_sharewith_acl b/cassandane/tiny-tests/JMAPContacts/addressbook_set_sharewith_acl new file mode 100644 index 0000000000..b7032097ba --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_sharewith_acl @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_sharewith_acl + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.test1"); + + my $res = $jmap->CallMethods([ + ['AddressBook/set', { + create => { + '1' => { + name => 'A', + } + }, + }, 'R1'], + ]); + my $addressbookId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($addressbookId); + + my @testCases = ({ + rights => { + mayRead => JSON::true, + }, + acl => 'lrw', + }, { + rights => { + mayWrite => JSON::true, + }, + acl => 'switedn', + wantRights => { + mayWrite => JSON::true, + }, + }, { + rights => { + mayAdmin => JSON::true, + }, + acl => 'wa', + }, { + rights => { + mayDelete => JSON::true, + }, + acl => 'wxc', + }); + + foreach(@testCases) { + + xlog "Run test for acl $_->{acl}"; + + $res = $jmap->CallMethods([ + ['AddressBook/set', { + update => { + $addressbookId => { + shareWith => { + test1 => $_->{rights}, + }, + }, + }, + }, 'R1'], + ['AddressBook/get', { + ids => [$addressbookId], + properties => ['shareWith'], + }, 'R2'], + ]); + + $_->{wantRights} ||= $_->{rights}; + + my %mergedrights = (( + mayRead => JSON::false, + mayWrite => JSON::false, + mayAdmin => JSON::false, + mayDelete => JSON::false, + ), %{$_->{wantRights}}); + + $self->assert_deep_equals(\%mergedrights, + $res->[1][1]{list}[0]{shareWith}{test1}); + my %acl = @{$admin->getacl("user.cassandane.#addressbooks.$addressbookId")}; + $self->assert_str_equals($_->{acl}, $acl{test1}); + } +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_state b/cassandane/tiny-tests/JMAPContacts/addressbook_set_state new file mode 100644 index 0000000000..1966dfb572 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_state @@ -0,0 +1,100 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_state + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with invalid state token"; + my $res = $jmap->CallMethods([ + ['AddressBook/set', { + ifInState => "badstate", + create => { "1" => { name => "foo" }} + }, "R1"] + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "create with wrong state token"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + ifInState => "987654321", + create => { "1" => { name => "foo" }} + }, "R1"] + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "create addressbook"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { create => { "1" => { + name => "foo" + }}}, "R1"] + ]); + $self->assert_not_null($res); + + my $id = $res->[0][1]{created}{"1"}{id}; + my $state = $res->[0][1]{newState}; + + xlog $self, "update addressbook $id with current state"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + ifInState => $state, + update => {"$id" => {name => "bar"}} + }, "R1"] + ]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "setAddressBook noops must keep state"; + $res = $jmap->CallMethods([ + ['AddressBook/set', {}, "R1"], + ['AddressBook/set', {}, "R2"], + ['AddressBook/set', {}, "R3"] + ]); + $self->assert_not_null($res->[0][1]{newState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + + xlog $self, "update addressbook $id with expired state"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + ifInState => $oldState, + update => {"$id" => {name => "baz"}} + }, "R1"] + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals("stateMismatch", $res->[0][1]{type}); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get addressbook $id to make sure state didn't change"; + $res = $jmap->CallMethods([['AddressBook/get', {ids => [$id]}, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]{state}); + $self->assert_str_equals('bar', $res->[0][1]{list}[0]{name}); + + xlog $self, "destroy addressbook $id with expired state"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + ifInState => $oldState, + destroy => [$id] + }, "R1"] + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals("stateMismatch", $res->[0][1]{type}); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "destroy addressbook $id with current state"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { + ifInState => $state, + destroy => [$id] + }, "R1"] + ]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/addressbook_set_unknown_addressbookright b/cassandane/tiny-tests/JMAPContacts/addressbook_set_unknown_addressbookright new file mode 100644 index 0000000000..5c6529f17d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/addressbook_set_unknown_addressbookright @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_addressbook_set_unknown_addressbookright + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['AddressBook/set', { + update => { + Default => { + shareWith => { + sharee => { + unknownAddressBookRight => JSON::true, + }, + }, + }, + }, + }, 'R1'], + ]); + + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{Default}{type}); + + $self->assert_deep_equals(['shareWith/sharee/unknownAddressBookRight'], + $res->[0][1]{notUpdated}{Default}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_changes b/cassandane/tiny-tests/JMAPContacts/card_changes new file mode 100644 index 0000000000..866c99fbb6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_changes @@ -0,0 +1,133 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_changes + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + +# Update to ContactCard/[get|set] once implemented + xlog $self, "get contacts"; + my $res = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $state, + addressbookId => "Default", + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + + xlog $self, "create contact 1"; + $res = $jmap->CallMethods([['Contact/set', {create => {"1" => {firstName => "first", lastName => "last"}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "create contact 2"; + $res = $jmap->CallMethods([['Contact/set', {create => {"2" => {firstName => "second", lastName => "prev"}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get contact updates (since last change)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get contact updates (in bulk)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $oldState + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get contact updates from initial state (maxChanges=1)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $oldState, + maxChanges => 1 + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + my $interimState = $res->[0][1]{newState}; + + xlog $self, "get contact updates from interim state (maxChanges=10)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $interimState, + maxChanges => 10 + }, "R2"]]); + $self->assert_str_equals($interimState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "destroy contact 1, update contact 2"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$id1], + update => {$id2 => {firstName => "foo"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id2, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy contact 2"; + $res = $jmap->CallMethods([['Contact/set', {destroy => [$id2]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_changes_shared b/cassandane/tiny-tests/JMAPContacts/card_changes_shared new file mode 100644 index 0000000000..012d5dfc51 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_changes_shared @@ -0,0 +1,170 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_changes_shared + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrswipkxtecdn') or die; + +# Update to ContactCard/[get|set] once implemented + xlog $self, "get contacts"; + my $res = $jmap->CallMethods([['Contact/get', { accountId => 'manifold' }, "R2"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + + xlog $self, "create contact 1"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => {"1" => {firstName => "first", lastName => "last"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "create contact 2"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => {"2" => {firstName => "second", lastName => "prev"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get contact updates (since last change)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get contact updates (in bulk)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $oldState + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get contact updates from initial state (maxChanges=1)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $oldState, + maxChanges => 1 + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + my $interimState = $res->[0][1]{newState}; + + xlog $self, "get contact updates from interim state (maxChanges=10)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $interimState, + maxChanges => 10 + }, "R2"]]); + $self->assert_str_equals($interimState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "destroy contact 1, update contact 2"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + destroy => [$id1], + update => {$id2 => {firstName => "foo"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id2, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy contact 2"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + destroy => [$id2] + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_copy b/cassandane/tiny-tests/JMAPContacts/card_copy new file mode 100644 index 0000000000..99544f3ab6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_copy @@ -0,0 +1,229 @@ +#!perl +use Cassandane::Tiny; + +# +# Needs to be updated once the mechanism for setting media gets sorted out +# + +sub test_card_copy + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared accounts"; + $admintalk->create("user.other"); + $admintalk->create("user.other2"); + $admintalk->create("user.other3"); + +# my $carddav = Net::CardDAVTalk->new( +# user => 'cassandane', +# password => 'pass', +# host => $service->host(), +# port => $service->port(), +# scheme => 'http', +# url => '/', +# expandurl => 1, +# ); + + my $othercarddav = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $other2carddav = Net::CardDAVTalk->new( + user => "other2", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $other3carddav = Net::CardDAVTalk->new( + user => "other3", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "share addressbooks"; + $admintalk->setacl("user.other.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + $admintalk->setacl("user.other2.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + $admintalk->setacl("user.other3.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + + # avatar + xlog $self, "upload avatar"; + my $data = "some photo"; + my $res = $jmap->Upload($data, "image/jpeg"); + my $blobid = $res->{blobId}; + + my $card = { + "addressBookId" => "Default", + name => { full => "foo bar" }, +# "avatar" => { +# "blobId" => $blobid, +# "size" => 10, +# "type" => "image/jpeg", +# "name" => JSON::null +# } + }; + + xlog $self, "create card"; + $res = $jmap->CallMethods([['ContactCard/set',{ + create => {"1" => $card}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + my $cardId = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "copy card $cardId w/o changes"; + $res = $jmap->CallMethods([['ContactCard/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $cardId, + addressBookId => "Default", + }, + }, + }, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + my $copiedCardId = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['ContactCard/get', { + accountId => 'other', + ids => [$copiedCardId], + }, 'R1'], + ['ContactCard/get', { + accountId => undef, + ids => [$cardId], + }, 'R2'], + ]); + $self->assert_str_equals('foo bar', $res->[0][1]{list}[0]{name}{full}); +# my $blob = $jmap->Download({ accept => 'image/jpeg' }, +# 'other', $res->[0][1]{list}[0]{avatar}{blobId}); +# $self->assert_str_equals('image/jpeg', +# $blob->{headers}->{'content-type'}); +# $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); +# $self->assert_equals($data, $blob->{content}); + + $self->assert_str_equals('foo bar', $res->[1][1]{list}[0]{name}{full}); +# $blob = $jmap->Download({ accept => 'image/jpeg' }, +# 'cassandane', $res->[1][1]{list}[0]{avatar}{blobId}); +# $self->assert_str_equals('image/jpeg', +# $blob->{headers}->{'content-type'}); +# $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); +# $self->assert_equals($data, $blob->{content}); + + xlog $self, "move card $cardId with changes"; + $res = $jmap->CallMethods([['ContactCard/copy', { + fromAccountId => 'cassandane', + accountId => 'other2', + create => { + 1 => { + id => $cardId, + addressBookId => "Default", +# avatar => JSON::null, + nicknames => { n1 => { '@type' => 'Nickname', name => "xxxxx" } } + }, + } + }, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + $copiedCardId = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['ContactCard/get', { + accountId => 'other2', + ids => [$copiedCardId], + }, 'R1'], + ['ContactCard/get', { + accountId => undef, + ids => [$cardId], + }, 'R2'], + ]); + $self->assert_str_equals('foo bar', $res->[0][1]{list}[0]{name}{full}); + $self->assert_str_equals('xxxxx', $res->[0][1]{list}[0]{nicknames}{n1}{name}); +# $self->assert_null($res->[0][1]{list}[0]{avatar}); +return; + my $other3Jmap = Mail::JMAPTalk->new( + user => 'other3', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $other3Jmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + # avatar + xlog $self, "upload avatar for other3"; + $data = "some other photo"; + $res = $other3Jmap->Upload($data, "image/jpeg"); + $blobid = $res->{blobId}; + + $admintalk->setacl("user.other3.#jmap", + "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "move card $cardId with different avatar"; + $res = $jmap->CallMethods([['Contact/copy', { + fromAccountId => 'cassandane', + accountId => 'other3', + create => { + 1 => { + id => $cardId, + addressbookId => "Default", + avatar => { + blobId => "$blobid", + size => 16, + type => "image/jpeg", + name => JSON::null + } + }, + }, + onSuccessDestroyOriginal => JSON::true, + }, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + $copiedCardId = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['Contact/get', { + accountId => 'other3', + ids => [$copiedCardId], + }, 'R1'], + ['Contact/get', { + accountId => undef, + ids => [$cardId], + }, 'R2'], + ]); + $self->assert_str_equals('foo', $res->[0][1]{list}[0]{firstName}); + $blob = $jmap->Download({ accept => 'image/jpeg' }, + 'other3', $res->[0][1]{list}[0]{avatar}{blobId}); + $self->assert_str_equals('image/jpeg', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_equals($data, $blob->{content}); + + $self->assert_str_equals($cardId, $res->[1][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_copy_state b/cassandane/tiny-tests/JMAPContacts/card_copy_state new file mode 100644 index 0000000000..6a242a4191 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_copy_state @@ -0,0 +1,91 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_copy_state + :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.other"); + + my $othercarddav = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "share addressbook"; + $admintalk->setacl("user.other.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + + my $card = { + "addressBookId" => "Default", + name => { full => "foo bar" }, + }; + + xlog $self, "create card"; + $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => {"1" => $card} + }, "R1"], + ['ContactCard/get', { + accountId => 'other', + ids => ['foo'], # Just fetching current state for 'other' + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}); + my $cardId = $res->[0][1]{created}{"1"}{id}; + my $fromState = $res->[0][1]->{newState}; + $self->assert_not_null($fromState); + my $state = $res->[1][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "move card"; + $res = $jmap->CallMethods([ + ['ContactCard/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + ifFromInState => $fromState, + ifInState => $state, + create => { + 1 => { + id => $cardId, + addressBookId => "Default", + }, + }, + onSuccessDestroyOriginal => JSON::true, + destroyFromIfInState => $fromState, + }, "R1"], + ['ContactCard/get', { + accountId => 'other', + ids => ['#1'], + properties => ['name'], + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}); + my $oldState = $res->[0][1]->{oldState}; + $self->assert_str_equals($oldState, $state); + my $newState = $res->[0][1]->{newState}; + $self->assert_not_null($newState); + $self->assert_str_equals('ContactCard/set', $res->[1][0]); + $self->assert_str_equals($cardId, $res->[1][1]{destroyed}[0]); + $self->assert_str_equals('foo bar', $res->[2][1]{list}[0]{name}{full}); + + # Is the blobId downloadable? + my $blob = $jmap->Download({ accept => 'text/vcard' }, + 'other', + $res->[0][1]{created}{"1"}{'cyrusimap.org:blobId'}); + $self->assert_str_equals('text/vcard; version=4.0', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_matches(qr/\r\nFN:foo bar\r\n/, $blob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_get_apple_countrycode b/cassandane/tiny-tests/JMAPContacts/card_get_apple_countrycode new file mode 100644 index 0000000000..eced91c190 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_get_apple_countrycode @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_get_apple_countrycode + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/test.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + properties => ['addresses'] + }, 'R1'] + ]); + + $self->assert_str_equals('us', $res->[0][1]{list}[0]{addresses}{A1}{countryCode}); + $self->assert_str_equals('xyz', $res->[0][1]{list}[0]{addresses}{A1}{label}); + $self->assert_str_equals('de', $res->[0][1]{list}[0]{addresses}{A2}{countryCode}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_get_disable_uri_as_blobid b/cassandane/tiny-tests/JMAPContacts/card_get_disable_uri_as_blobid new file mode 100644 index 0000000000..386fc084e8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_get_disable_uri_as_blobid @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_get_disable_uri_as_blobid + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + my $vcard = <<'EOF'; +BEGIN:VCARD +VERSION:4.0 +UID:85b5d651-1cde-43d9-901d-7059d67807f9 +FN:Jane +PHOTO;PROP-ID=photo1:data:image/x-bla;base64,YmxhYmxhCg= +CREATED:20230823T133154Z +END:VCARD +EOF + $vcard =~ s/\r?\n/\r\n/gs; + $carddav->Request('PUT', 'Default/test.vcf', $vcard, + 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + properties => ['media'], + }, 'R1'], + ['ContactCard/get', { + properties => ['media'], + disableUriAsBlobId => JSON::true, + }, 'R2'], + ]); + + $self->assert_not_null($res->[0][1]{list}[0]{media}{photo1}{blobId}); + $self->assert_null($res->[0][1]{list}[0]{media}{photo1}{uri}); + + $self->assert_null($res->[1][1]{list}[0]{media}{photo1}{blobId}); + $self->assert_not_null($res->[1][1]{list}[0]{media}{photo1}{uri}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_get_localizations b/cassandane/tiny-tests/JMAPContacts/card_get_localizations new file mode 100644 index 0000000000..acc8dc4605 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_get_localizations @@ -0,0 +1,139 @@ +#!perl +use Cassandane::Tiny; +use utf8; + +sub test_card_get_localizations + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + # Sample card from RFC 6350 + # Second N suffix removed due to vparse bug + # PROP-IDs added so we can easily compare the results + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/test.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + my $want_jscard = { + '@type' => 'Card', + version => '1.0', + addressBookId => 'Default', + 'cyrusimap.org:href' => $carddav->fullpath() . $href, + id => $id, + uid => $id, + kind => 'individual', + language => 'es', + vCardProps => [ + [ 'version', {}, 'text', '4.0' ] + ], + name => { + full => 'Gabriel García Márquez', + components => [ + { 'kind' => 'surname', 'value' => 'Márquez' }, + { 'kind' => 'given', 'value' => 'Gabriel' }, + { 'kind' => 'given2', 'value' => 'García' }, + ], + }, + titles => { + t1 => { + 'name' => 'Novelista' + } + }, + speakToAs => { + grammaticalGender => 'neuter', + pronouns => { + k19 => { + pronouns => 'él' + } + } + }, + addresses => { + addr1 => { + components => [ + { kind => 'locality', value =>'Tokio' } + ] + } + }, + localizations => { + en => { + 'titles/t1/name' => 'Novelist', + 'speakToAs/grammaticalGender' => 'masculine', + 'addresses/addr1/components/0/value' => 'Tokyo' + }, + fr => { + 'titles/t1/name' => 'Écrivain', + 'speakToAs/pronouns/k19/pronouns' => 'il' + }, + de => { + 'speakToAs/pronouns/k19/pronouns' => 'er' + }, + it => { + 'speakToAs/pronouns/k19/pronouns' => 'lui' + }, + jp => { + 'name/full' => 'ガブリエル・ガルシア・マルケス', + 'name/components/0/value' => 'マルケス', + 'name/components/1/value' => 'ガブリエル', + 'name/components/2/value' => 'ガルシア', + 'addresses/addr1/components/0/value' => '東京' + } + } + }; + + + my $have_jscard = $res->[0][1]{list}[0]; + + # Delete generated fields + delete $have_jscard->{blobId}; + delete $have_jscard->{'cyrusimap.org:blobId'}; + delete $have_jscard->{'cyrusimap.org:size'}; + + # Normalize and compare cards + normalize_jscard($want_jscard); + normalize_jscard($have_jscard); + + $self->assert_deep_equals($want_jscard, $have_jscard); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_get_ordered_phonetics b/cassandane/tiny-tests/JMAPContacts/card_get_ordered_phonetics new file mode 100644 index 0000000000..2f3c1e5ca7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_get_ordered_phonetics @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; +use utf8; + +sub test_card_get_ordered_phonetics + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/test.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + my $want_jscard = { + '@type' => 'Card', + version => '1.0', + addressBookId => 'Default', + 'cyrusimap.org:href' => $carddav->fullpath() . $href, + id => $id, + uid => $id, + created => '2023-08-24T14:36:19Z', + vCardProps => [ + [ 'version', {}, 'text', '4.0' ] + ], + name => { + phoneticSystem => 'ipa', + isOrdered => JSON::true, + components => [ + { kind => 'given', value => 'John' , phonetic => "/d͡ʒɑn/" }, + { kind => 'surname', value => 'Smith', phonetic => "/smɪθ/" } + ] + }, + }; + + my $have_jscard = $res->[0][1]{list}[0]; + + # Delete generated fields + delete $have_jscard->{blobId}; + delete $have_jscard->{'cyrusimap.org:blobId'}; + delete $have_jscard->{'cyrusimap.org:size'}; + + # Normalize and compare cards + normalize_jscard($want_jscard); + normalize_jscard($have_jscard); + +warn Dumper($want_jscard); +warn Dumper($have_jscard); + $self->assert_deep_equals($want_jscard, $have_jscard); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_get_phonetics b/cassandane/tiny-tests/JMAPContacts/card_get_phonetics new file mode 100644 index 0000000000..04cc8a49c0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_get_phonetics @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; +use utf8; + +sub test_card_get_phonetics + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/test.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + my $want_jscard = { + '@type' => 'Card', + version => '1.0', + addressBookId => 'Default', + 'cyrusimap.org:href' => $carddav->fullpath() . $href, + id => $id, + uid => $id, + language => 'zho-Hant', + vCardProps => [ + [ 'version', {}, 'text', '4.0' ] + ], + name => { + full => '孫中山文逸仙', + components => [ + { kind => 'surname', value => '孫' }, + { kind => 'given', value => '中山' }, + { kind => 'given2', value => '文' }, + { kind => 'given2', value => '逸仙' } + ] + }, + localizations => { + yue => { + "name/phoneticSystem" => "jyut", + "name/phoneticScript" => "Latn", + "name/components/0/phonetic" => "syun1", + "name/components/1/phonetic" => "zung1saan1", + "name/components/2/phonetic" => "man4", + "name/components/3/phonetic" => "jat6sin1" + } + } + }; + + my $have_jscard = $res->[0][1]{list}[0]; + + # Delete generated fields + delete $have_jscard->{blobId}; + delete $have_jscard->{'cyrusimap.org:blobId'}; + delete $have_jscard->{'cyrusimap.org:size'}; + + # Normalize and compare cards + normalize_jscard($want_jscard); + normalize_jscard($have_jscard); + + $self->assert_deep_equals($want_jscard, $have_jscard); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_get_v3 b/cassandane/tiny-tests/JMAPContacts/card_get_v3 new file mode 100644 index 0000000000..1be84b9c5c --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_get_v3 @@ -0,0 +1,132 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_get_v3 + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + # PROP-IDs added so we can easily compare the results + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/test.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + my $want_jscard = { + '@type' => 'Card', + version => '1.0', + addressBookId => 'Default', + 'cyrusimap.org:href' => $carddav->fullpath() . $href, + id => $id, + uid => $id, + updated => '2008-04-24T19:52:43Z', + vCardProps => [ + [ 'version', {}, 'text', '3.0' ] + ], + name => { + full => 'Forrest Gump', + components => [ + { 'kind' => 'surname', 'value' => 'Gump' }, + { 'kind' => 'given', 'value' => 'Forrest' }, + { 'kind' => 'title', 'value' => 'Mr.' }, + ] + }, + anniversaries => { + A1 => { + 'kind' => 'birth', + 'date' => { 'year' => 1944, 'month' => 6, 'day' => 7 } + } + }, + organizations => { + O1 => { + name => 'Bubba Gump Shrimp Co.', + units => [ + { name => 'foo' } + ] + } + }, + titles => { + T1 => { + 'name' => 'Shrimp Man' + } + }, + addresses => { + A1 => { + 'isOrdered' => JSON::true, + 'components' => [ + { kind => 'name', value => '1501 Broadway' }, + { kind => 'locality', value => 'New York' }, + { kind => 'region', value => 'NY' }, + { kind => 'postcode', value => '10036' }, + { kind => 'country', value => 'USA' } + ], + 'coordinates' => 'geo:40.7571383482188,-73.98695548990568', + 'timeZone' => 'Etc/GMT+5' + } + }, + media => { + P1 => { + kind => 'photo', + mediaType => 'image/jpeg' + } + } + }; + + + my $have_jscard = $res->[0][1]{list}[0]; + + # Get media blob id before we delete it + my $blobid = $res->[0][1]{list}[0]{media}{P1}{blobId}; + $self->assert_not_null($blobid); + + # Delete generated fields + delete $have_jscard->{blobId}; + delete $have_jscard->{media}{P1}{blobId}; + delete $have_jscard->{'cyrusimap.org:blobId'}; + delete $have_jscard->{'cyrusimap.org:size'}; + + # Normalize and compare cards + normalize_jscard($want_jscard); + normalize_jscard($have_jscard); + $self->assert_deep_equals($want_jscard, $have_jscard); + + $res = $jmap->Download('cassandane', $blobid); + + $self->assert_str_equals('image/jpeg', $res->{headers}{'content-type'}); + $self->assert_str_equals('some photo', $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_get_v4 b/cassandane/tiny-tests/JMAPContacts/card_get_v4 new file mode 100644 index 0000000000..7871ab93c9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_get_v4 @@ -0,0 +1,237 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_get_v4 + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + # Sample card from RFC 6350 + # Second N suffix removed due to vparse bug + # PROP-IDs added so we can easily compare the results + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/test.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + my $want_jscard = { + '@type' => 'Card', + version => '1.0', + addressBookId => 'Default', + 'cyrusimap.org:href' => $carddav->fullpath() . $href, + id => $id, + uid => $id, + kind => 'individual', + updated => '2023-04-22T19:46:39Z', + vCardProps => [ + [ 'version', {}, 'text', '4.0' ], + [ 'gender', {}, 'text', 'M' ], + ], + name => { + full => 'Simon Perreault', + components => [ + { 'kind' => 'surname', 'value' => 'Perreault' }, + { 'kind' => 'given', 'value' => 'Simon' }, + { 'kind' => 'credential', 'value' => 'ing. jr' }, + ], + foo => 'bar' + }, + anniversaries => { + A1 => { + 'kind' => 'birth', + 'date' => { 'month' => 2, 'day' => 3 } + }, + A2 => { + 'kind' => 'wedding', + 'date' => { '@type' => 'Timestamp', 'utc' => '2009-08-08T19:30:00Z' } + }, + }, + preferredLanguages => { + L2 => { + 'language' => 'en', + 'pref' => 2 + }, + L1 => { + 'language' => 'fr', + 'pref' => 1 + }, + }, + organizations => { + O1 => { + 'name' => 'Viagenie', + foo => {} + } + }, + addresses => { + A1 => { + 'components' => [ + { kind => 'apartment', value => 'Suite D2-630' }, + { kind => 'name', value => '2875 Laurier' }, + { kind => 'locality', value => 'Quebec' }, + { kind => 'region', value => 'QC' }, + { kind => 'postcode', value => 'G1V 2M2' }, + { kind => 'country', value => 'Canada' } + ], + 'contexts' => { 'work' => JSON::true }, + 'coordinates' => 'geo:46.772673,-71.282945', + 'timeZone' => 'America/Montreal' + }, + A2 => { + full => 'Somewhere', + components => [ + { kind => 'foo', value => 'bar' } + ] + } + }, + phones => { + P1 => { + number => "tel:+1-418-656-9254;ext=102", + contexts => { + work => JSON::true + }, + features => { + voice => JSON::true, + foo => JSON::true + }, + pref => 1 + }, + P2 => { + 'number' => 'tel:+1-418-262-6501', + 'contexts' => { 'work' => JSON::true }, + 'features' => { 'cell' => JSON::true, 'voice' => JSON::true, + 'video' => JSON::true, 'text' => JSON::true } + }, + }, + emails => { + E1 => { + 'address' => 'simon.perreault@viagenie.ca', + 'contexts' => { 'work' => JSON::true } + }, + }, + cryptoKeys => { + K1 => { + 'uri' => 'http://www.viagenie.ca/simon.perreault/simon.asc', + 'contexts' => { 'work' => JSON::true } + }, + }, + links => { + L1 => { + 'uri' => 'http://nomis80.org', + 'contexts' => { 'private' => JSON::true } + }, + }, + onlineServices => { + 'OS1' => { + 'uri' => 'xmpp:simon@example.com', + 'vCardName' => 'impp', + 'pref' => 1 + }, + 'OS2' => { + 'user' => 'Simon P.', + 'uri' => 'https://example.com/@simon', + 'service' => 'Mastodon' + } + }, + media => { + L1 => { + 'kind' => 'logo', + 'mediaType' => 'image/png', + 'uri' => 'http://example.org/logo.png' + }, + P1 => { + kind => 'photo', + mediaType => 'image/jpeg', + }, + S1 => { + kind => 'sound', + mediaType => 'audio/mpeg', + }, + }, + }; + + + my $have_jscard = $res->[0][1]{list}[0]; + + # Get media blob ids before we delete them + my $p_blobid = $have_jscard->{media}{P1}{blobId}; + $self->assert_not_null($p_blobid); + my $s_blobid = $have_jscard->{media}{S1}{blobId}; + $self->assert_not_null($s_blobid); + + # Delete generated fields + delete $have_jscard->{blobId}; + delete $have_jscard->{media}{P1}{blobId}; + delete $have_jscard->{media}{S1}{blobId}; + delete $have_jscard->{'cyrusimap.org:blobId'}; + delete $have_jscard->{'cyrusimap.org:size'}; + + # Normalize and compare cards + normalize_jscard($want_jscard); + normalize_jscard($have_jscard); + $self->assert_deep_equals($want_jscard, $have_jscard); + + $res = $jmap->Download('cassandane', $p_blobid); + + $self->assert_str_equals('image/jpeg', $res->{headers}{'content-type'}); + $self->assert_str_equals('some photo', $res->{content}); + + $res = $jmap->Download('cassandane', $s_blobid); + + $self->assert_str_equals('audio/mpeg', $res->{headers}{'content-type'}); + $self->assert_str_equals('some sound', $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_parse b/cassandane/tiny-tests/JMAPContacts/card_parse new file mode 100644 index 0000000000..b32ed0186a --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_parse @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_parse + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + # PROP-IDs added so we can easily compare the results + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $card = <Upload($card, "text/vcard"); + my $blobId = $res->{blobId}; + + my $res = $jmap->CallMethods([ + ['ContactCard/parse', { + blobIds => [ $blobId ], + properties => [ "\@type", "uid", "name", "media", "vCardProps" ] + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{parsed}{$blobId}); + $self->assert_str_equals('Card', $res->[0][1]{parsed}{$blobId}{'@type'}); + $self->assert_str_equals($id, $res->[0][1]{parsed}{$blobId}{uid}); + $self->assert_deep_equals([ + [ 'version', {}, 'text', '3.0' ] + ], $res->[0][1]{parsed}{$blobId}{vCardProps}); + $self->assert_str_equals('Forrest Gump', $res->[0][1]{parsed}{$blobId}{name}{full}); + + $self->assert_null($res->[0][1]{parsed}{$blobId}{version}); + $self->assert_null($res->[0][1]{parsed}{$blobId}{updated}); + $self->assert_null($res->[0][1]{parsed}{$blobId}{anniversaries}); + $self->assert_null($res->[0][1]{parsed}{$blobId}{organizations}); + $self->assert_null($res->[0][1]{parsed}{$blobId}{titles}); + + $self->assert_str_equals('photo', + $res->[0][1]{parsed}{$blobId}{media}{P1}{kind}); + $self->assert_str_equals('image/jpeg', + $res->[0][1]{parsed}{$blobId}{media}{P1}{mediaType}); + + my $blobid = $res->[0][1]{parsed}{$blobId}{media}{P1}{blobId}; + $self->assert_not_null($blobid); + + $res = $jmap->Download('cassandane', $blobid); + + $self->assert_str_equals('image/jpeg', $res->{headers}{'content-type'}); + $self->assert_str_equals('some photo', $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_parse_disable_uri_as_blobid b/cassandane/tiny-tests/JMAPContacts/card_parse_disable_uri_as_blobid new file mode 100644 index 0000000000..a1b126dc44 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_parse_disable_uri_as_blobid @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_parse_disable_uri_as_blobid + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + my $vcard = <<'EOF'; +BEGIN:VCARD +VERSION:4.0 +UID:85b5d651-1cde-43d9-901d-7059d67807f9 +FN:Jane +PHOTO;PROP-ID=photo1:data:image/x-bla;base64,YmxhYmxhCg= +CREATED:20230823T133154Z +END:VCARD +EOF + $vcard =~ s/\r?\n/\r\n/gs; + + my $data = $jmap->Upload($vcard, "text/vcard"); + my $blobId = $data->{blobId}; + $self->assert_not_null($blobId); + + $res = $jmap->CallMethods([ + ['ContactCard/parse', { + blobIds => [$blobId], + }, 'R1'], + ['ContactCard/parse', { + blobIds => [$blobId], + disableUriAsBlobId => JSON::true, + }, 'R2'], + ]); + + $self->assert_not_null($res->[0][1]{parsed}{$blobId}{ + media}{photo1}{blobId}); + $self->assert_null($res->[0][1]{parsed}{$blobId}{ + media}{photo1}{uri}); + + $self->assert_null($res->[1][1]{parsed}{$blobId}{ + media}{photo1}{blobId}); + $self->assert_not_null($res->[1][1]{parsed}{$blobId}{ + media}{photo1}{uri}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_query b/cassandane/tiny-tests/JMAPContacts/card_query new file mode 100644 index 0000000000..5b4a1b0b1f --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_query @@ -0,0 +1,383 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_query + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create cards"; + my $res = $jmap->CallMethods([['ContactCard/set', { + create => { + "1" => { + name => { + isOrdered => JSON::false, + components => [ + { + kind => "given", + value => "foo" + }, + { + kind => "surname", + value => "last" + }, + ], + sortAs => { + surname => 'aaa' + } + }, + nicknames => { + 'n1' => { + name => "foo" + } + }, + emails => { + 'e1' => { + contexts => { + private => JSON::true + }, + address => "foo\@example.com" + } + }, + personalInfo => { + 'p1' => { + kind => 'hobby', + value => 'reading' + } + } + }, + "2" => { + name => { + isOrdered => JSON::false, + components => [ + { + kind => "given", + value => "bar" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + emails => { + 'e1' => { + contexts => { + work => JSON::true + }, + address => "bar\@bar.org" + }, + 'e2' => { + contexts => { + other => JSON::true + }, + address => "me\@example.com" + } + }, + addresses => { + 'a1' => { + contexts => { + private => JSON::true + }, + isOrdered => JSON::false, + components => [ + { + kind => "name", + value => "Some Lane" + }, + { + kind => "number", + value => "24" + }, + { + kind => "locality", + value => "SomeWhere City" + }, + { + kind => "region", + value => "" + }, + { + kind => "postcode", + value => "1234" + } + ] + } + } + }, + "3" => { + name => { + isOrdered => JSON::false, + components => [ + { + kind => "given", + value => "baz" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + addresses => { + 'a1' => { + contexts => { + private => JSON::true + }, + isOrdered => JSON::false, + components => [ + { + kind => "name", + value => "Some Lane" + }, + { + kind => "number", + value => "24" + }, + { + kind => "locality", + value => "SomeWhere City" + }, + { + kind => "region", + value => "" + }, + { + kind => "postcode", + value => "1234" + }, + { + kind => "country", + value => "Someinistan" + } + ] + } + }, + personalInfo => { + 'p1' => { + kind => 'interest', + value => 'r&b music' + } + } + }, + "4" => { + name => { + isOrdered => JSON::false, + components => [ + { + kind => "given", + value => "bam" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + nicknames => { + 'n1' => { + name => "bam" + } + }, + notes => { + 'n1' => { + note => "hello" + } + } + }, + "5" => { + kind => 'org', + name => { + full => 'My Org' + } + } + } + }, "R1"]]); + + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + my $id3 = $res->[0][1]{created}{"3"}{id}; + my $id4 = $res->[0][1]{created}{"4"}{id}; + + xlog $self, "create card groups"; + $res = $jmap->CallMethods([['ContactCard/set', {create => { + "1" => { kind => 'group', + name => { full => "group1" }, + members => { $id1 => JSON::true, $id2 => JSON::true } + }, + "2" => { kind => 'group', + name => { full => "group2" }, + members => { $id3 => JSON::true } + }, + "3" => { kind => 'group', + name => { full => "group3" }, + members => { $id4 => JSON::true } + } + }}, "R1"]]); + + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $group1 = $res->[0][1]{created}{"1"}{id}; + my $group2 = $res->[0][1]{created}{"2"}{id}; + my $group3 = $res->[0][1]{created}{"3"}{id}; + + xlog $self, "get unfiltered card list"; + $res = $jmap->CallMethods([ ['ContactCard/query', { }, "R1"] ]); + + $self->assert_num_equals(8, $res->[0][1]{total}); + $self->assert_num_equals(8, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by kind"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { kind => 'individual'} + }, "R1"] ]); + + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by kind"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { kind => 'org'} + }, "R1"] ]); + + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by name (fullName)"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { name => "foo" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name (fullName)"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { name => "last" } + }, "R1"] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by name/given"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { 'name/given' => "foo" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name/surname"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { 'name/surname' => "last" } + }, "R1"] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by name/given and name/surname (one filter)"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { 'name/given' => "bam", 'name/surname' => "last" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id4, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name/given and name/surname (AND filter)"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { operator => "AND", conditions => [{ + 'name/surname' => "last" + }, { + 'name/given' => "baz" + }]} + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id3, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name/given (OR filter)"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { operator => "OR", conditions => [{ + 'name/given' => "bar" + }, { + 'name/given' => "baz" + }]} + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by text"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { text => "some" } + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by nickName"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { nickName => "foo" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by email"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { email => "example.com" } + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by hobby"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { hobby => "reading" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by note"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { note => "hello" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by inCardGroup"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { inCardGroup => [$group1, $group3] } + }, "R1"] ]); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by inCardGroup and name/given"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { inCardGroup => [$group1, $group3], + 'name/given' => "foo" } + }, "R1"] ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "sort by name/given"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { kind => 'individual'}, + sort => [ { property => "name/given" } ] + }, "R1"] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id4, $res->[0][1]{ids}[0]); + $self->assert_str_equals($id2, $res->[0][1]{ids}[1]); + $self->assert_str_equals($id3, $res->[0][1]{ids}[2]); + $self->assert_str_equals($id1, $res->[0][1]{ids}[3]); + + xlog $self, "sort by name/surname"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { kind => 'individual'}, + sort => [ { property => "name/surname" } ] + }, "R1"] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_query_multi_sort b/cassandane/tiny-tests/JMAPContacts/card_query_multi_sort new file mode 100644 index 0000000000..f78e2d932d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_query_multi_sort @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_query_multi_sort + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create cards"; + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + card1 => { + uid => 'XXX-UID-1', + organizations => { + 'o1' => { + name => 'companyB' + } + } + }, + card2 => { + uid => 'XXX-UID-2', + organizations => { + 'o1' => { + name => 'companyA' + } + } + }, + card3 => { + uid => 'XXX-UID-3', + organizations => { + 'o1' => { + name => 'companyB' + } + } + }, + card4 => { + uid => 'XXX-UID-4', + organizations => { + 'o1' => { + name => 'companyC' + } + } + }, + }, + }, 'R1'], + ]); + my $cardId1 = $res->[0][1]{created}{card1}{id}; + $self->assert_not_null($cardId1); + + my $cardId2 = $res->[0][1]{created}{card2}{id}; + $self->assert_not_null($cardId2); + + my $cardId3 = $res->[0][1]{created}{card3}{id}; + $self->assert_not_null($cardId3); + + my $cardId4 = $res->[0][1]{created}{card4}{id}; + $self->assert_not_null($cardId4); + + xlog $self, "sort by multi-dimensional comparator"; + $res = $jmap->CallMethods([ + ['ContactCard/query', { + sort => [{ + property => 'organization', + }, { + property => 'uid', + isAscending => JSON::false, + }], + }, 'R2'], + ]); + $self->assert_deep_equals([ + $cardId2, + $cardId3, + $cardId1, + $cardId4, + ], $res->[0][1]{ids} + ); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_query_shared b/cassandane/tiny-tests/JMAPContacts/card_query_shared new file mode 100644 index 0000000000..a859151445 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_query_shared @@ -0,0 +1,440 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_query_shared + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "create cards"; + my $res = $jmap->CallMethods([ [ + 'ContactCard/set', + { + accountId => 'manifold', + create => { + card1 => { + name => { + isOrdered => JSON::false, + components => [ + { + kind => "given", + value => "given1" + }, + { + kind => "surname", + value => "last" + }, + ], + sortAs => { surname => 'aaa' } + }, + nicknames => { 'n1' => { name => "nick1" } }, + emails => { + 'e1' => { + contexts => { private => JSON::true }, + address => "card1\@example.com" + } + }, + personalInfo => { + 'p1' => { + kind => 'hobby', + value => 'reading' + } + } + }, + card2 => { + name => { + isOrdered => JSON::false, + components => [ + { + kind => "given", + value => "given2" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + emails => { + 'e1' => { + contexts => { work => JSON::true }, + address => "card2\@bar.org" + }, + 'e2' => { + contexts => { other => JSON::true }, + address => "me\@example.com" + } + }, + addresses => { + 'a1' => { + contexts => { private => JSON::true }, + isOrdered => JSON::false, + components => [ + { + kind => "name", + value => "Some Lane" + }, + { + kind => "number", + value => "24" + }, + { + kind => "locality", + value => "SomeWhere City" + }, + { + kind => "region", + value => "" + }, + { + kind => "postcode", + value => "1234" + } + ] + } + } + }, + card3 => { + name => { + isOrdered => JSON::false, + components => [ + { + kind => "given", + value => "given3" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + addresses => { + 'a1' => { + contexts => { private => JSON::true }, + isOrdered => JSON::false, + components => [ + { + kind => "name", + value => "Some Lane" + }, + { + kind => "number", + value => "24" + }, + { + kind => "locality", + value => "SomeWhere City" + }, + { + kind => "region", + value => "" + }, + { + kind => "postcode", + value => "1234" + }, + { + kind => "country", + value => "Someinistan" + } + ] + } + }, + personalInfo => { + 'p1' => { + kind => 'interest', + value => 'r&b music' + } + } + }, + card4 => { + name => { + isOrdered => JSON::false, + components => [ + { + kind => "given", + value => "given4" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + nicknames => { 'n1' => { name => "bam" } }, + notes => { 'n1' => { note => "hello" } } + } + } + }, + "R1" + ] ]); + + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"card1"}{id}; + my $id2 = $res->[0][1]{created}{"card2"}{id}; + my $id3 = $res->[0][1]{created}{"card3"}{id}; + my $id4 = $res->[0][1]{created}{"card4"}{id}; + + xlog $self, "create card groups"; + $res = $jmap->CallMethods([ [ + 'ContactCard/set', + { + accountId => 'manifold', + create => { + group1 => { + kind => 'group', + name => { full => "group1" }, + members => { $id1 => JSON::true, $id2 => JSON::true } + }, + group2 => { + kind => 'group', + name => { full => "group2" }, + members => { $id3 => JSON::true } + }, + group3 => { + kind => 'group', + name => { full => "group3" }, + members => { $id4 => JSON::true } + } + } + }, + "R1" + ] ]); + + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $group1 = $res->[0][1]{created}{"group1"}{id}; + my $group2 = $res->[0][1]{created}{"group2"}{id}; + my $group3 = $res->[0][1]{created}{"group3"}{id}; + + xlog $self, "get unfiltered card list"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + }, + "R1" + ] ]); + + $self->assert_num_equals(7, $res->[0][1]{total}); + $self->assert_num_equals(7, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by name (fullName)"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { name => "given1" } + }, + "R1" + ] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name (fullName)"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { name => "last" } + }, + "R1" + ] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by name/given"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { 'name/given' => "given1" } + }, + "R1" + ] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name/surname"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { 'name/surname' => "last" } + }, + "R1" + ] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by name/given and name/surname (one filter)"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { 'name/given' => "given4", 'name/surname' => "last" } + }, + "R1" + ] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + $self->assert_str_equals($id4, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name/given and name/surname (AND filter)"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => + { operator => "AND", conditions => [ { 'name/surname' => "last" }, { 'name/given' => "given3" } ] } + }, + "R1" + ] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + $self->assert_str_equals($id3, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name/given (OR filter)"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => + { operator => "OR", conditions => [ { 'name/given' => "given2" }, { 'name/given' => "given3" } ] } + }, + "R1" + ] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by text"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { text => "some" } + }, + "R1" + ] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by nickName"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { nickName => "nick1" } + }, + "R1" + ] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by email"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { email => "example.com" } + }, + "R1" + ] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by hobby"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { hobby => "reading" } + }, + "R1" + ] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by note"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { note => "hello" } + }, + "R1" + ] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by inCardGroup"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { inCardGroup => [ $group1, $group3 ] } + }, + "R1" + ] ]); + $self->assert_num_equals(3, scalar @{ $res->[0][1]{ids} }); + + xlog $self, "filter by inCardGroup and name/given"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + accountId => 'manifold', + filter => { + inCardGroup => [ $group1, $group3 ], + 'name/given' => "given1" + } + }, + "R1" + ] ]); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "sort by name/given"; + $res = $jmap->CallMethods([ [ + 'ContactCard/query', + { + filter => { name => "last" }, + accountId => 'manifold', + sort => [ { property => "name/given" } ] + }, + "R1" + ] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_deep_equals( + [ $id1, $id2, $id3, $id4 ], + $res->[0][1]{ids} + ); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_query_text b/cassandane/tiny-tests/JMAPContacts/card_query_text new file mode 100644 index 0000000000..6a7795ff58 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_query_text @@ -0,0 +1,142 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_query_text + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create cards"; + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + card1 => { + notes => { + 'n1' => { + note => 'cats and dogs' + } + } + }, + card2 => { + notes => { + 'n1' => { + note => 'hats and bats' + } + } + }, + }, + }, 'R1'], + ]); + my $cardId1 = $res->[0][1]{created}{card1}{id}; + $self->assert_not_null($cardId1); + my $cardId2 = $res->[0][1]{created}{card2}{id}; + $self->assert_not_null($cardId2); + + xlog "Query with loose terms"; + $res = $jmap->CallMethods([ + ['ContactCard/query', { + filter => { + note => "cats dogs", + }, + }, 'R1'], + ['ContactCard/query', { + filter => { + operator => 'NOT', + conditions => [{ + note => 'cats dogs', + }], + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$cardId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$cardId2], $res->[1][1]{ids}); + + xlog "Query with phrase"; + $res = $jmap->CallMethods([ + ['ContactCard/query', { + filter => { + note => "'cats and dogs'", + }, + }, 'R1'], + ['ContactCard/query', { + filter => { + operator => 'NOT', + conditions => [{ + note => "'cats and dogs'", + }], + }, + }, 'R1'], + ]); + $self->assert_deep_equals([$cardId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$cardId2], $res->[1][1]{ids}); + + xlog "Query with both phrase and loose terms"; + $res = $jmap->CallMethods([ + ['ContactCard/query', { + filter => { + note => "cats 'cats and dogs' dogs", + }, + }, 'R1'], + ['ContactCard/query', { + filter => { + operator => 'NOT', + conditions => [{ + note => "cats 'cats and dogs' dogs", + }], + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$cardId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$cardId2], $res->[1][1]{ids}); + + xlog "Query text"; + $res = $jmap->CallMethods([ + ['ContactCard/query', { + filter => { + text => "cats dogs", + }, + }, 'R1'], + ['ContactCard/query', { + filter => { + operator => 'NOT', + conditions => [{ + text => "cats dogs", + }], + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$cardId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$cardId2], $res->[1][1]{ids}); + + xlog "Query text and notes"; + $res = $jmap->CallMethods([ + ['ContactCard/query', { + filter => { + operator => 'AND', + conditions => [{ + text => "cats", + }, { + note => "dogs", + }], + }, + }, 'R1'], + ['ContactCard/query', { + + filter => { + operator => 'NOT', + conditions => [{ + operator => 'AND', + conditions => [{ + text => "cats", + }, { + note => "dogs", + }], + }], + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$cardId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$cardId2], $res->[1][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_query_windowing b/cassandane/tiny-tests/JMAPContacts/card_query_windowing new file mode 100644 index 0000000000..eef73fdc44 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_query_windowing @@ -0,0 +1,125 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_query_windowing + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create cards"; + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + card1 => { + uid => 'XXX-UID-1', + organizations => { + 'o1' => { + name => 'companyB' + } + } + }, + card2 => { + uid => 'XXX-UID-2', + organizations => { + 'o1' => { + name => 'companyA' + } + } + }, + card3 => { + uid => 'XXX-UID-3', + organizations => { + 'o1' => { + name => 'companyB' + } + } + }, + card4 => { + uid => 'XXX-UID-4', + organizations => { + 'o1' => { + name => 'companyC' + } + } + }, + }, + }, 'R1'], + ]); + my $cardId1 = $res->[0][1]{created}{card1}{id}; + $self->assert_not_null($cardId1); + + my $cardId2 = $res->[0][1]{created}{card2}{id}; + $self->assert_not_null($cardId2); + + my $cardId3 = $res->[0][1]{created}{card3}{id}; + $self->assert_not_null($cardId3); + + my $cardId4 = $res->[0][1]{created}{card4}{id}; + $self->assert_not_null($cardId4); + + xlog $self, "run query with windowing"; + $res = $jmap->CallMethods([ + ['ContactCard/query', { + sort => [{ + property => 'uid', + }], + limit => 2, + }, 'R1'], + ['ContactCard/query', { + sort => [{ + property => 'uid', + }], + limit => 2, + position => 2, + }, 'R2'], + ['ContactCard/query', { + sort => [{ + property => 'uid', + }], + anchor => $cardId3, + anchorOffset => -1, + limit => 2, + }, 'R3'], + ['ContactCard/query', { + sort => [{ + property => 'uid', + }], + limit => 2, + position => -2, + }, 'R4'], + ]); + # Request 1 + $self->assert_deep_equals([ + $cardId1, + $cardId2, + ], $res->[0][1]{ids} + ); + $self->assert_num_equals(0, $res->[0][1]{position}); + $self->assert_num_equals(4, $res->[0][1]{total}); + # Request 2 + $self->assert_deep_equals([ + $cardId3, + $cardId4, + ], $res->[1][1]{ids} + ); + $self->assert_num_equals(2, $res->[1][1]{position}); + $self->assert_num_equals(4, $res->[1][1]{total}); + # Request 3 + $self->assert_deep_equals([ + $cardId2, + $cardId3, + ], $res->[2][1]{ids} + ); + $self->assert_num_equals(1, $res->[2][1]{position}); + $self->assert_num_equals(4, $res->[2][1]{total}); + # Request 4 + $self->assert_deep_equals([ + $cardId3, + $cardId4, + ], $res->[3][1]{ids} + ); + $self->assert_num_equals(2, $res->[3][1]{position}); + $self->assert_num_equals(4, $res->[3][1]{total}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_addresses b/cassandane/tiny-tests/JMAPContacts/card_set_create_addresses new file mode 100644 index 0000000000..fdaa5cf6ba --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_addresses @@ -0,0 +1,133 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_addresses + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'John Doe' }, + addresses => { + k23 => { + '@type' => 'Address', + contexts => { + work => JSON::true + }, + full => "54321 Oak St\nReston\nVA\n20190\nUSA", + isOrdered => JSON::true, + defaultSeparator => "\n", + components => [ + { + kind => 'number', + value => '54321' + }, + { + kind => 'separator', + value => " " + }, + { + kind => 'name', + value => 'Oak St' + }, + { + kind => 'locality', + value => 'Reston' + }, + { + kind => 'separator', + value => ', ' + }, + { + kind => 'region', + value => 'VA' + }, + { + kind => 'postcode', + value => '20190' + }, + { + kind => 'country', + value => 'USA' + } + ], + countryCode => 'US' + }, + k24 => { + contexts => { + private => JSON::true + }, + full => "12345 Elm St\nReston\nVA\n20190\nUSA", + isOrdered => JSON::false, + components => [ + { + kind => 'number', + value => '12345' + }, + { + '@type' => 'Address', + kind => 'name', + value => 'Elm St' + }, + { + kind => 'locality', + value => 'Reston' + }, + { + kind => 'region', + value => 'VA' + }, + { + kind => 'postcode', + value => '20190' + }, + { + kind => 'country', + value => 'USA' + } + ], + countryCode => 'US', + timeZone => 'America/New_York' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + $self->assert_not_null($res->[0][1]{created}{1}{created}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/ADR;JSCOMPS="s,\^n;10;s, ;11;3;s,\\, ;4;5;6";PROP-ID=k23;TYPE=WORK;CC=US;LABEL=54321 Oak St\^nReston\^nVA\^n20190\^nUSA:;;54321,Oak St;Reston;VA;20190;USA;;;;54321;Oak St;;;;;;/, $card); + $self->assert_matches(qr/ADR;PROP-ID=k24;TYPE=HOME;TZ=America\/New_York;CC=US;LABEL=12345 Elm St\^nReston\^nVA\^n20190\^nUSA:;;12345,Elm St;Reston;VA;20190;USA;;;;12345;Elm St;;;;;;/, $card); + $self->assert_matches(qr/CREATED:/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_anniversaries b/cassandane/tiny-tests/JMAPContacts/card_set_create_anniversaries new file mode 100644 index 0000000000..51a7b4794d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_anniversaries @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_anniversaries + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + anniversaries => { + k8 => { + '@type' => 'Anniversary', + kind => 'birth', + date => { + year => 1953, + month => 4, + day => 15 + } + }, + k9 => { + '@type' => 'Anniversary', + kind => 'death', + date => { + '@type' => 'Timestamp', + utc => '2019-10-15T23:10:00Z' + }, + place => { + '@type' => 'Address', + full => + '4445 Tree Street\nNew England, ND 58647\nUSA' + } + }, + k10 => { + '@type' => 'Anniversary', + kind => 'wedding', + date => { + '@type' => 'PartialDate', + year => 1975 + }, + place => { + full => 'Somewhere' + } + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|BDAY;PROP-ID=k8:19530415|, $card); + $self->assert_matches(qr|DEATHDATE;VALUE=TIMESTAMP;PROP-ID=k9:20191015T231000Z|, $card); + $self->assert_matches(qr|DEATHPLACE;PROP-ID=k9:4445 Tree Street\\nNew England\\, ND 58647\\nUSA|, $card); + $self->assert_matches(qr|ANNIVERSARY;PROP-ID=k10:1975|, $card); + $self->assert_matches(qr|JSPROP;JSPTR=anniversaries/k10/place:|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_basic b/cassandane/tiny-tests/JMAPContacts/card_set_create_basic new file mode 100644 index 0000000000..5282cc0fc2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_basic @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_basic + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create addressbook"; + my $res = $jmap->CallMethods([ + ['AddressBook/set', { create => { "1" => { + name => "foo" + }}}, "R1"] + ]); + + my $abookid = $res->[0][1]{created}{"1"}{id}; + + my $now = DateTime->now(); + my $created = $now->strftime('%Y-%m-%dT%H:%M:%SZ'); + my $prodid = '-//Example Corp.//CardDAV Client//EN'; + my $name = 'Mr. John Q. Public, Esq.'; + my $id = 'urn:uuid:ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + addressBookId => $abookid, + uid => $id, + prodId => $prodid, + kind => 'individual', + created => $created, + name => { full => $name } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + $self->assert_not_null($res->[0][1]{created}{1}{id}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $name =~ s/,/\\\\,/gs; # escape commas + + $created = $now->strftime('%Y%m%dT%H%M%SZ'); # vCard doesn't use separators + + $self->assert_matches(qr/VERSION:4.0/, $card); + $self->assert_matches(qr/KIND:INDIVIDUAL/, $card); + $self->assert_matches(qr/UID:$id/, $card); + $self->assert_matches(qr/PRODID:$prodid/, $card); + $self->assert_matches(qr/CREATED:$created/, $card); + $self->assert_matches(qr/FN:$name/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_bday_noyear b/cassandane/tiny-tests/JMAPContacts/card_set_create_bday_noyear new file mode 100644 index 0000000000..285df4a85c --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_bday_noyear @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_bday_noyear + :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + anniversaries => { + k8 => { + '@type' => 'Anniversary', + kind => 'birth', + date => { + month => 4, + day => 15 + } + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|BDAY(;VALUE=DATE)?;PROP-ID=k8:--0415|, $card); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=3.0'); + + $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|BDAY(;VALUE=DATE)?;PROP-ID=k8;X-APPLE-OMIT-YEAR=1604:1604(-)?04(-)?15|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_calendars b/cassandane/tiny-tests/JMAPContacts/card_set_create_calendars new file mode 100644 index 0000000000..04b1778ef6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_calendars @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_calendars + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + calendars => { + 'CAL-1' => { + '@type' => 'CalendarResource', + kind => 'calendar', + uri => 'https://cal.example.com/calA', + pref => 1 + }, + 'CAL-2' => { + '@type' => 'CalendarResource', + kind => 'calendar', + uri => 'https://ftp.example.com/calA.ics', + mediaType => 'text/calendar' + }, + 'FBURL-1' => { + '@type' => 'CalendarResource', + kind => 'freeBusy', + uri => 'https://www.example.com/busy/janedoe', + pref => 1 + }, + 'FBURL-2' => { + '@type' => 'CalendarResource', + kind => 'freeBusy', + uri => 'https://example.com/busy/project-a.ifb', + mediaType => 'text/calendar' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|CALURI;PROP-ID=CAL-1;PREF=1:https://cal.example.com/calA|, $card); + $self->assert_matches(qr|CALURI;PROP-ID=CAL-2;MEDIATYPE=text/calendar:https://ftp.example.com/calA.ics|, $card); + $self->assert_matches(qr|FBURL;PROP-ID=FBURL-1;PREF=1:https://www.example.com/busy/janedoe|, $card); + $self->assert_matches(qr|FBURL;PROP-ID=FBURL-2;MEDIATYPE=text/calendar:https://example.com/busy/project-a.ifb|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_cryptokeys b/cassandane/tiny-tests/JMAPContacts/card_set_create_cryptokeys new file mode 100644 index 0000000000..1041e6603b --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_cryptokeys @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_cryptokeys + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $key = 'data:,-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA+xGZ/wcz9ugFpP07Nspo6U17l0YhFiFpxxU4pTk3Lifz9R3zsIsu\nERwta7+fWIfxOo208ett/jhskiVodSEt3QBGh4XBipyWopKwZ93HHaDVZAALi/2A\n+xTBtWdEo7XGUujKDvC2/aZKukfjpOiUI8AhLAfjmlcD/UZ1QPh0mHsglRNCmpCw\nmwSXA9VNmhz+PiB+Dml4WWnKW/VHo2ujTXxq7+efMU4H2fny3Se3KYOsFPFGZ1TN\nQSYlFuShWrHPtiLmUdPoP6CV2mML1tk+l7DIIqXrQhLUKDACeM5roMx0kLhUWB8P\n+0uj1CNlNN4JRZlC7xFfqiMbFRU9Z4N6YwIDAQAB\n-----END RSA PUBLIC KEY-----'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + cryptoKeys => { + mykey1 => { + '@type' => 'CryptoResource', + uri => 'https://www.example.com/keys/jdoe.cer' + }, + mykey2 => { + '@type' => 'CryptoResource', + uri => $key + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|KEY(;VALUE=URI)?;PROP-ID=mykey1:https://www.example.com/keys/jdoe.cer|, $card); + $self->assert_matches(qr|KEY;PROP-ID=mykey2:data:,|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_directories b/cassandane/tiny-tests/JMAPContacts/card_set_create_directories new file mode 100644 index 0000000000..5a0a85c91f --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_directories @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_directories + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + directories => { + 'DIRECTORY-1' => { + '@type' => 'DirectoryResource', + kind => 'directory', + uri => 'https://directory.mycompany.example.com', + listAs => 1 + }, + 'DIRECTORY-2' => { + '@type' => 'DirectoryResource', + kind => 'directory', + uri => 'ldap://ldap.tech.example/o=Tech,ou=Engineering', + pref => 1 + }, + 'ENTRY-1' => { + '@type' => 'DirectoryResource', + kind => 'entry', + uri => 'https://dir.example.com/addrbook/jdoe/Jean%20Dupont.vcf' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|ORG-DIRECTORY;PROP-ID=DIRECTORY-1;INDEX=1:https://directory.mycompany.example.com|, $card); + $self->assert_matches(qr|ORG-DIRECTORY;PROP-ID=DIRECTORY-2;PREF=1:ldap://ldap.tech.example/o=Tech,ou=Engineering|, $card); + $self->assert_matches(qr|SOURCE;PROP-ID=ENTRY-1:https://dir.example.com/addrbook/jdoe/Jean%20Dupont.vcf|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_emails b/cassandane/tiny-tests/JMAPContacts/card_set_create_emails new file mode 100644 index 0000000000..bce1653c97 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_emails @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_emails + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + emails => { + e1 => { + contexts => { + work => JSON::true + }, + address => 'jqpublic@xyz.example.com' + }, + e2 => { + '@type' => 'EmailAddress', + address => 'jane_doe@example.com', + pref => 1 + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/EMAIL;PROP-ID=e1;TYPE=WORK:jqpublic\@xyz.example.com/, $card); + $self->assert_matches(qr/EMAIL;PROP-ID=e2;PREF=1:jane_doe\@example.com/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_extra_rejected b/cassandane/tiny-tests/JMAPContacts/card_set_create_extra_rejected new file mode 100644 index 0000000000..8026f302b7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_extra_rejected @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_extra_rejected + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + card1 => { + '@type' => 'Card', + name => { + full => 'John', + extra => 'reserved', + }, + extra => 'reserved', + localizations => { + de => { + 'name/extra' => 'reserved2', + }, + }, + }, + }, + }, 'R1'], + ]); + + $self->assert_null($res->[0][1]{created}{card1}); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notCreated}{card1}{type}); + + my @wantInvalidProps = ( + "extra", + "localizations/de/name~1extra", + "name/extra", + ); + my @haveInvalidProps = sort @{$res->[0][1]{notCreated}{card1}{properties}}; + $self->assert_deep_equals(\@wantInvalidProps, \@haveInvalidProps); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_keywords b/cassandane/tiny-tests/JMAPContacts/card_set_create_keywords new file mode 100644 index 0000000000..07a910a849 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_keywords @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_keywords + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + keywords => { 'foo' => JSON::true } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/CATEGORIES:foo/, $card); + $self->assert_does_not_match(qr/JSPROP/, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_links b/cassandane/tiny-tests/JMAPContacts/card_set_create_links new file mode 100644 index 0000000000..98465a2771 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_links @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_links + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + links => { + 'CONTACT-1' => { + '@type' => 'LinkResource', + kind => 'contact', + uri => 'mailto:contact@example.com', + pref => 1 + }, + 'LINK-1' => { + '@type' => 'LinkResource', + uri => 'https://example.org/restaurant.french/~chezchic.html' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|URL;PROP-ID=LINK-1:https://example.org/restaurant.french/~chezchic.html|, $card); + $self->assert_matches(qr|CONTACT-URI;PROP-ID=CONTACT-1;PREF=1:mailto:contact\@example.com|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_localizations b/cassandane/tiny-tests/JMAPContacts/card_set_create_localizations new file mode 100644 index 0000000000..c9eb3ddc45 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_localizations @@ -0,0 +1,154 @@ +#!perl +use Cassandane::Tiny; +use utf8; + +sub test_card_set_create_localizations + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + language => 'es', + name => { + '@type' => 'Name', + full => 'Gabriel García Márquez', + isOrdered => JSON::false, + components => [ + { + '@type' => 'Name', + kind => 'given', + value => 'Gabriel' + }, + { + '@type' => 'Name', + kind => 'given2', + value => 'García' + }, + { + '@type' => 'Name', + kind => 'surname', + value => 'Márquez' + } + ] + }, + addresses => { + addr1 => { + '@type' => 'Address', + isOrdered => JSON::false, + components => [ + { kind => 'locality', value => 'Tokio' } + ] + } + }, + speakToAs => { + '@type' => 'SpeakToAs', + grammaticalGender => 'neuter', + pronouns => { + k19 => { + '@type' => 'Pronouns', + pronouns => 'él', + } + } + }, + localizations => { + en => { + titles => { + t1 => { + '@type' => 'Title', + name => 'Novelist' + } + }, + 'addresses/addr1/components/0/value' => 'Tokyo', + 'speakToAs/grammaticalGender' => 'masculine' + }, + de => { + 'speakToAs/pronouns/k19/pronouns' => 'er' + }, + it => { + 'speakToAs/pronouns/k19/pronouns' => 'lui' + }, + fr => { + titles => { + t1 => { + '@type' => 'Title', + name => 'Écrivain' + } + }, + speakToAs => { + '@type' => 'SpeakToAs', + pronouns => { + k19 => { + '@type' => 'Pronouns', + pronouns => 'il', + } + } + } + }, + es => { + titles => { + t1 => { + '@type' => 'Title', + name => 'Novelista' + } + } + }, + jp => { + 'name/full' => 'ガブリエル・ガルシア・マルケス', + 'name/components/0/value' => 'ガブリエル', + 'name/components/1/value' => 'ガルシア', + 'name/components/2/value' => 'マルケス', + 'addresses/addr1/components/0/value' => '東京' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/FN:Gabriel/, $card); + $self->assert_matches(qr/FN;LANGUAGE=jp:/, $card); + $self->assert_matches(qr/N;ALTID=n1:M/, $card); + $self->assert_matches(qr/N;ALTID=n1;LANGUAGE=jp:/, $card); + $self->assert_matches(qr/ADR;PROP-ID=addr1;ALTID=addr1:;;;Tokio;;;/, $card); + $self->assert_matches(qr/ADR;PROP-ID=addr1;ALTID=addr1;LANGUAGE=jp:/, $card); + $self->assert_matches(qr/ADR;PROP-ID=addr1;ALTID=addr1;LANGUAGE=en:;;;Tokyo;;;/, $card); + $self->assert_matches(qr/TITLE;PROP-ID=t1;ALTID=t1:Novelista/, $card); + $self->assert_matches(qr/TITLE;PROP-ID=t1;ALTID=t1;LANGUAGE=en:Novelist/, $card); + $self->assert_matches(qr/GRAMGENDER:NEUTER/, $card); + $self->assert_matches(qr/GRAMGENDER;LANGUAGE=en:MASCULINE/, $card); + $self->assert_matches(qr/PRONOUNS;PROP-ID=k19;ALTID=k19:/, $card); + $self->assert_matches(qr/PRONOUNS;PROP-ID=k19;ALTID=k19;LANGUAGE=fr:il/, $card); + $self->assert_matches(qr/PRONOUNS;PROP-ID=k19;ALTID=k19;LANGUAGE=de:er/, $card); + $self->assert_matches(qr/PRONOUNS;PROP-ID=k19;ALTID=k19;LANGUAGE=it:lui/, $card); + $self->assert_does_not_match(qr/JSPROP/, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_localizations_bad b/cassandane/tiny-tests/JMAPContacts/card_set_create_localizations_bad new file mode 100644 index 0000000000..6246a0403a --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_localizations_bad @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; +use utf8; + +sub test_card_set_create_localizations_bad + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + language => 'es', + name => { full => 'Gabriel García Márquez' }, + speakToAs => { + '@type' => 'SpeakToAs', + grammaticalGender => 'masculino', + pronouns => { + k19 => { + '@type' => 'Pronouns', + pronouns => 'él', + } + } + }, + localizations => { + en => { + 'foo/bar' => JSON::false + }, + de => { + 'titles/foo' => JSON::false + }, + es => { + titles => { + t1 => { + '@type' => 'Title', + title => 'Novelista' + } + } + }, + jp => { + 'name/foo/bar' => JSON::false, + 'titles/t2/title/foo' => JSON::false, + 'speakToAs/foo/bar' => JSON::false + } + } + } + } + }, 'R1'] + ]); + + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notCreated}{1}{type}); + $self->assert_num_equals(6, + scalar @{$res->[0][1]{notCreated}{1}{properties}}); + + my @bad_props = sort @{$res->[0][1]{notCreated}{1}{properties}}; + + $self->assert_str_equals('localizations/de/titles/foo', $bad_props[0]); + $self->assert_str_equals('localizations/en/foo/bar', $bad_props[1]); + $self->assert_str_equals('localizations/es/titles/t1/name', $bad_props[2]); + $self->assert_str_equals('localizations/jp/name/foo/bar', $bad_props[3]); + $self->assert_str_equals('localizations/jp/speakToAs/foo/bar', $bad_props[4]); + $self->assert_str_equals('localizations/jp/titles/t2/title/foo', $bad_props[5]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_media b/cassandane/tiny-tests/JMAPContacts/card_set_create_media new file mode 100644 index 0000000000..a5dbb32619 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_media @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_media + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + media => { + res45 => { + '@type' => 'MediaResource', + kind => 'sound', + uri => 'CID:JOHNQ.part8.19960229T080000.xyzMail@example.com' + }, + res47 => { + '@type' => 'MediaResource', + kind => 'logo', + mediaType => 'image/jpeg', + uri => 'https://www.example.com/pub/logos/abccorp.jpg' + }, + res1 => { + '@type' => 'MediaResource', + kind => 'photo', + uri => 'data:image/jpeg;base64,c29tZSBwaG90bw==' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + my $blobId = $res->[0][1]{created}{1}{media}{res1}{blobId}; + $self->assert_not_null($blobId); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|LOGO;PROP-ID=res47;MEDIATYPE=image/jpeg:https://www.example.com/pub/logos/abccorp.jpg|, $card); + $self->assert_matches(qr|SOUND(;VALUE=URI)?;PROP-ID=res45:CID:JOHNQ.part8.19960229T080000.xyzMail\@example.com|, $card); + $self->assert_matches(qr|PHOTO;PROP-ID=res1:data:image/jpeg;base64,c29tZSBwaG90bw==|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); + + $res = $jmap->Download('cassandane', $blobId); + + $self->assert_str_equals('image/jpeg', $res->{headers}{'content-type'}); + $self->assert_str_equals('some photo', $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_media_blob b/cassandane/tiny-tests/JMAPContacts/card_set_create_media_blob new file mode 100644 index 0000000000..668b59294d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_media_blob @@ -0,0 +1,67 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_media_blob + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "upload photo"; + my $res = $jmap->Upload("some photo", "image/jpeg"); + my $blobId = $res->{blobId}; + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + media => { + res1 => { + '@type' => 'MediaResource', + kind => 'photo', + mediaType => 'image/jpeg', + blobId => $blobId + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + $blobId = $res->[0][1]{created}{1}{media}{res1}{blobId}; + $self->assert_not_null($blobId); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|PHOTO;PROP-ID=res1:data:image/jpeg;base64,c29tZSBwaG90bw==|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); + + $res = $jmap->Download('cassandane', $blobId); + + $self->assert_str_equals('image/jpeg', $res->{headers}{'content-type'}); + $self->assert_str_equals('some photo', $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_name b/cassandane/tiny-tests/JMAPContacts/card_set_create_name new file mode 100644 index 0000000000..a4a4b335c4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_name @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_name + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { + isOrdered => JSON::true, + components => [ + { + kind => 'given', + value => 'Robert' + }, + { + kind => 'given2', + value => 'Pau' + }, + { + kind => 'surname', + value => 'Shou' + }, + { + '@type' => 'Name', + kind => 'surname2', + value => 'Chang' + } + ], + sortAs => { + surname => 'Pau Shou Chang', + given => 'Robert' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/N;JSCOMPS=";1;2;0;5";SORT-AS=Pau Shou Chang,Robert:Shou,Chang;Robert;Pau;;;Chang;/, $card); + $self->assert_matches(qr/FN;DERIVED=TRUE:Robert Pau Shou Chang/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_name_bad_type b/cassandane/tiny-tests/JMAPContacts/card_set_create_name_bad_type new file mode 100644 index 0000000000..2c3b53e6d0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_name_bad_type @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_name_bad_type + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { + isOrdered => JSON::true, + components => [ + { + kind => 'given', + value => 'Robert' + }, + { + kind => 'given2', + value => 'Pau' + }, + { + kind => 'surname', + value => 'Shou' + }, + { + '@type' => 'Foo', + kind => 'surname2', + value => 'Chang' + } + ], + sortAs => { + surname => 'Pau Shou Chang', + given => 'Robert' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{notCreated}{1}); + $self->assert_equals("name/components[3]/\@type", + $res->[0][1]{notCreated}{1}{properties}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_name_unknown_kind b/cassandane/tiny-tests/JMAPContacts/card_set_create_name_unknown_kind new file mode 100644 index 0000000000..384f2d645b --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_name_unknown_kind @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_name_unknown_kind + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { + isOrdered => JSON::true, + components => [ + { + kind => 'given', + value => 'Robert' + }, + { + kind => 'middle', + value => 'Pau' + }, + { + kind => 'surname', + value => 'Shou' + }, + { + '@type' => 'Name', + kind => 'surname2', + value => 'Chang' + } + ], + sortAs => { + surname => 'Pau Shou Chang', + given => 'Robert' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|JSPROP;JSPTR=name/components:|, $card); + $self->assert_matches(qr/FN;DERIVED=TRUE:No Name/, $card); + $self->assert_does_not_match(qr/\r\nN;/, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_name_unknown_prop b/cassandane/tiny-tests/JMAPContacts/card_set_create_name_unknown_prop new file mode 100644 index 0000000000..e602ce07e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_name_unknown_prop @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_name_unknown_prop + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { + isOrdered => JSON::true, + components => [ + { + kind => 'given', + value => 'Robert', + foo => bar + }, + { + kind => 'given2', + value => 'Pau' + }, + { + kind => 'surname', + value => 'Shou' + }, + { + '@type' => 'Name', + kind => 'surname2', + value => 'Chang' + } + ], + sortAs => { + surname => 'Pau Shou Chang', + given => 'Robert' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|JSPROP;JSPTR=name/components:|, $card); + $self->assert_matches(qr/FN;DERIVED=TRUE:No Name/, $card); + $self->assert_does_not_match(qr/\r\nN;/, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_nicknames b/cassandane/tiny-tests/JMAPContacts/card_set_create_nicknames new file mode 100644 index 0000000000..827c7d7172 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_nicknames @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_nicknames + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'John Doe' }, + nicknames => { + k391 => { + '@type' => 'Nickname', + name => 'Johnny' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/NICKNAME;PROP-ID=k391:Johnny/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_notes b/cassandane/tiny-tests/JMAPContacts/card_set_create_notes new file mode 100644 index 0000000000..cbdbb1ac29 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_notes @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_notes + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + notes => { + 'NOTE-1' => { + '@type' => 'Note', + note => 'Office hours are from 0800 to 1715 EST, Mon-Fri.', + created => '2022-11-23T15:01:32Z', + author => { + '@type' => 'Author', + name => 'John' + } + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|NOTE;AUTHOR-NAME=John;PROP-ID=NOTE-1;CREATED=20221123T150132Z:Office hours are from 0800 to 1715 EST\\, Mon-Fri.|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_online b/cassandane/tiny-tests/JMAPContacts/card_set_create_online new file mode 100644 index 0000000000..ae3fca7cb8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_online @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_online + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + onlineServices => { + x1 => { + '@type' => 'OnlineService', + uri => 'xmpp:alice@example.com', + vCardName => 'impp', + pref => 1 + }, + x2 => { + '@type' => 'OnlineService', + service => 'Mastodon', + user => '@foo@example.com', + uri => 'https://example.com/@foo' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|IMPP;PROP-ID=x1;PREF=1:xmpp:alice\@example.com|, $card); + $self->assert_matches(qr|SOCIALPROFILE;SERVICE-TYPE=Mastodon;USERNAME=\@foo\@example.com;PROP-ID=x2:https://example.com/\@foo|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_organizations b/cassandane/tiny-tests/JMAPContacts/card_set_create_organizations new file mode 100644 index 0000000000..2d3dab1417 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_organizations @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_organizations + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'John Doe' }, + organizations => { + o1 => { + '@type' => 'Organization', + name => 'ABC, Inc.', + units => [ + { + '@type' => 'OrgUnit', + name => 'North American Division' + }, + { + '@type' => 'OrgUnit', + name => 'Marketing' + } + ], + sortAs => 'ABC' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/ORG;SORT-AS=ABC;PROP-ID=o1:ABC\\, Inc.;North American Division;Marketing/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_personal b/cassandane/tiny-tests/JMAPContacts/card_set_create_personal new file mode 100644 index 0000000000..4f41e55bd1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_personal @@ -0,0 +1,97 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_personal + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + personalInfo => { + 'PERSINFO-1' => { + '@type' => 'PersonalInfo', + kind => 'expertise', + value => 'chinese literature', + level => 'low', + listAs => 2 + }, + 'PERSINFO-2' => { + '@type' => 'PersonalInfo', + kind => 'expertise', + value => 'chemistry', + level => 'high', + listAs => 1 + }, + 'PERSINFO-3' => { + '@type' => 'PersonalInfo', + kind => 'hobby', + value => 'reading', + level => 'low', + listAs => 1 + }, + 'PERSINFO-4' => { + '@type' => 'PersonalInfo', + kind => 'hobby', + value => 'sewing', + level => 'high', + listAs => 2 + }, + 'PERSINFO-5' => { + '@type' => 'PersonalInfo', + kind => 'interest', + value => 'r&b music', + level => 'medium', + listAs => 1 + }, + 'PERSINFO-6' => { + '@type' => 'PersonalInfo', + kind => 'interest', + value => 'rock&roll music', + level => 'high', + listAs => 2 + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|EXPERTISE;PROP-ID=PERSINFO-1;LEVEL=BEGINNER;INDEX=2:chinese literature|, $card); + $self->assert_matches(qr|EXPERTISE;PROP-ID=PERSINFO-2;LEVEL=EXPERT;INDEX=1:chemistry|, $card); + $self->assert_matches(qr|HOBBY;PROP-ID=PERSINFO-3;LEVEL=LOW;INDEX=1:reading|, $card); + $self->assert_matches(qr|HOBBY;PROP-ID=PERSINFO-4;LEVEL=HIGH;INDEX=2:sewing|, $card); + $self->assert_matches(qr|INTEREST;PROP-ID=PERSINFO-5;LEVEL=MEDIUM;INDEX=1:r&b music|, $card); + $self->assert_matches(qr|INTEREST;PROP-ID=PERSINFO-6;LEVEL=HIGH;INDEX=2:rock&roll music|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_phones b/cassandane/tiny-tests/JMAPContacts/card_set_create_phones new file mode 100644 index 0000000000..4aa545892f --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_phones @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_phones + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + phones => { + tel0 => { + '@type' => 'Phone', + contexts => { + private => JSON::true + }, + features => { + voice => JSON::true + }, + number => 'tel:+1-555-555-5555;ext=5555', + pref => 1 + }, + tel3 => { + '@type' => 'Phone', + contexts => { + work => JSON::true + }, + number => 'tel:+1-201-555-0123', + label => 'foo' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/TEL;VALUE=URI;PROP-ID=tel0;TYPE=VOICE,HOME;PREF=1:tel:\+1-555-555-5555;ext=55/, $card); + $self->assert_matches(qr/tel0.TEL;VALUE=URI;PROP-ID=tel3;TYPE=WORK:tel:\+1-201-555-0123/, $card); + $self->assert_matches(qr/tel0.X-ABLabel:foo/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_preferred_languages b/cassandane/tiny-tests/JMAPContacts/card_set_create_preferred_languages new file mode 100644 index 0000000000..6fe6534717 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_preferred_languages @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_preferred_languages + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + preferredLanguages => { + l1 => { + '@type' => 'LanguagePref', + language => 'en', + contexts => { work => JSON::true }, + pref => 1 + }, + l2 => { + '@type' => 'LanguagePref', + language => 'fr', + contexts => { work => JSON::true }, + pref => 2 + }, + l3 => { + language => 'fr', + contexts => { private => JSON::true } + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/LANG;PROP-ID=l1;PREF=1;TYPE=WORK:en/, $card); + $self->assert_matches(qr/LANG;PROP-ID=l2;PREF=2;TYPE=WORK:fr/, $card); + $self->assert_matches(qr/LANG;PROP-ID=l3;TYPE=HOME:fr/, $card); + $self->assert_does_not_match(qr/JSPROP/, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_relatedto b/cassandane/tiny-tests/JMAPContacts/card_set_create_relatedto new file mode 100644 index 0000000000..09fc73d117 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_relatedto @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_relatedto + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'John Doe' }, + relatedTo => { + 'urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6' => { + '@type' => 'Relation', + relation => { + friend => JSON::true + } + }, + 'https://example.com/directory/john.vcf' => { + '@type' => 'Relation', + relation => { + contact => JSON::true + } + }, + 'Please contact my deputy John for any inquiries.' => { + '@type' => 'Relation', + relation => { } + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|RELATED;TYPE=FRIEND:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6|, $card); + $self->assert_matches(qr|RELATED;TYPE=CONTACT:https://example.com/directory/john.vcf|, $card); + $self->assert_matches(qr|RELATED;VALUE=TEXT:Please contact my deputy John for any inquiries.|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_sched_addrs b/cassandane/tiny-tests/JMAPContacts/card_set_create_sched_addrs new file mode 100644 index 0000000000..a00587dd6d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_sched_addrs @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_schedaddrs + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + schedulingAddresses => { + sched1 => { + '@type' => 'SchedulingAddress', + uri => 'mailto:janedoe@example.com', + contexts => { + private => JSON::true + } + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/CALADRURI;PROP-ID=sched1;TYPE=HOME:mailto:janedoe\@example.com/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_speaktoas b/cassandane/tiny-tests/JMAPContacts/card_set_create_speaktoas new file mode 100644 index 0000000000..e1725e23b2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_speaktoas @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_speaktoas + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'John Doe' }, + speakToAs => { + '@type' => 'SpeakToAs', + grammaticalGender => 'neuter', + pronouns => { + k19 => { + pronouns => 'they/them', + pref => 2 + }, + k32 => { + '@type' => 'Pronouns', + pronouns => 'xe/xir', + pref => 1 + } + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|GRAMGENDER:NEUTER|, $card); + $self->assert_matches(qr|PRONOUNS;PROP-ID=k19;PREF=2:they/them|, $card); + $self->assert_matches(qr|PRONOUNS;PROP-ID=k32;PREF=1:xe/xir|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_titles b/cassandane/tiny-tests/JMAPContacts/card_set_create_titles new file mode 100644 index 0000000000..917938979c --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_titles @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_titles + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + titles => { + 'TITLE-1' => { + '@type' => 'Title', + kind => 'title', + name => 'Project Leader' + }, + 'TITLE-2' => { + '@type' => 'Title', + kind => 'role', + name => 'Research Scientist', + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/ROLE;PROP-ID=TITLE-2:Research Scientist/, $card); + $self->assert_matches(qr/TITLE;PROP-ID=TITLE-1:Project Leader/, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_create_titles_org b/cassandane/tiny-tests/JMAPContacts/card_set_create_titles_org new file mode 100644 index 0000000000..a16066b3da --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_create_titles_org @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_create_titles_org + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + titles => { + 'TITLE-1' => { + '@type' => 'Title', + kind => 'title', + name => 'Project Leader' + }, + 'TITLE-2' => { + '@type' => 'Title', + kind => 'role', + name => 'Research Scientist', + organizationId => 'ORG-1' + } + }, + organizations => { + 'ORG-1' => { + '@type' => 'Organization', + name => 'ABC, Inc.' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/TITLE;PROP-ID=TITLE-1:Project Leader/, $card); + $self->assert_matches(qr/ORG-1.ROLE;PROP-ID=TITLE-2:Research Scientist/, $card); + $self->assert_matches(qr/ORG-1.ORG;PROP-ID=ORG-1:ABC\\, Inc./, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_destroy b/cassandane/tiny-tests/JMAPContacts/card_set_destroy new file mode 100644 index 0000000000..81b6e8f4e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_destroy @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_destroy + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/test.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { }, 'R1'] + ]); + my $cardId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($cardId); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + destroy => [$cardId] + }, 'R1'] + ]); + + $self->assert_str_equals($cardId, $res->[0][1]{destroyed}[0]); + + $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_importance_float b/cassandane/tiny-tests/JMAPContacts/card_set_importance_float new file mode 100644 index 0000000000..5326913724 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_importance_float @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_importance_float + :min_version_3_5 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + c1 => { + name => { full => 'John Doe' }, + 'cyrusimap.org:importance' => -122.129545321514, + }, + }, + }, 'R1'], + ['ContactCard/get', { + ids => ['#c1'], + properties => ['cyrusimap.org:importance'], + }, 'R2'], + ]); + my $contactId = $res->[0][1]{created}{c1}{id}; + $self->assert_not_null($contactId); + $self->assert_equals(-122.129545321514, + $res->[1][1]{list}[0]{'cyrusimap.org:importance'}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_importance_later b/cassandane/tiny-tests/JMAPContacts/card_set_importance_later new file mode 100644 index 0000000000..1a324ca91a --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_importance_later @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_importance_later + :min_version_3_1 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with no importance"; + my $res = $jmap->CallMethods([['ContactCard/set', + {create => {"1" => {name => { full => "John Doe" }}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('John Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(0.0, + $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); + + $res = $jmap->CallMethods([['ContactCard/set', + {update => {$id => {"cyrusimap.org:importance" => -0.1}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('John Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(-0.1, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_importance_multiedit b/cassandane/tiny-tests/JMAPContacts/card_set_importance_multiedit new file mode 100644 index 0000000000..e7bad9825f --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_importance_multiedit @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_importance_multiedit + :min_version_3_1 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with no importance"; + my $res = $jmap->CallMethods([['ContactCard/set', + {create => {"1" => {name => { full => "John Doe" }, + "cyrusimap.org:importance" => -5.2}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('John Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); + + $res = $jmap->CallMethods([['ContactCard/set', + {update => {$id => {"name" => { full => "Jane Doe" }, + "cyrusimap.org:importance" => -0.2}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('Jane Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(-0.2, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_importance_peruser b/cassandane/tiny-tests/JMAPContacts/card_set_importance_peruser new file mode 100644 index 0000000000..28b28fa2d5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_importance_peruser @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_importance_peruser + :min_version_3_5 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:contacts', + 'https://cyrusimap.org/ns/jmap/contacts', + ]); + $admin->setacl("user.cassandane.#addressbooks.Default", + "manifold" => 'lrswipkxtecdn') or die; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + c1 => { + name => { full => 'John Doe' }, + 'cyrusimap.org:importance' => 1.0, + }, + }, + }, 'R1'], + ['ContactCard/get', { + ids => ['#c1'], + properties => ['cyrusimap.org:importance'], + }, 'R2'], + ]); + my $contactId = $res->[0][1]{created}{c1}{id}; + $self->assert_not_null($contactId); + $self->assert_equals(1.0, $res->[1][1]{list}[0]{'cyrusimap.org:importance'}); + + $res = $manjmap->CallMethods([ + ['ContactCard/get', { + accountId => 'cassandane', + ids => [$contactId], + properties => ['cyrusimap.org:importance'], + }, 'R1'], + ['ContactCard/set', { + accountId => 'cassandane', + update => { + $contactId => { + 'cyrusimap.org:importance' => 2.0, + }, + }, + }, 'R2'], + ['ContactCard/get', { + accountId => 'cassandane', + ids => [$contactId], + properties => ['cyrusimap.org:importance'], + }, 'R3'], + ]); + + $self->assert_equals(1.0, $res->[0][1]{list}[0]{'cyrusimap.org:importance'}); + $self->assert(exists $res->[1][1]{updated}{$contactId}); + $self->assert_equals(2.0, $res->[2][1]{list}[0]{'cyrusimap.org:importance'}); + + $res = $jmap->CallMethods([ + ['ContactCard/get', { + ids => ['#c1'], + properties => ['cyrusimap.org:importance'], + }, 'R1'], + ]); + $self->assert_equals(1.0, $res->[0][1]{list}[0]{'cyrusimap.org:importance'}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_importance_shared b/cassandane/tiny-tests/JMAPContacts/card_set_importance_shared new file mode 100644 index 0000000000..5fddf426ac --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_importance_shared @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_importance_shared + :min_version_3_1 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "create contact"; + my $res = $jmap->CallMethods([['ContactCard/set', { + accountId => 'manifold', + create => {"1" => {name => { full => "John Doe" }}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + $admintalk->setacl("user.manifold.#addressbooks.Default", + "cassandane" => 'lrsn') or die; + + xlog $self, "update importance"; + $res = $jmap->CallMethods([['ContactCard/set', { + accountId => 'manifold', + update => {$id => {"cyrusimap.org:importance" => -0.1}} + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_importance_upfront b/cassandane/tiny-tests/JMAPContacts/card_set_importance_upfront new file mode 100644 index 0000000000..195645aa65 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_importance_upfront @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_importance_upfront + :min_version_3_1 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with importance in initial create"; + my $res = $jmap->CallMethods([['ContactCard/set', + {create => {"1" => {name => { full => "John Doe" }, + "cyrusimap.org:importance" => -5.2}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('John Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); + + $res = $jmap->CallMethods([['ContactCard/set', + {update => {$id => {"name" => { full => "Jane Doe" }}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('Jane Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_importance_zero_byself b/cassandane/tiny-tests/JMAPContacts/card_set_importance_zero_byself new file mode 100644 index 0000000000..37e394b7ce --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_importance_zero_byself @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_importance_zero_byself + :min_version_3_1 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with no importance"; + my $res = $jmap->CallMethods([['ContactCard/set', + {create => {"1" => {name => { full => "John Doe" }, + "cyrusimap.org:importance" => -5.2}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('John Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); + + $res = $jmap->CallMethods([['ContactCard/set', + {update => {$id => {"cyrusimap.org:importance" => 0}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('John Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(0, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_importance_zero_multi b/cassandane/tiny-tests/JMAPContacts/card_set_importance_zero_multi new file mode 100644 index 0000000000..9d8427571a --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_importance_zero_multi @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_importance_zero_multi + :min_version_3_1 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with no importance"; + my $res = $jmap->CallMethods([['ContactCard/set', + {create => {"1" => {name => { full => "John Doe" }, + "cyrusimap.org:importance" => -5.2}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('John Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); + + $res = $jmap->CallMethods([['ContactCard/set', + {update => {$id => {name => { full => "Jane Doe" }, + "cyrusimap.org:importance" => 0}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['ContactCard/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('ContactCard/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('Jane Doe', $fetch->[0][1]{list}[0]{name}{full}); + $self->assert_num_equals(0, $fetch->[0][1]{list}[0]{"cyrusimap.org:importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_state b/cassandane/tiny-tests/JMAPContacts/card_set_state new file mode 100644 index 0000000000..524fb6bf55 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_state @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_state + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contact"; + my $name = 'Mr. John Q. Public, Esq.'; + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => $name } + } + } + }, 'R1'] + ]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + my $state = $res->[0][1]{newState}; + + xlog $self, "get contact $id"; + $res = $jmap->CallMethods([['ContactCard/get', {}, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/get', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + $self->assert_str_equals($name, $res->[0][1]{list}[0]{name}{full}); + $self->assert_str_equals($state, $res->[0][1]{state}); + + xlog $self, "update $id with state token $state"; + $res = $jmap->CallMethods([['ContactCard/set', { + ifInState => $state, + update => {$id => + {name => { full => $name }} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert(exists $res->[0][1]{updated}{$id}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "update $id with expired state token $oldState"; + $res = $jmap->CallMethods([['ContactCard/set', { + ifInState => $oldState, + update => {$id => + {name => { full => $name }} + }}, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "get contact $id to make sure state didn't change"; + $res = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]{state}); + + xlog $self, "destroy $id with expired state token $oldState"; + $res = $jmap->CallMethods([['ContactCard/set', { + ifInState => $oldState, + destroy => [$id] + }, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "destroy contact $id with current state"; + $res = $jmap->CallMethods([ + ['ContactCard/set', { + ifInState => $state, + destroy => [$id] + }, "R1"] + ]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_uid_text b/cassandane/tiny-tests/JMAPContacts/card_set_uid_text new file mode 100644 index 0000000000..803abb9ec2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_uid_text @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_uid_text + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + my $res = $jmap->CallMethods([ + ['AddressBook/set', { + create => { "1" => { name => "foo" }} + }, "R1"] + ]); + my $abookid = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($abookid); + + my $id = 'e2640cc234ad93b9@example.com'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + addressBookId => $abookid, + uid => $id, + kind => 'individual', + name => { full => 'foo' } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + $self->assert_not_null($res->[0][1]{created}{1}{id}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/VERSION:4.0/, $card); + $self->assert_matches(qr/UID;VALUE=TEXT:$id/, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_update b/cassandane/tiny-tests/JMAPContacts/card_set_update new file mode 100644 index 0000000000..ca5bb64f20 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_update @@ -0,0 +1,178 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_update + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'John Doe' }, + nicknames => { + k391 => { + '@type' => 'Nickname', + name => 'Johnny' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + 'nicknames/k391/name' => 'Johnny Boy', + 'nicknames/foo' => { + '@type' => 'Nickname', + name => 'Doey' + } + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{updated}{$id}); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/NICKNAME;PROP-ID=foo:Doey/, $card); + $self->assert_matches(qr/NICKNAME;PROP-ID=k391:Johnny Boy/, $card); + $self->assert_does_not_match(qr/JSPROP/, $card); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + 'nicknames/k391' => JSON::null + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{updated}{$id}); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/NICKNAME;PROP-ID=foo:Doey/, $card); + $self->assert_does_not_match(qr/NICKNAME;PROP-ID=k391:Johnny Boy/, $card); + $self->assert_does_not_match(qr/JSPROP/, $card); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + 'cyrusimap.org:importance' => -0.1 + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{updated}{$id}); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + 'cyrusimap.org:importance' => 0.0, + keywords => { foo => JSON::true } + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{updated}{$id}); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/CATEGORIES:foo/, $card); + $self->assert_does_not_match(qr/JSPROP/, $card); + + xlog $self, "create alternate addressbook"; + $res = $jmap->CallMethods([ + ['AddressBook/set', { create => { "1" => { + name => "foo" + }}}, "R1"] + ]); + + my $abookid = $res->[0][1]{created}{"1"}{id}; + my $href = "$abookid/$id.vcf"; + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + addressBookId => $abookid + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{updated}{$id}); + $self->assert_not_null($res->[0][1]{updated}{$id}{updated}); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/UID:$id/, $card); + $self->assert_matches(qr/NICKNAME;PROP-ID=foo:Doey/, $card); + $self->assert_matches(qr/CATEGORIES:foo/, $card); + $self->assert_matches(qr/REV:/, $card); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + kind => 'group' + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{notUpdated}{$id}); + $self->assert_str_equals("invalidProperties", + $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("kind", + $res->[0][1]{notUpdated}{$id}{properties}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_update_extra_rejected b/cassandane/tiny-tests/JMAPContacts/card_set_update_extra_rejected new file mode 100644 index 0000000000..0200b27324 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_update_extra_rejected @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_update_extra_rejected + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + card1 => { + '@type' => 'Card', + name => { + full => 'John', + }, + }, + }, + }, 'R1'], + ]); + my $cardId = $res->[0][1]{created}{card1}{id}; + $self->assert_not_null($cardId); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $cardId => { + extra => 'reserved', + 'name/extra' => 'reserved', + localizations => { + de => { + 'name/extra' => 'reserved2', + }, + }, + }, + }, + }, 'R1'], + ]); + + $self->assert_null($res->[0][1]{created}{card1}); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{$cardId}{type}); + + my @wantInvalidProps = ( + "extra", + "localizations/de/name~1extra", + "name/extra", + ); + my @haveInvalidProps = sort @{$res->[0][1]{notUpdated}{$cardId}{properties}}; + $self->assert_deep_equals(\@wantInvalidProps, \@haveInvalidProps); +} diff --git a/cassandane/tiny-tests/JMAPContacts/card_set_update_media_blob b/cassandane/tiny-tests/JMAPContacts/card_set_update_media_blob new file mode 100644 index 0000000000..50ef77a0ea --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/card_set_update_media_blob @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_card_set_update_media_blob + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + name => { full => 'Jane Doe' }, + media => { + res1 => { + '@type' => 'MediaResource', + kind => 'photo', + uri => 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA=' + } + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|PHOTO;PROP-ID=res1:data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA=|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); + + xlog $self, "upload photo"; + my $res = $jmap->Upload("some photo", "image/jpeg"); + my $blobId = $res->{blobId}; + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + 'media/res1/blobId' => $blobId + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{updated}{$id}); + $blobId = $res->[0][1]{updated}{$id}{media}{res1}{blobId}; + $self->assert_not_null($blobId); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|PHOTO;PROP-ID=res1:data:image/jpeg;base64,c29tZSBwaG90bw==|, $card); + $self->assert_does_not_match(qr|JSPROP|, $card); + + $res = $jmap->Download('cassandane', $blobId); + + $self->assert_str_equals('image/jpeg', $res->{headers}{'content-type'}); + $self->assert_str_equals('some photo', $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/cardgroup_changes b/cassandane/tiny-tests/JMAPContacts/cardgroup_changes new file mode 100644 index 0000000000..ef8b51a8fb --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/cardgroup_changes @@ -0,0 +1,142 @@ +#!perl +use Cassandane::Tiny; + +sub test_cardgroup_changes + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + +# Update to Card[Group]/[get|set] once implemented + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "a" => {firstName => "a", lastName => "a"}, + "b" => {firstName => "b", lastName => "b"}, + "c" => {firstName => "c", lastName => "c"}, + "d" => {firstName => "d", lastName => "d"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $contactA = $res->[0][1]{created}{"a"}{id}; + my $contactB = $res->[0][1]{created}{"b"}{id}; + my $contactC = $res->[0][1]{created}{"c"}{id}; + my $contactD = $res->[0][1]{created}{"d"}{id}; + + xlog $self, "get contact groups state"; + $res = $jmap->CallMethods([['ContactGroup/get', {}, "R2"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "create contact group 1"; + $res = $jmap->CallMethods([['ContactGroup/set', {create => { + "1" => {name => "first", contactIds => [$contactA, $contactB]}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + + + xlog $self, "get contact group updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "create contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', {create => { + "2" => {name => "second", contactIds => [$contactC, $contactD]}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get contact group updates (since last change)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get contact group updates (in bulk)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $oldState + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get contact group updates from initial state (maxChanges=1)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $oldState, + maxChanges => 1 + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + my $interimState = $res->[0][1]{newState}; + + xlog $self, "get contact group updates from interim state (maxChanges=10)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $interimState, + maxChanges => 10 + }, "R2"]]); + $self->assert_str_equals($interimState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "destroy contact group 1, update contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', { + destroy => [$id1], + update => {$id2 => {name => "second (updated)"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get contact group updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id2, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', {destroy => [$id2]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/cardgroup_changes_shared b/cassandane/tiny-tests/JMAPContacts/cardgroup_changes_shared new file mode 100644 index 0000000000..36d33ae173 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/cardgroup_changes_shared @@ -0,0 +1,179 @@ +#!perl +use Cassandane::Tiny; + +sub test_cardgroup_changes_shared + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrswipkxtecdn') or die; + +# Update to Card[Group]/[get|set] once implemented + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => { + "a" => {firstName => "a", lastName => "a"}, + "b" => {firstName => "b", lastName => "b"}, + "c" => {firstName => "c", lastName => "c"}, + "d" => {firstName => "d", lastName => "d"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $contactA = $res->[0][1]{created}{"a"}{id}; + my $contactB = $res->[0][1]{created}{"b"}{id}; + my $contactC = $res->[0][1]{created}{"c"}{id}; + my $contactD = $res->[0][1]{created}{"d"}{id}; + + xlog $self, "get contact groups state"; + $res = $jmap->CallMethods([['ContactGroup/get', { accountId => 'manifold', }, "R2"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "create contact group 1"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + create => { + "1" => {name => "first", contactIds => [$contactA, $contactB]}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + + + xlog $self, "get contact group updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "create contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + create => { + "2" => {name => "second", contactIds => [$contactC, $contactD]}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get contact group updates (since last change)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get contact group updates (in bulk)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $oldState + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get contact group updates from initial state (maxChanges=1)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $oldState, + maxChanges => 1 + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + my $interimState = $res->[0][1]{newState}; + + xlog $self, "get contact group updates from interim state (maxChanges=10)"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $interimState, + maxChanges => 10 + }, "R2"]]); + $self->assert_str_equals($interimState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "destroy contact group 1, update contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + destroy => [$id1], + update => {$id2 => {name => "second (updated)"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get contact group updates"; + $res = $jmap->CallMethods([['ContactCard/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id2, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + destroy => [$id2] + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/cardgroup_get_v3 b/cassandane/tiny-tests/JMAPContacts/cardgroup_get_v3 new file mode 100644 index 0000000000..2ca3c19afe --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/cardgroup_get_v3 @@ -0,0 +1,78 @@ +#!perl +use Cassandane::Tiny; + +sub test_cardgroup_get_v3 + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $member1 = 'urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af'; + my $member2 = 'urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + my $want_jscard = { + '@type' => 'Card', + version => '1.0', + addressBookId => 'Default', + 'cyrusimap.org:href' => $carddav->fullpath() . $href, + id => $id, + uid => $id, + kind => 'group', + vCardProps => [ + [ 'version', {}, 'text', '3.0' ] + ], + name => { + full => 'The Doe Family' + }, + members => { + $member1 => JSON::true, + $member2 => JSON::true + } + }; + + + my $have_jscard = $res->[0][1]{list}[0]; + + # Delete generated fields + delete $have_jscard->{blobId}; + delete $have_jscard->{'cyrusimap.org:blobId'}; + delete $have_jscard->{'cyrusimap.org:size'}; + + # Normalize and compare cards + normalize_jscard($want_jscard); + normalize_jscard($have_jscard); + $self->assert_deep_equals($want_jscard, $have_jscard); +} diff --git a/cassandane/tiny-tests/JMAPContacts/cardgroup_get_v4 b/cassandane/tiny-tests/JMAPContacts/cardgroup_get_v4 new file mode 100644 index 0000000000..0faff36064 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/cardgroup_get_v4 @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_cardgroup_get_v4 + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $member1 = 'urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af'; + my $member2 = 'urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + my $want_jscard = { + '@type' => 'Card', + version => '1.0', + addressBookId => 'Default', + 'cyrusimap.org:href' => $carddav->fullpath() . $href, + id => $id, + uid => $id, + kind => 'group', + vCardProps => [ + [ 'version', {}, 'text', '4.0' ] + ], + name => { + full => 'The Doe Family' + }, + members => { + $member1 => JSON::true, + $member2 => JSON::true + } + }; + + + my $have_jscard = $res->[0][1]{list}[0]; + + # Delete generated fields + delete $have_jscard->{blobId}; + delete $have_jscard->{'cyrusimap.org:blobId'}; + delete $have_jscard->{'cyrusimap.org:size'}; + + # Normalize and compare cards + normalize_jscard($want_jscard); + normalize_jscard($have_jscard); + $self->assert_deep_equals($want_jscard, $have_jscard); +} diff --git a/cassandane/tiny-tests/JMAPContacts/cardgroup_query b/cassandane/tiny-tests/JMAPContacts/cardgroup_query new file mode 100644 index 0000000000..a36a2b8b58 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/cardgroup_query @@ -0,0 +1,244 @@ +#!perl +use Cassandane::Tiny; + +sub test_cardgroup_query + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create cards"; + my $res = $jmap->CallMethods([['ContactCard/set', { + create => { + "1" => { + name => { + components => [ + { + kind => "given", + value => "foo" + }, + { + kind => "surname", + value => "last" + }, + ], + sortAs => { + surname => 'aaa' + } + }, + nicknames => { + 'n1' => { + name => "foo" + } + }, + emails => { + 'e1' => { + contexts => { + private => JSON::true + }, + address => "foo\@example.com" + } + }, + personalInfo => { + 'p1' => { + kind => 'hobby', + value => 'reading' + } + } + }, + "2" => { + name => { + components => [ + { + kind => "given", + value => "bar" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + emails => { + 'e1' => { + contexts => { + work => JSON::true + }, + address => "bar\@bar.org" + }, + 'e2' => { + contexts => { + other => JSON::true + }, + address => "me\@example.com" + } + }, + addresses => { + 'a1' => { + contexts => { + private => JSON::true + }, + components => [ + { + kind => "name", + value => "Some Lane" + }, + { + kind => "number", + value => "24" + }, + { + kind => 'locality', + value => "SomeWhere City" + }, + { + kind => 'region', + value => "" + }, + { + kind => 'postcode', + value => "1234" + } + ], + } + } + }, + "3" => { + name => { + components => [ + { + kind => "given", + value => "baz" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + addresses => { + 'a1' => { + contexts => { + private => JSON::true + }, + components => [ + { + kind => "name", + value => "Some Lane" + }, + { + kind => "number", + value => "24" + }, + { + kind => 'locality', + value => "SomeWhere City" + }, + { + kind => 'postcode', + value => "1234" + }, + { + kind => 'country', + value => "Someinistan" + } + ], + } + }, + personalInfo => { + 'p1' => { + kind => 'interest', + value => 'r&b music' + } + } + }, + "4" => { + name => { + components => [ + { + kind => "given", + value => "bam" + }, + { + kind => "surname", + value => "last" + }, + ] + }, + nicknames => { + 'n1' => { + name => "bam" + } + }, + notes => { + 'n1' => { + note => "hello" + } + } + } + } + }, "R1"]]); + + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + my $id3 = $res->[0][1]{created}{"3"}{id}; + my $id4 = $res->[0][1]{created}{"4"}{id}; + + xlog $self, "create card groups"; + $res = $jmap->CallMethods([['ContactCard/set', {create => { + "1" => { kind => 'group', + name => { full => "group1" }, + members => { $id1 => JSON::true, $id2 => JSON::true } + }, + "2" => { kind => 'group', + name => { full => "group2" }, + members => { $id3 => JSON::true } + }, + "3" => { kind => 'group', + name => { full => "group3" }, + members => { $id4 => JSON::true } + } + }}, "R1"]]); + + $self->assert_not_null($res); + $self->assert_str_equals('ContactCard/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $group1 = $res->[0][1]{created}{"1"}{id}; + my $group2 = $res->[0][1]{created}{"2"}{id}; + my $group3 = $res->[0][1]{created}{"3"}{id}; + + xlog $self, "filter by kind"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { kind => "group" } + }, "R1"] ]); + + $self->assert_num_equals(3, $res->[0][1]{total}); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by group name (fullName)"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { kind => "group", name => "group1" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($group1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by group name (fullName)"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { kind => "group", name => "group" } + }, "R1"] ]); + $self->assert_num_equals(0, $res->[0][1]{total}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by member"; + $res = $jmap->CallMethods([ ['ContactCard/query', { + filter => { kind => "group", hasMember => $id3 } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($group2, $res->[0][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/cardgroup_set_create b/cassandane/tiny-tests/JMAPContacts/cardgroup_set_create new file mode 100644 index 0000000000..f66fc518ad --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/cardgroup_set_create @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_cardgroup_set_create + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $name = 'The Doe Family'; + my $member1 = "urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af"; + my $member2 = "urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519"; + my $id = 'urn:uuid:ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + kind => 'group', + name => { full => $name }, + members => { + $member1 => JSON::true, + $member2 => JSON::true + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[0][1]{created}{1}{'cyrusimap.org:href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/VERSION:4.0/, $card); + $self->assert_matches(qr/KIND:GROUP/, $card); + $self->assert_matches(qr/UID:$id/, $card); + $self->assert_matches(qr/FN:$name/, $card); + $self->assert_matches(qr/MEMBER:$member1/, $card); + $self->assert_matches(qr/MEMBER:$member2/, $card); + $self->assert_does_not_match(qr/N:;/, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/cardgroup_set_destroy b/cassandane/tiny-tests/JMAPContacts/cardgroup_set_destroy new file mode 100644 index 0000000000..49005015f8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/cardgroup_set_destroy @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_cardgroup_set_destroy + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $member1 = '03a0e51f-d1aa-4385-8a53-e29025acd8af'; + my $member2 = 'b8767877-b4a1-4c70-9acc-505d3819e519'; + my $href = "Default/test.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactCard/get', { } , 'R1'] + ]); + my $cardId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($cardId); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + destroy => [$cardId] + }, 'R1'] + ]); + + $self->assert_str_equals($cardId, $res->[0][1]{destroyed}[0]); + + $res = $jmap->CallMethods([ + ['ContactCard/get', { + }, 'R1'] + ]); + + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/cardgroup_set_update b/cassandane/tiny-tests/JMAPContacts/cardgroup_set_update new file mode 100644 index 0000000000..a40c674bdd --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/cardgroup_set_update @@ -0,0 +1,112 @@ +#!perl +use Cassandane::Tiny; + +sub test_cardgroup_set_update + :min_version_3_9 :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $name = 'The Doe Family'; + my $member1 = "urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af"; + my $member2 = "urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519"; + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/$id.vcf"; + + my $res = $jmap->CallMethods([ + ['ContactCard/set', { + create => { + "1" => { + '@type' => 'Card', + version => '1.0', + uid => $id, + kind => 'group', + name => { full => $name }, + members => { + $member1 => JSON::true + } + } + } + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/MEMBER:$member1/, $card); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + "members/$member2" => JSON::true + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{updated}{$id}); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/MEMBER:$member2/, $card); + $self->assert_matches(qr/MEMBER:$member1/, $card); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + "members/$member1" => JSON::null + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{updated}{$id}); + + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/MEMBER:$member2/, $card); + $self->assert_does_not_match(qr/MEMBER:$member1/, $card); + + $res = $jmap->CallMethods([ + ['ContactCard/set', { + update => { + $id => { + kind => 'individual' + } + } + }, "R2"] + ]); + + $self->assert_not_null($res->[0][1]{notUpdated}{$id}); + $self->assert_str_equals("invalidProperties", + $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("kind", + $res->[0][1]{notUpdated}{$id}{properties}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_apple_label_handling b/cassandane/tiny-tests/JMAPContacts/contact_apple_label_handling new file mode 100644 index 0000000000..997b3d29e2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_apple_label_handling @@ -0,0 +1,119 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_apple_label_handling + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create a contact with 3 labels: unassociated, shared, & unshared"; + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/$id.vcf"; + my $card = <!\$_ +foo.EMAIL;TYPE=work:bubba\@local +bar.X-ABLabel:bar +bar.TEL;VALUE=uri;TYPE="home":tel:+1-555-555-5555 +email0.X-ABLabel:aaa +email0.EMAIL;TYPE=work:shrimp\@local +END:VCARD +EOF + + $card =~ s/\r?\n/\r\n/gs; + + $carddav->Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['addresses', 'emails', 'phones'], + }, 'R1'] + ]); + + $id = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($id); + $self->assert_equals("foo", $res->[0][1]{list}[0]{addresses}[0]{label}); + $self->assert_equals("foo", $res->[0][1]{list}[0]{emails}[0]{label}); + $self->assert_equals("bar", $res->[0][1]{list}[0]{phones}[0]{label}); + + xlog $self, "update contact"; + $res = $jmap->CallMethods([['Contact/set', { + update => { + $id => { + emails => [{ + type => "work", + label => undef, + value => "bubba\@local" + }, + { + type => "work", + label => "aaa", + value => "shrimp\@local" + }, + { + type => "personal", + label => "bbb", + value => "gump\@local" + }], + phones => [{ + type => "home", + label => undef, + value => "tel:+1-555-555-5555" + }] + } + } + }, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['addresses', 'emails', 'phones', 'blobId'], + }, 'R1'] + ]); + + $self->assert_equals("foo", $res->[0][1]{list}[0]{addresses}[0]{label}); + $self->assert_null($res->[0][1]{list}[0]{emails}[0]{label}); + $self->assert_null($res->[0][1]{list}[0]{phones}[0]{label}); + + xlog $self, "download and check content"; + my $blob = $jmap->Download({ accept => 'text/vcard' }, + 'cassandane', $res->[0][1]{list}[0]{blobId}); + + $self->assert_matches(qr/X-ABLabel:this-should-not-crash-cyrus/, + $blob->{content}); + + $self->assert_matches(qr/foo\.X-ABLabel/, $blob->{content}); + $self->assert_matches(qr/foo\.ADR/, $blob->{content}); + + $self->assert_null(grep { m/foo\.EMAIL/ } $blob->{content}); + $self->assert_null(grep { m/email0\./ } $blob->{content}); + $self->assert_matches(qr/email1\.X-ABLabel/, $blob->{content}); + $self->assert_matches(qr/email1\.EMAIL/, $blob->{content}); + $self->assert_matches(qr/email2\.X-ABLabel/, $blob->{content}); + $self->assert_matches(qr/email2\.EMAIL/, $blob->{content}); + + $self->assert_null(grep { m/bar\.X-ABLabel/ } $blob->{content}); + $self->assert_null(grep { m/bar\.TEL/ } $blob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_blobid b/cassandane/tiny-tests/JMAPContacts/contact_blobid new file mode 100644 index 0000000000..8cf6458569 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_blobid @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_blobid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contact"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "1" => { firstName => "foo", lastName => "last1" }, + }}, "R1"]]); + my $contactId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($contactId); + + xlog $self, "get contact blobId"; + $res = $jmap->CallMethods([ + ['Contact/get', { + ids => [$contactId], + properties => ['blobId'], + }, 'R2'] + ]); + + # fetch a second time to make sure this works with a cached response + $res = $jmap->CallMethods([ + ['Contact/get', { + ids => [$contactId], + properties => ['blobId'], + }, 'R2'] + ]); + my $blobId = $res->[0][1]{list}[0]{blobId}; + $self->assert_not_null($blobId); + + xlog $self, "download blob"; + + $res = $jmap->Download('cassandane', $blobId); + $self->assert_str_equals("BEGIN:VCARD", substr($res->{content}, 0, 11)); + $self->assert_num_not_equals(-1, index($res->{content}, 'FN:foo last1')); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_changes b/cassandane/tiny-tests/JMAPContacts/contact_changes new file mode 100644 index 0000000000..c17541b40c --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_changes @@ -0,0 +1,132 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_changes + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "get contacts"; + my $res = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['Contact/changes', { + sinceState => $state, + addressbookId => "Default", + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + + xlog $self, "create contact 1"; + $res = $jmap->CallMethods([['Contact/set', {create => {"1" => {firstName => "first", lastName => "last"}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['Contact/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "create contact 2"; + $res = $jmap->CallMethods([['Contact/set', {create => {"2" => {firstName => "second", lastName => "prev"}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get contact updates (since last change)"; + $res = $jmap->CallMethods([['Contact/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get contact updates (in bulk)"; + $res = $jmap->CallMethods([['Contact/changes', { + sinceState => $oldState + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get contact updates from initial state (maxChanges=1)"; + $res = $jmap->CallMethods([['Contact/changes', { + sinceState => $oldState, + maxChanges => 1 + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + my $interimState = $res->[0][1]{newState}; + + xlog $self, "get contact updates from interim state (maxChanges=10)"; + $res = $jmap->CallMethods([['Contact/changes', { + sinceState => $interimState, + maxChanges => 10 + }, "R2"]]); + $self->assert_str_equals($interimState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "destroy contact 1, update contact 2"; + $res = $jmap->CallMethods([['Contact/set', { + destroy => [$id1], + update => {$id2 => {firstName => "foo"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['Contact/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id2, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy contact 2"; + $res = $jmap->CallMethods([['Contact/set', {destroy => [$id2]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_changes_shared b/cassandane/tiny-tests/JMAPContacts/contact_changes_shared new file mode 100644 index 0000000000..f04463f06c --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_changes_shared @@ -0,0 +1,169 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_changes_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "get contacts"; + my $res = $jmap->CallMethods([['Contact/get', { accountId => 'manifold' }, "R2"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['Contact/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + + xlog $self, "create contact 1"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => {"1" => {firstName => "first", lastName => "last"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['Contact/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "create contact 2"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => {"2" => {firstName => "second", lastName => "prev"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get contact updates (since last change)"; + $res = $jmap->CallMethods([['Contact/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get contact updates (in bulk)"; + $res = $jmap->CallMethods([['Contact/changes', { + accountId => 'manifold', + sinceState => $oldState + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get contact updates from initial state (maxChanges=1)"; + $res = $jmap->CallMethods([['Contact/changes', { + accountId => 'manifold', + sinceState => $oldState, + maxChanges => 1 + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + my $interimState = $res->[0][1]{newState}; + + xlog $self, "get contact updates from interim state (maxChanges=10)"; + $res = $jmap->CallMethods([['Contact/changes', { + accountId => 'manifold', + sinceState => $interimState, + maxChanges => 10 + }, "R2"]]); + $self->assert_str_equals($interimState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "destroy contact 1, update contact 2"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + destroy => [$id1], + update => {$id2 => {firstName => "foo"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get contact updates"; + $res = $jmap->CallMethods([['Contact/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id2, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy contact 2"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + destroy => [$id2] + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_copy b/cassandane/tiny-tests/JMAPContacts/contact_copy new file mode 100644 index 0000000000..c73f83837d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_copy @@ -0,0 +1,217 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_copy + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared accounts"; + $admintalk->create("user.other"); + $admintalk->create("user.other2"); + $admintalk->create("user.other3"); + + my $othercarddav = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $other2carddav = Net::CardDAVTalk->new( + user => "other2", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $other3carddav = Net::CardDAVTalk->new( + user => "other3", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "share addressbooks"; + $admintalk->setacl("user.other.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + $admintalk->setacl("user.other2.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + $admintalk->setacl("user.other3.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + + # avatar + xlog $self, "upload avatar"; + my $data = "some photo"; + my $res = $jmap->Upload($data, "image/jpeg"); + my $blobid = $res->{blobId}; + + my $card = { + "addressbookId" => "Default", + "firstName"=> "foo", + "lastName"=> "bar", + "avatar" => { + "blobId" => $blobid, + "size" => 10, + "type" => "image/jpeg", + "name" => JSON::null + } + }; + + xlog $self, "create card"; + $res = $jmap->CallMethods([['Contact/set',{ + create => {"1" => $card}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + my $cardId = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "copy card $cardId w/o changes"; + $res = $jmap->CallMethods([['Contact/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $cardId, + addressbookId => "Default", + }, + }, + }, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + my $copiedCardId = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['Contact/get', { + accountId => 'other', + ids => [$copiedCardId], + }, 'R1'], + ['Contact/get', { + accountId => undef, + ids => [$cardId], + }, 'R2'], + ]); + $self->assert_str_equals('foo', $res->[0][1]{list}[0]{firstName}); + my $blob = $jmap->Download({ accept => 'image/jpeg' }, + 'other', $res->[0][1]{list}[0]{avatar}{blobId}); + $self->assert_str_equals('image/jpeg', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_equals($data, $blob->{content}); + + $self->assert_str_equals('foo', $res->[1][1]{list}[0]{firstName}); + $blob = $jmap->Download({ accept => 'image/jpeg' }, + 'cassandane', $res->[1][1]{list}[0]{avatar}{blobId}); + $self->assert_str_equals('image/jpeg', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_equals($data, $blob->{content}); + + xlog $self, "move card $cardId with changes"; + $res = $jmap->CallMethods([['Contact/copy', { + fromAccountId => 'cassandane', + accountId => 'other2', + create => { + 1 => { + id => $cardId, + addressbookId => "Default", + avatar => JSON::null, + nickname => "xxxxx" + }, + } + }, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + $copiedCardId = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['Contact/get', { + accountId => 'other2', + ids => [$copiedCardId], + }, 'R1'], + ['Contact/get', { + accountId => undef, + ids => [$cardId], + }, 'R2'], + ]); + $self->assert_str_equals('foo', $res->[0][1]{list}[0]{firstName}); + $self->assert_str_equals('xxxxx', $res->[0][1]{list}[0]{nickname}); + $self->assert_null($res->[0][1]{list}[0]{avatar}); + $self->assert_str_equals('foo', $res->[1][1]{list}[0]{firstName}); + + my $other3Jmap = Mail::JMAPTalk->new( + user => 'other3', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $other3Jmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + # avatar + xlog $self, "upload avatar for other3"; + $data = "some other photo"; + $res = $other3Jmap->Upload($data, "image/jpeg"); + $blobid = $res->{blobId}; + + $admintalk->setacl("user.other3.#jmap", + "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "move card $cardId with different avatar"; + $res = $jmap->CallMethods([['Contact/copy', { + fromAccountId => 'cassandane', + accountId => 'other3', + create => { + 1 => { + id => $cardId, + addressbookId => "Default", + avatar => { + blobId => "$blobid", + size => 16, + type => "image/jpeg", + name => JSON::null + } + }, + }, + onSuccessDestroyOriginal => JSON::true, + }, + "R1"]]); + $self->assert_not_null($res->[0][1]{created}); + $copiedCardId = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['Contact/get', { + accountId => 'other3', + ids => [$copiedCardId], + }, 'R1'], + ['Contact/get', { + accountId => undef, + ids => [$cardId], + }, 'R2'], + ]); + $self->assert_str_equals('foo', $res->[0][1]{list}[0]{firstName}); + $blob = $jmap->Download({ accept => 'image/jpeg' }, + 'other3', $res->[0][1]{list}[0]{avatar}{blobId}); + $self->assert_str_equals('image/jpeg', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_equals($data, $blob->{content}); + + $self->assert_str_equals($cardId, $res->[1][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_copy_overquota b/cassandane/tiny-tests/JMAPContacts/contact_copy_overquota new file mode 100644 index 0000000000..7d02e1a5b5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_copy_overquota @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_copy_overquota + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared accounts"; + $admintalk->create("user.other"); + + my $othercarddav = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl('user.other.#addressbooks.Default', + 'cassandane' => 'lrswipkxtecdn') or die; + + $self->_set_quotaroot('user.other.#addressbooks'); + $self->_set_quotalimits(storage => 1); + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + 1 => { + lastName => 'name', + notes => ('x' x 1024), + }, + }, + }, 'R1'], + ]); + my $contactId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($contactId); + + $res = $jmap->CallMethods([ + ['Contact/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 2 => { + id => $contactId, + }, + }, + }, 'R1'] + ]); + $self->assert_str_equals('overQuota', $res->[0][1]{notCreated}{2}{type}); + +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_copy_preserve_xprops b/cassandane/tiny-tests/JMAPContacts/contact_copy_preserve_xprops new file mode 100644 index 0000000000..3b4ce1ad62 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_copy_preserve_xprops @@ -0,0 +1,105 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_copy_preserve_xprops + : needs_component_jmap { + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.other"); + + my $otherCarddav = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/contacts', + 'https://cyrusimap.org/ns/jmap/debug' + ]); + + xlog $self, "share addressbook"; + $admintalk->setacl( + "user.other.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn' + ) or die; + + my $card = decode( + 'utf-8', <Request('PUT', 'Default/test.vcf', $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + [ 'Contact/query', {}, 'R1' ], + ]); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + my $contactId = $res->[0][1]{ids}[0]; + $self->assert_not_null($contactId); + + $res = $jmap->CallMethods([ + [ + 'Contact/copy', + { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + contact1 => { + addressbookId => 'Default', + id => $contactId + } + }, + onSuccessDestroyOriginal => JSON::false, + }, + 'R1' + ], + ]); + my $copiedContactId = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($copiedContactId); + + $res = $otherJmap->CallMethods([ + [ + 'Contact/get', + { + accountId => 'other', + ids => [$copiedContactId], + properties => [ 'x-href' ], + }, + 'R1' + ], + ]); + + $card = $otherCarddav->Request('GET', $res->[0][1]{list}[0]{'x-href'}); + $self->assert_matches(qr/^X-FOO;X-BAZ=Bam:Bar\r$/m, $card->{content}); + $self->assert_matches(qr/^X-PHONETIC-FIRST-NAME:phoneticFirst\r$/m, $card->{content}); + $self->assert_matches(qr/^X-PHONETIC-LAST-NAME:phoneticLast\r$/m, $card->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_copy_state b/cassandane/tiny-tests/JMAPContacts/contact_copy_state new file mode 100644 index 0000000000..736cce7bf0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_copy_state @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_copy_state + :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.other"); + + my $othercarddav = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "share addressbook"; + $admintalk->setacl("user.other.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn') or die; + + my $card = { + "addressbookId" => "Default", + "firstName"=> "foo", + "lastName"=> "bar", + }; + + xlog $self, "create card"; + $res = $jmap->CallMethods([ + ['Contact/set', { + create => {"1" => $card} + }, "R1"], + ['Contact/get', { + accountId => 'other', + ids => ['foo'], # Just fetching current state for 'other' + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}); + my $cardId = $res->[0][1]{created}{"1"}{id}; + my $fromState = $res->[0][1]->{newState}; + $self->assert_not_null($fromState); + my $state = $res->[1][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "move card"; + $res = $jmap->CallMethods([ + ['Contact/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + ifFromInState => $fromState, + ifInState => $state, + create => { + 1 => { + id => $cardId, + addressbookId => "Default", + }, + }, + onSuccessDestroyOriginal => JSON::true, + destroyFromIfInState => $fromState, + }, "R1"], + ['Contact/get', { + accountId => 'other', + ids => ['#1'], + properties => ['firstName'], + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}); + my $oldState = $res->[0][1]->{oldState}; + $self->assert_str_equals($oldState, $state); + my $newState = $res->[0][1]->{newState}; + $self->assert_not_null($newState); + $self->assert_str_equals('Contact/set', $res->[1][0]); + $self->assert_str_equals($cardId, $res->[1][1]{destroyed}[0]); + $self->assert_str_equals('foo', $res->[2][1]{list}[0]{firstName}); + + # Is the blobId downloadable? + my $blob = $jmap->Download({ accept => 'text/vcard' }, + 'other', + $res->[0][1]{created}{"1"}{blobId}); + $self->assert_str_equals('text/vcard; version=3.0', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_matches(qr/\r\nFN:foo bar\r\n/, $blob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_get_apple_countrycode b/cassandane/tiny-tests/JMAPContacts/contact_get_apple_countrycode new file mode 100644 index 0000000000..8b8640e609 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_get_apple_countrycode @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_get_apple_countrycode + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['addresses'] + }, 'R1'] + ]); + $self->assert_str_equals('us', $res->[0][1]{list}[0]{addresses}[0]{countryCode}); + $self->assert_str_equals('xyz', $res->[0][1]{list}[0]{addresses}[0]{label}); + $self->assert_str_equals('de', $res->[0][1]{list}[0]{addresses}[1]{countryCode}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_get_avatar_v4 b/cassandane/tiny-tests/JMAPContacts/contact_get_avatar_v4 new file mode 100644 index 0000000000..b45a42d512 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_get_avatar_v4 @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_get_avatar_v4 + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + xlog $self, "create a v4 contact with a photo"; + my $id = '816ad14a-f9ef-43a8-9039-b57bf321de1f'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['avatar', 'x-hasPhoto'], + }, 'R1'] + ]); + + $self->assert_not_null($res->[0][1]{list}[0]{id}); + $self->assert_not_null($res->[0][1]{list}[0]{avatar}); + $self->assert_equals("image/png", $res->[0][1]{list}[0]{avatar}{type}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{'x-hasPhoto'}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_get_invalid_utf8 b/cassandane/tiny-tests/JMAPContacts/contact_get_invalid_utf8 new file mode 100644 index 0000000000..1f93950538 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_get_invalid_utf8 @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_get_invalid_utf8 + :min_version_3_3 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['emails'], + }, 'R1'] + ]); + + my $datadir = $self->{instance}->folder_to_directory("user.cassandane.#addressbooks.Default"); + copy('data/vcard/invalid-utf8.eml', "$datadir/1.") or die; + $self->{instance}->run_command({ cyrus => 1 }, + 'reconstruct', 'user.cassandane.#addressbooks.Default'); + + $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['emails'], + }, 'R1'] + ]); + $self->assert_deep_equals([{ + type => 'work', + value => "beno\N{REPLACEMENT CHARACTER}t\@local", + isDefault => JSON::true, + }], $res->[0][1]{list}[0]{emails}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_get_issue2292 b/cassandane/tiny-tests/JMAPContacts/contact_get_issue2292 new file mode 100644 index 0000000000..ea7911ccf5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_get_issue2292 @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_get_issue2292 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contact"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "1" => { firstName => "foo", lastName => "last1" }, + }}, "R1"]]); + $self->assert_not_null($res->[0][1]{created}{"1"}); + + xlog $self, "get contact with no ids"; + $res = $jmap->CallMethods([['Contact/get', { }, "R3"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + + xlog $self, "get contact with empty ids"; + $res = $jmap->CallMethods([['Contact/get', { ids => [] }, "R3"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + + xlog $self, "get contact with null ids"; + $res = $jmap->CallMethods([['Contact/get', { ids => undef }, "R3"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_get_with_addressbookid b/cassandane/tiny-tests/JMAPContacts/contact_get_with_addressbookid new file mode 100644 index 0000000000..b10cc035ab --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_get_with_addressbookid @@ -0,0 +1,15 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_get_with_addressbookid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "get contact with addressbookid"; + my $res = $jmap->CallMethods([['Contact/get', + { addressbookId => "Default" }, "R3"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_query b/cassandane/tiny-tests/JMAPContacts/contact_query new file mode 100644 index 0000000000..fa231fe3ec --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_query @@ -0,0 +1,174 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_query + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "1" => + { + firstName => "foo", lastName => "last", + emails => [{ + type => "personal", + value => "foo\@example.com" + }] + }, + "2" => + { + firstName => "bar", lastName => "last", + emails => [{ + type => "work", + value => "bar\@bar.org" + }, { + type => "other", + value => "me\@example.com" + }], + addresses => [{ + type => "home", + label => undef, + street => "Some Lane 24", + locality => "SomeWhere City", + region => "", + postcode => "1234", + country => "Someinistan", + isDefault => JSON::false + }], + isFlagged => JSON::true + }, + "3" => + { + firstName => "baz", lastName => "last", + addresses => [{ + type => "home", + label => undef, + street => "Some Lane 12", + locality => "SomeWhere City", + region => "", + postcode => "1234", + country => "Someinistan", + isDefault => JSON::false + }] + }, + "4" => {firstName => "bam", lastName => "last", + isFlagged => JSON::false } + }}, "R1"]]); + + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + my $id3 = $res->[0][1]{created}{"3"}{id}; + my $id4 = $res->[0][1]{created}{"4"}{id}; + + xlog $self, "create contact groups"; + $res = $jmap->CallMethods([['ContactGroup/set', {create => { + "1" => {name => "group1", contactIds => [$id1, $id2]}, + "2" => {name => "group2", contactIds => [$id3]}, + "3" => {name => "group3", contactIds => [$id4]} + }}, "R1"]]); + + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $group1 = $res->[0][1]{created}{"1"}{id}; + my $group2 = $res->[0][1]{created}{"2"}{id}; + my $group3 = $res->[0][1]{created}{"3"}{id}; + + xlog $self, "get unfiltered contact list"; + $res = $jmap->CallMethods([ ['Contact/query', { }, "R1"] ]); + + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by firstName"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { firstName => "foo" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by lastName"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { lastName => "last" } + }, "R1"] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by firstName and lastName (one filter)"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { firstName => "bam", lastName => "last" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id4, $res->[0][1]{ids}[0]); + + xlog $self, "filter by firstName and lastName (AND filter)"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { operator => "AND", conditions => [{ + lastName => "last" + }, { + firstName => "baz" + }]} + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id3, $res->[0][1]{ids}[0]); + + xlog $self, "filter by firstName (OR filter)"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { operator => "OR", conditions => [{ + firstName => "bar" + }, { + firstName => "baz" + }]} + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by text"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { text => "some" } + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by email"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { email => "example.com" } + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by isFlagged (true)"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { isFlagged => JSON::true } + }, "R1"] ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id2, $res->[0][1]{ids}[0]); + + xlog $self, "filter by isFlagged (false)"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { isFlagged => JSON::false } + }, "R1"] ]); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by inContactGroup"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { inContactGroup => [$group1, $group3] } + }, "R1"] ]); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by inContactGroup and firstName"; + $res = $jmap->CallMethods([ ['Contact/query', { + filter => { inContactGroup => [$group1, $group3], firstName => "foo" } + }, "R1"] ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_query_shared b/cassandane/tiny-tests/JMAPContacts/contact_query_shared new file mode 100644 index 0000000000..69abf6bcee --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_query_shared @@ -0,0 +1,212 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_query_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => { + "1" => + { + firstName => "foo", lastName => "last", + emails => [{ + type => "personal", + value => "foo\@example.com" + }] + }, + "2" => + { + firstName => "bar", lastName => "last", + emails => [{ + type => "work", + value => "bar\@bar.org" + }, { + type => "other", + value => "me\@example.com" + }], + addresses => [{ + type => "home", + label => undef, + street => "Some Lane 24", + locality => "SomeWhere City", + region => "", + postcode => "1234", + country => "Someinistan", + isDefault => JSON::false + }], + isFlagged => JSON::true + }, + "3" => + { + firstName => "baz", lastName => "last", + addresses => [{ + type => "home", + label => undef, + street => "Some Lane 12", + locality => "SomeWhere City", + region => "", + postcode => "1234", + country => "Someinistan", + isDefault => JSON::false + }] + }, + "4" => {firstName => "bam", lastName => "last", + isFlagged => JSON::false } + }}, "R1"]]); + + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + my $id3 = $res->[0][1]{created}{"3"}{id}; + my $id4 = $res->[0][1]{created}{"4"}{id}; + + xlog $self, "create contact groups"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + create => { + "1" => {name => "group1", contactIds => [$id1, $id2]}, + "2" => {name => "group2", contactIds => [$id3]}, + "3" => {name => "group3", contactIds => [$id4]} + }}, "R1"]]); + + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $group1 = $res->[0][1]{created}{"1"}{id}; + my $group2 = $res->[0][1]{created}{"2"}{id}; + my $group3 = $res->[0][1]{created}{"3"}{id}; + + xlog $self, "get unfiltered contact list"; + $res = $jmap->CallMethods([ ['Contact/query', { accountId => 'manifold' }, "R1"] ]); + + xlog $self, "check total"; + $self->assert_num_equals(4, $res->[0][1]{total}); + xlog $self, "check ids"; + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by firstName"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { firstName => "foo" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by lastName"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { lastName => "last" } + }, "R1"] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by firstName and lastName (one filter)"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { firstName => "bam", lastName => "last" } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id4, $res->[0][1]{ids}[0]); + + xlog $self, "filter by firstName and lastName (AND filter)"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { operator => "AND", conditions => [{ + lastName => "last" + }, { + firstName => "baz" + }]} + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id3, $res->[0][1]{ids}[0]); + + xlog $self, "filter by firstName (OR filter)"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { operator => "OR", conditions => [{ + firstName => "bar" + }, { + firstName => "baz" + }]} + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by text"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { text => "some" } + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by email"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { email => "example.com" } + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by isFlagged (true)"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { isFlagged => JSON::true } + }, "R1"] ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id2, $res->[0][1]{ids}[0]); + + xlog $self, "filter by isFlagged (false)"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { isFlagged => JSON::false } + }, "R1"] ]); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by inContactGroup"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { inContactGroup => [$group1, $group3] } + }, "R1"] ]); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + + xlog $self, "filter by inContactGroup and firstName"; + $res = $jmap->CallMethods([ ['Contact/query', { + accountId => 'manifold', + filter => { inContactGroup => [$group1, $group3], firstName => "foo" } + }, "R1"] ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_query_sort b/cassandane/tiny-tests/JMAPContacts/contact_query_sort new file mode 100644 index 0000000000..8b521bc788 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_query_sort @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_query_sort + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + uid => 'XXX-UID-1', + company => 'companyB', + isFlagged => JSON::true, + }, + contact2 => { + uid => 'XXX-UID-2', + company => 'companyA', + isFlagged => JSON::true, + }, + contact3 => { + uid => 'XXX-UID-3', + company => 'companyB', + isFlagged => JSON::false, + }, + contact4 => { + uid => 'XXX-UID-4', + company => 'companyC', + isFlagged => JSON::true, + }, + }, + }, 'R1'], + ]); + my $contactId1 = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($contactId1); + + my $contactId2 = $res->[0][1]{created}{contact2}{id}; + $self->assert_not_null($contactId2); + + my $contactId3 = $res->[0][1]{created}{contact3}{id}; + $self->assert_not_null($contactId3); + + my $contactId4 = $res->[0][1]{created}{contact4}{id}; + $self->assert_not_null($contactId4); + + xlog $self, "sort by multi-dimensional comparator"; + $res = $jmap->CallMethods([ + ['Contact/query', { + sort => [{ + property => 'company', + }, { + property => 'uid', + isAscending => JSON::false, + }], + }, 'R2'], + ]); + $self->assert_deep_equals([ + $contactId2, + $contactId3, + $contactId1, + $contactId4, + ], $res->[0][1]{ids} + ); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_query_text b/cassandane/tiny-tests/JMAPContacts/contact_query_text new file mode 100644 index 0000000000..db2b0e6391 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_query_text @@ -0,0 +1,134 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_query_text + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + notes => 'cats and dogs', + }, + contact2 => { + notes => 'hats and bats', + }, + }, + }, 'R1'], + ]); + my $contactId1 = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($contactId1); + my $contactId2 = $res->[0][1]{created}{contact2}{id}; + $self->assert_not_null($contactId2); + + xlog "Query with loose terms"; + $res = $jmap->CallMethods([ + ['Contact/query', { + filter => { + notes => "cats dogs", + }, + }, 'R1'], + ['Contact/query', { + filter => { + operator => 'NOT', + conditions => [{ + notes => 'cats dogs', + }], + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$contactId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$contactId2], $res->[1][1]{ids}); + + xlog "Query with phrase"; + $res = $jmap->CallMethods([ + ['Contact/query', { + filter => { + notes => "'cats and dogs'", + }, + }, 'R1'], + ['Contact/query', { + filter => { + operator => 'NOT', + conditions => [{ + notes => "'cats and dogs'", + }], + }, + }, 'R1'], + ]); + $self->assert_deep_equals([$contactId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$contactId2], $res->[1][1]{ids}); + + xlog "Query with both phrase and loose terms"; + $res = $jmap->CallMethods([ + ['Contact/query', { + filter => { + notes => "cats 'cats and dogs' dogs", + }, + }, 'R1'], + ['Contact/query', { + filter => { + operator => 'NOT', + conditions => [{ + notes => "cats 'cats and dogs' dogs", + }], + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$contactId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$contactId2], $res->[1][1]{ids}); + + xlog "Query text"; + $res = $jmap->CallMethods([ + ['Contact/query', { + filter => { + text => "cats dogs", + }, + }, 'R1'], + ['Contact/query', { + filter => { + operator => 'NOT', + conditions => [{ + text => "cats dogs", + }], + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$contactId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$contactId2], $res->[1][1]{ids}); + + xlog "Query text and notes"; + $res = $jmap->CallMethods([ + ['Contact/query', { + filter => { + operator => 'AND', + conditions => [{ + text => "cats", + }, { + notes => "dogs", + }], + }, + }, 'R1'], + ['Contact/query', { + + filter => { + operator => 'NOT', + conditions => [{ + operator => 'AND', + conditions => [{ + text => "cats", + }, { + notes => "dogs", + }], + }], + }, + }, 'R2'], + ]); + $self->assert_deep_equals([$contactId1], $res->[0][1]{ids}); + $self->assert_deep_equals([$contactId2], $res->[1][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_query_uid b/cassandane/tiny-tests/JMAPContacts/contact_query_uid new file mode 100644 index 0000000000..a3231e456d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_query_uid @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_query_uid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + firstName => 'contact1', + }, + contact2 => { + firstName => 'contact2', + }, + contact3 => { + firstName => 'contact3', + }, + }, + }, 'R1'], + ]); + my $contactId1 = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($contactId1); + my $contactUid1 = $res->[0][1]{created}{contact1}{uid}; + $self->assert_not_null($contactUid1); + + my $contactId2 = $res->[0][1]{created}{contact2}{id}; + $self->assert_not_null($contactId2); + my $contactUid2 = $res->[0][1]{created}{contact2}{uid}; + $self->assert_not_null($contactUid2); + + my $contactId3 = $res->[0][1]{created}{contact3}{id}; + $self->assert_not_null($contactId3); + my $contactUid3 = $res->[0][1]{created}{contact3}{uid}; + $self->assert_not_null($contactUid3); + + xlog $self, "query by single uid"; + $res = $jmap->CallMethods([ + ['Contact/query', { + filter => { + uid => $contactUid2, + }, + }, 'R2'], + ]); + $self->assert_str_equals("Contact/query", $res->[0][0]); + $self->assert_deep_equals([$contactId2], $res->[0][1]{ids}); + + xlog $self, "query by invalid uid"; + $res = $jmap->CallMethods([ + ['Contact/query', { + filter => { + uid => "notarealuid", + }, + }, 'R2'], + ]); + $self->assert_str_equals("Contact/query", $res->[0][0]); + $self->assert_deep_equals([], $res->[0][1]{ids}); + + xlog $self, "query by multiple uids"; + $res = $jmap->CallMethods([ + ['Contact/query', { + filter => { + operator => 'OR', + conditions => [{ + uid => $contactUid1, + }, { + uid => $contactUid3, + }], + }, + }, 'R2'], + ]); + $self->assert_str_equals("Contact/query", $res->[0][0]); + my %gotIds = map { $_ => 1 } @{$res->[0][1]{ids}}; + $self->assert_deep_equals({ $contactUid1 => 1, $contactUid3 => 1, }, \%gotIds); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_query_windowing b/cassandane/tiny-tests/JMAPContacts/contact_query_windowing new file mode 100644 index 0000000000..08c21b9769 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_query_windowing @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_query_windowing + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + uid => 'XXX-UID-1', + company => 'companyB', + isFlagged => JSON::true, + }, + contact2 => { + uid => 'XXX-UID-2', + company => 'companyA', + isFlagged => JSON::true, + }, + contact3 => { + uid => 'XXX-UID-3', + company => 'companyB', + isFlagged => JSON::false, + }, + contact4 => { + uid => 'XXX-UID-4', + company => 'companyC', + isFlagged => JSON::true, + }, + }, + }, 'R1'], + ]); + my $contactId1 = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($contactId1); + + my $contactId2 = $res->[0][1]{created}{contact2}{id}; + $self->assert_not_null($contactId2); + + my $contactId3 = $res->[0][1]{created}{contact3}{id}; + $self->assert_not_null($contactId3); + + my $contactId4 = $res->[0][1]{created}{contact4}{id}; + $self->assert_not_null($contactId4); + + xlog $self, "run query with windowing"; + $res = $jmap->CallMethods([ + ['Contact/query', { + sort => [{ + property => 'uid', + }], + limit => 2, + }, 'R1'], + ['Contact/query', { + sort => [{ + property => 'uid', + }], + limit => 2, + position => 2, + }, 'R2'], + ['Contact/query', { + sort => [{ + property => 'uid', + }], + anchor => $contactId3, + anchorOffset => -1, + limit => 2, + }, 'R3'], + ['Contact/query', { + sort => [{ + property => 'uid', + }], + limit => 2, + position => -2, + }, 'R4'], + ]); + # Request 1 + $self->assert_deep_equals([ + $contactId1, + $contactId2, + ], $res->[0][1]{ids} + ); + $self->assert_num_equals(0, $res->[0][1]{position}); + $self->assert_num_equals(4, $res->[0][1]{total}); + # Request 2 + $self->assert_deep_equals([ + $contactId3, + $contactId4, + ], $res->[1][1]{ids} + ); + $self->assert_num_equals(2, $res->[1][1]{position}); + $self->assert_num_equals(4, $res->[1][1]{total}); + # Request 3 + $self->assert_deep_equals([ + $contactId2, + $contactId3, + ], $res->[2][1]{ids} + ); + $self->assert_num_equals(1, $res->[2][1]{position}); + $self->assert_num_equals(4, $res->[2][1]{total}); + # Request 4 + $self->assert_deep_equals([ + $contactId3, + $contactId4, + ], $res->[3][1]{ids} + ); + $self->assert_num_equals(2, $res->[3][1]{position}); + $self->assert_num_equals(4, $res->[3][1]{total}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set b/cassandane/tiny-tests/JMAPContacts/contact_set new file mode 100644 index 0000000000..5894d20efb --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set @@ -0,0 +1,383 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $contact = { + firstName => "first", + lastName => "last", + avatar => JSON::null + }; + + my $res = $jmap->CallMethods([['Contact/set', {create => {"1" => $contact }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + # get expands default values, so do the same manually + $contact->{id} = $id; + $contact->{uid} = $id; + $contact->{isFlagged} = JSON::false; + $contact->{prefix} = ''; + $contact->{suffix} = ''; + $contact->{nickname} = ''; + $contact->{birthday} = '0000-00-00'; + $contact->{anniversary} = '0000-00-00'; + $contact->{company} = ''; + $contact->{department} = ''; + $contact->{jobTitle} = ''; + $contact->{online} = []; + $contact->{phones} = []; + $contact->{addresses} = []; + $contact->{emails} = []; + $contact->{notes} = ''; + $contact->{avatar} = undef; + + # Non-JMAP properties. + $contact->{"importance"} = 0; + $contact->{"x-hasPhoto"} = JSON::false; + $contact->{"addressbookId"} = 'Default'; + + if ($res->[0][1]{created}{"1"}{blobId}) { + $contact->{blobId} = $res->[0][1]{created}{"1"}{blobId}; + $contact->{size} = $res->[0][1]{created}{"1"}{size}; + } + + xlog $self, "get contact $id"; + my $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $contact->{"x-href"} = $fetch->[0][1]{list}[0]{"x-href"}; + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update isFlagged"; + $contact->{isFlagged} = JSON::true; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {isFlagged => JSON::true} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update prefix"; + $contact->{prefix} = 'foo'; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {prefix => 'foo'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update suffix"; + $contact->{suffix} = 'bar'; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {suffix => 'bar'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update nickname"; + $contact->{nickname} = 'nick'; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {nickname => 'nick'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update birthday (with JMAP datetime error)"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {birthday => '1979-04-01T00:00:00Z'} }}, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("birthday", $res->[0][1]{notUpdated}{$id}{properties}[0]); + + xlog $self, "update birthday"; + $contact->{birthday} = '1979-04-01'; # Happy birthday, El Barto! + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {birthday => '1979-04-01'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update anniversary (with JMAP datetime error)"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {anniversary => '1989-12-17T00:00:00Z'} }}, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("anniversary", $res->[0][1]{notUpdated}{$id}{properties}[0]); + + xlog $self, "update anniversary"; + $contact->{anniversary} = '1989-12-17'; # Happy anniversary, Simpsons! + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {anniversary => '1989-12-17'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update company"; + $contact->{company} = 'acme'; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {company => 'acme'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update department"; + $contact->{department} = 'looney tunes'; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {department => 'looney tunes'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update jobTitle"; + $contact->{jobTitle} = 'director of everything'; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {jobTitle => 'director of everything'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + # emails + xlog $self, "update emails (with missing type error)"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + emails => [{ value => "acme\@example.com" }] + } }}, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("emails[0].type", $res->[0][1]{notUpdated}{$id}{properties}[0]); + + xlog $self, "update emails (with missing value error)"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + emails => [{ type => "other" }] + } }}, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("emails[0].value", $res->[0][1]{notUpdated}{$id}{properties}[0]); + + xlog $self, "update emails"; + $contact->{emails} = [{ type => "work", value => "acme\@example.com", isDefault => JSON::true }]; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + emails => [{ type => "work", value => "acme\@example.com" }] + } }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + # phones + xlog $self, "update phones (with missing type error)"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + phones => [{ value => "12345678" }] + } }}, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("phones[0].type", $res->[0][1]{notUpdated}{$id}{properties}[0]); + + xlog $self, "update phones (with missing value error)"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + phones => [{ type => "home" }] + } }}, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("phones[0].value", $res->[0][1]{notUpdated}{$id}{properties}[0]); + + xlog $self, "update phones"; + $contact->{phones} = [{ type => "home", value => "12345678" }]; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + phones => [{ type => "home", value => "12345678" }] + } }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + # online + xlog $self, "update online (with missing type error)"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + online => [{ value => "http://example.com/me" }] + } }}, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("online[0].type", $res->[0][1]{notUpdated}{$id}{properties}[0]); + + xlog $self, "update online (with missing value error)"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + online => [{ type => "uri" }] + } }}, "R1"]]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals("online[0].value", $res->[0][1]{notUpdated}{$id}{properties}[0]); + + xlog $self, "update online"; + $contact->{online} = [{ type => "uri", value => "http://example.com/me" }]; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + online => [{ type => "uri", value => "http://example.com/me" }] + } }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + # addresses + xlog $self, "update addresses"; + $contact->{addresses} = [{ + type => "home", + street => "acme lane 1", + locality => "acme city", + region => "", + postcode => "1234", + country => "acme land", + label => undef, + }]; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => { + addresses => [{ + type => "home", + street => "acme lane 1", + locality => "acme city", + region => "", + postcode => "1234", + country => "acme land", + label => undef, + }] + } }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + xlog $self, "update notes"; + $contact->{notes} = 'baz'; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {notes => 'baz'} }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + xlog $self, "get contact $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); + + # avatar + xlog $self, "upload avatar"; + $res = $jmap->Upload("some photo", "image/jpeg"); + my $blobId = $res->{blobId}; + $contact->{"x-hasPhoto"} = JSON::true; + $contact->{avatar} = { + blobId => $blobId, + size => 10, + type => "image/jpeg", + name => JSON::null + }; + + xlog $self, "attempt to update avatar with invalid type"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => + {avatar => { + blobId => $blobId, + size => 10, + type => "JPEG", + name => JSON::null + } + } }}, "R1"]]); + $self->assert_null($res->[0][1]{updated}); + $self->assert_not_null($res->[0][1]{notUpdated}{$id}); + + xlog $self, "update avatar"; + $res = $jmap->CallMethods([['Contact/set', {update => {$id => + {avatar => { + blobId => $blobId, + size => 10, + type => "image/jpeg", + name => JSON::null + } + } }}, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + if ($res->[0][1]{updated}{$id}{blobId}) { + $contact->{blobId} = $res->[0][1]{updated}{$id}{blobId}; + $contact->{size} = $res->[0][1]{updated}{$id}{size}; + } + + if ($res->[0][1]{updated}{$id}{avatar}{blobId}) { + $contact->{avatar}{blobId} = $res->[0][1]{updated}{$id}{avatar}{blobId}; + } + + xlog $self, "get avatar $id"; + $fetch = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_deep_equals($contact, $fetch->[0][1]{list}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_apple_countrycode b/cassandane/tiny-tests/JMAPContacts/contact_set_apple_countrycode new file mode 100644 index 0000000000..3b2219a543 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_apple_countrycode @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_apple_countrycode + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + lastName => "Smith", + addresses => [{ + type => "work", + label => "xyz", + street => "2 Example Avenue", + locality => "Anytown", + region => "NY", + postcode => "01111", + country => "USA", + countryCode => "us" + }, { + type => "work", + street => "Beispielstrasse 2", + locality => 'IrgendwoStadt', + region => 'IrgendwoLand', + postcode => '00000', + country => "Germany", + countryCode => 'DE', + }], + }, + }, + }, 'R1'], + ['Contact/get', { + ids => ['#contact1'], + properties => ['addresses'], + }, 'R2'], + ]); + $self->assert_str_equals('us', $res->[1][1]{list}[0]{addresses}[0]{countryCode}); + $self->assert_str_equals('xyz', $res->[1][1]{list}[0]{addresses}[0]{label}); + $self->assert_str_equals('de', $res->[1][1]{list}[0]{addresses}[1]{countryCode}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_from_deleted_contact b/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_from_deleted_contact new file mode 100644 index 0000000000..6e995b9f09 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_from_deleted_contact @@ -0,0 +1,100 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_avatar_from_deleted_contact + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $contact = { + firstName => "first", + lastName => "last", + avatar => { + blobId => "#img", + size => 10, + type => "image/jpeg", + name => JSON::null + } + }; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/contacts', + 'https://cyrusimap.org/ns/jmap/blob', + ]; + + xlog $self, "create initial card"; + my $res = $jmap->CallMethods([ + ['Blob/upload', { create => { + "img" => { data => [{'data:asText' => 'some photo'}], + type => 'image/jpeg' } } }, 'R0'], + ['Contact/set', {create => {"1" => $contact }}, "R1"], + ['Contact/get', {}, "R2"]], + $using); + $self->assert_not_null($res); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_str_equals('R0', $res->[0][2]); + + $contact->{avatar}{blobId} = $res->[0][1]{created}{"img"}{blobId}; + + $self->assert_str_equals('Contact/set', $res->[1][0]); + $self->assert_str_equals('R1', $res->[1][2]); + my $id = $res->[1][1]{created}{"1"}{id}; + + $contact->{avatar}{blobId} = $res->[1][1]{created}{"1"}{avatar}{blobId}; + + $self->assert_str_equals('Contact/get', $res->[2][0]); + $self->assert_str_equals('R2', $res->[2][2]); + $self->assert_str_equals($id, $res->[2][1]{list}[0]{id}); + $self->assert_str_equals('first', $res->[2][1]{list}[0]{firstName}); + $self->assert_deep_equals($contact->{avatar}, $res->[2][1]{list}[0]{avatar}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{"x-hasPhoto"}); + + my $newcontact = { + firstName => "first2", + lastName => "last2", + avatar => { + blobId => "$contact->{avatar}{blobId}", + size => 10, + type => "image/jpeg", + name => JSON::null + } + }; + + xlog $self, "delete initial card"; + $res = $jmap->CallMethods([ + ['Contact/set', { destroy => [ "$id"] }, 'R0']], + $using); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R0', $res->[0][2]); + + xlog $self, "create new card using avatar from deleted card"; + $res = $jmap->CallMethods([ + ['Contact/set', {create => {"1" => $newcontact }}, "R1"], + ['Contact/get', {}, "R2"]], + $using); + + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $id = $res->[0][1]{created}{"1"}{id}; + + $contact->{avatar}{blobId} = $res->[0][1]{created}{"1"}{avatar}{blobId}; + + $self->assert_str_equals('Contact/get', $res->[1][0]); + $self->assert_str_equals('R2', $res->[1][2]); + $self->assert_str_equals($id, $res->[1][1]{list}[0]{id}); + $self->assert_str_equals('first2', $res->[1][1]{list}[0]{firstName}); + $self->assert_deep_equals($contact->{avatar}, $res->[1][1]{list}[0]{avatar}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{"x-hasPhoto"}); + + xlog $self, "download and check avatar content"; + my $blob = $jmap->Download({ accept => 'image/jpeg' }, + 'cassandane', $res->[1][1]{list}[0]{avatar}{blobId}); + $self->assert_str_equals('image/jpeg', + $blob->{headers}->{'content-type'}); + $self->assert_num_equals(10, $blob->{headers}->{'content-length'}); + $self->assert_equals('some photo', $blob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_shared b/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_shared new file mode 100644 index 0000000000..0805349153 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_shared @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_avatar_shared + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + xlog $self, "create #jmap folder"; + $admintalk->create("user.manifold.#jmap", ['TYPE', 'COLLECTION']); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold.#jmap", admin => 'lrswipkxtecdn'); + + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrswipkxtecdn') or die; + + # avatar + xlog $self, "upload avatar - setacl on shared #jmap folder"; + my $res = $jmap->Upload("some photo", "image/jpeg", "manifold"); + my $blobId = $res->{blobId}; + + xlog $self, "create contact"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => {"1" => {firstName => "first", lastName => "last", + avatar => { + blobId => $blobId, + size => 10, + type => "image/jpeg", + name => JSON::null + } + }} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "delete #jmap folder"; + $admintalk->delete("user.manifold.#jmap") || die; + + # avatar + xlog $self, "upload new avatar - create new shared #jmap folder"; + $res = $jmap->Upload("some other photo", "image/jpeg", "manifold"); + $blobId = $res->{blobId}; + + xlog $self, "update avatar"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + update => {$id => + {avatar => { + blobId => $blobId, + size => 10, + type => "image/jpeg", + name => JSON::null + } + } + } + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_singlecommand b/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_singlecommand new file mode 100644 index 0000000000..78cc733a56 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_singlecommand @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_avatar_singlecommand + :min_version_3_3 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $contact = { + firstName => "first", + lastName => "last", + avatar => { + blobId => "#img", + size => 10, + type => "image/jpeg", + name => JSON::null + } + }; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/contacts', + 'https://cyrusimap.org/ns/jmap/blob', + ]; + + my $res = $jmap->CallMethods([ + ['Blob/upload', { create => { + "img" => { data => [{'data:asText' => 'some photo'}], + type => 'image/jpeg' } } }, 'R0'], + ['Contact/set', {create => {"1" => $contact }}, "R1"], + ['Contact/get', {}, "R2"]], + $using); + $self->assert_not_null($res); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_str_equals('R0', $res->[0][2]); + + $contact->{avatar}{blobId} = $res->[0][1]{created}{"img"}{blobId}; + + $self->assert_str_equals('Contact/set', $res->[1][0]); + $self->assert_str_equals('R1', $res->[1][2]); + my $id = $res->[1][1]{created}{"1"}{id}; + + if ($res->[1][1]{created}{"1"}{avatar}{blobId}) { + $contact->{avatar}{blobId} = $res->[1][1]{created}{"1"}{avatar}{blobId}; + } + + $self->assert_str_equals('Contact/get', $res->[2][0]); + $self->assert_str_equals('R2', $res->[2][2]); + $self->assert_str_equals($id, $res->[2][1]{list}[0]{id}); + $self->assert_deep_equals($contact->{avatar}, $res->[2][1]{list}[0]{avatar}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{"x-hasPhoto"}); + + xlog $self, "remove avatar"; + $res = $jmap->CallMethods([ + ['Contact/set', {update => {$id => {avatar => JSON::null} }}, "R1"], + ['Contact/get', {}, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $self->assert_str_equals('Contact/get', $res->[1][0]); + $self->assert_str_equals('R2', $res->[1][2]); + $self->assert_str_equals($id, $res->[1][1]{list}[0]{id}); + $self->assert_null($res->[1][1]{list}[0]{avatar}); + $self->assert_equals(JSON::false, $res->[1][1]{list}[0]{"x-hasPhoto"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_v4 b/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_v4 new file mode 100644 index 0000000000..0bbea68683 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_avatar_v4 @@ -0,0 +1,67 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_avatar_v4 + :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + xlog $self, "Create a v4 vCard over CardDAV"; + my $id = '816ad14a-f9ef-43a8-9039-b57bf321de1f'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + xlog $self, "Get JMAP Contact"; + my $res = $jmap->CallMethods([ + ['Contact/get', { + properties => ['avatar', 'x-hasPhoto'], + }, 'R1'] + ]); + my $contactId = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($contactId); + $self->assert_null($res->[0][1]{list}[0]{avatar}); + + xlog $self, "Set avatar on contact"; + my $binary = slurp_file(abs_path('data/logo.gif')); + my $data = $jmap->Upload($binary, "image/gif"); + $res = $jmap->CallMethods([ + ['Contact/set', { + update => { + $contactId => { + avatar => { + blobId => $data->{blobId}, + type => "image/gif", + } + } + } + }, 'R1'] + ]); + my $avatarBlobId = $res->[0][1]{updated}{$contactId}{avatar}{blobId}; + $self->assert_not_null($avatarBlobId); + + xlog $self, "Get vCard over CardDAV as version 4.0"; + $res = $carddav->Request('GET', $href, undef, + "Accept" => "text/vcard; version=4.0"); + my $vcard = Net::CardDAVTalk::VCard->new_fromstring($res->{content}); + my $photo = $vcard->{properties}->{photo}->[0] // undef; + $self->assert(not $photo->{binary}); + $self->assert_equals("data:image/gif;base64,", substr($photo->{value}, 0, 22)); + + xlog $self, "Assert avatar blob contents"; + $data = $jmap->Download('cassandane', $avatarBlobId); + $self->assert($binary eq $data->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_create_bday_noyear b/cassandane/tiny-tests/JMAPContacts/contact_set_create_bday_noyear new file mode 100644 index 0000000000..2705828c11 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_create_bday_noyear @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_create_bday_noyear + :needs_component_jmap :needs_dependency_icalvcard +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + "1" => { + uid => $id, + firstName => 'Jane', + lastName => 'Doe', + birthday => '0000-04-15' + } + } + }, 'R1'], + ['Contact/get', { ids => [ "#1" ] }, 'R2'] + ]); + + $self->assert_not_null($res->[0][1]{created}{1}); + + my $href = $res->[1][1]{list}[0]{'x-href'}; + $res = $carddav->Request('GET', $href, '', + 'Accept' => 'text/vcard; version=4.0'); + + my $card = $res->{content}; + $card =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr|BDAY(;VALUE=DATE)?:--0415|, $card); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_emaillabel b/cassandane/tiny-tests/JMAPContacts/contact_set_emaillabel new file mode 100644 index 0000000000..4a4de9e870 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_emaillabel @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_emaillabel + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # See https://github.com/cyrusimap/cyrus-imapd/issues/2273 + + my $contact = { + firstName => "first", + lastName => "last", + emails => [{ + type => "other", + label => "foo", + value => "foo\@local", + isDefault => JSON::true + }] + }; + + xlog $self, "create contact"; + my $res = $jmap->CallMethods([['Contact/set', {create => {"1" => $contact }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($id); + + xlog $self, "get contact $id"; + $res = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_str_equals('foo', $res->[0][1]{list}[0]{emails}[0]{label}); + + xlog $self, "update contact"; + $res = $jmap->CallMethods([['Contact/set', { + update => { + $id => { + emails => [{ + type => "personal", + label => undef, + value => "bar\@local", + isDefault => JSON::true + }] + } + } + }, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "get contact $id"; + $res = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_str_equals('personal', $res->[0][1]{list}[0]{emails}[0]{type}); + $self->assert_null($res->[0][1]{list}[0]{emails}[0]{label}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_importance_float b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_float new file mode 100644 index 0000000000..ea5d0bbf5f --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_float @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_importance_float + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + c1 => { + lastName => 'test', + importance => -122.129545321514, + }, + }, + }, 'R1'], + ['Contact/get', { + ids => ['#c1'], + properties => ['importance'], + }, 'R2'], + ]); + my $contactId = $res->[0][1]{created}{c1}{id}; + $self->assert_not_null($contactId); + $self->assert_equals(-122.129545321514, $res->[1][1]{list}[0]{importance}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_importance_later b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_later new file mode 100644 index 0000000000..e0bbe1021c --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_later @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_importance_later + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with no importance"; + my $res = $jmap->CallMethods([['Contact/set', {create => {"1" => {firstName => "first", lastName => "last"}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('first', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(0.0, $fetch->[0][1]{list}[0]{"importance"}); + + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {"importance" => -0.1}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('first', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(-0.1, $fetch->[0][1]{list}[0]{"importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_importance_multiedit b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_multiedit new file mode 100644 index 0000000000..ff446c055a --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_multiedit @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_importance_multiedit + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with no importance"; + my $res = $jmap->CallMethods([['Contact/set', {create => {"1" => {firstName => "first", lastName => "last", "importance" => -5.2}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('first', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"importance"}); + + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {"firstName" => "second", "importance" => -0.2}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('second', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(-0.2, $fetch->[0][1]{list}[0]{"importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_importance_peruser b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_peruser new file mode 100644 index 0000000000..82270a2fe0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_peruser @@ -0,0 +1,78 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_importance_peruser + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $admin->create("user.manifold"); + my $http = $self->{instance}->get_service("http"); + my $manjmap = Mail::JMAPTalk->new( + user => 'manifold', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $manjmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/contacts', + ]); + $admin->setacl("user.cassandane.#addressbooks.Default", + "manifold" => 'lrswipkxtecdn') or die; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + c1 => { + lastName => 'test', + importance => 1.0, + }, + }, + }, 'R1'], + ['Contact/get', { + ids => ['#c1'], + properties => ['importance'], + }, 'R2'], + ]); + my $contactId = $res->[0][1]{created}{c1}{id}; + $self->assert_not_null($contactId); + $self->assert_equals(1.0, $res->[1][1]{list}[0]{importance}); + + $res = $manjmap->CallMethods([ + ['Contact/get', { + accountId => 'cassandane', + ids => [$contactId], + properties => ['importance'], + }, 'R1'], + ['Contact/set', { + accountId => 'cassandane', + update => { + $contactId => { + importance => 2.0, + }, + }, + }, 'R2'], + ['Contact/get', { + accountId => 'cassandane', + ids => [$contactId], + properties => ['importance'], + }, 'R3'], + ]); + + $self->assert_equals(1.0, $res->[0][1]{list}[0]{importance}); + $self->assert(exists $res->[1][1]{updated}{$contactId}); + $self->assert_equals(2.0, $res->[2][1]{list}[0]{importance}); + + $res = $jmap->CallMethods([ + ['Contact/get', { + ids => ['#c1'], + properties => ['importance'], + }, 'R1'], + ]); + $self->assert_equals(1.0, $res->[0][1]{list}[0]{importance}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_importance_shared b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_shared new file mode 100644 index 0000000000..9dd55e4f39 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_shared @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_importance_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "create contact"; + my $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => {"1" => {firstName => "first", lastName => "last"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrsn') or die; + + xlog $self, "update importance"; + $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + update => {$id => {"importance" => -0.1}} + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_importance_upfront b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_upfront new file mode 100644 index 0000000000..fd12147528 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_upfront @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_importance_upfront + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with importance in initial create"; + my $res = $jmap->CallMethods([['Contact/set', {create => {"1" => {firstName => "first", lastName => "last", "importance" => -5.2}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('first', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"importance"}); + + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {"firstName" => "second"}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('second', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_importance_zero_byself b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_zero_byself new file mode 100644 index 0000000000..d5cbf87bff --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_zero_byself @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_importance_zero_byself + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with no importance"; + my $res = $jmap->CallMethods([['Contact/set', {create => {"1" => {firstName => "first", lastName => "last", "importance" => -5.2}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('first', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"importance"}); + + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {"importance" => 0}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('first', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(0, $fetch->[0][1]{list}[0]{"importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_importance_zero_multi b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_zero_multi new file mode 100644 index 0000000000..a7c6d6d2bd --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_importance_zero_multi @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_importance_zero_multi + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create with no importance"; + my $res = $jmap->CallMethods([['Contact/set', {create => {"1" => {firstName => "first", lastName => "last", "importance" => -5.2}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + my $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('first', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(-5.2, $fetch->[0][1]{list}[0]{"importance"}); + + $res = $jmap->CallMethods([['Contact/set', {update => {$id => {"firstName" => "second", "importance" => 0}}}, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_str_equals('second', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_num_equals(0, $fetch->[0][1]{list}[0]{"importance"}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_invalid b/cassandane/tiny-tests/JMAPContacts/contact_set_invalid new file mode 100644 index 0000000000..43e5cae1da --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_invalid @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_invalid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contact with invalid properties"; + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + "1" => { + id => "xyz", + firstName => "foo", + lastName => "last1", + foo => "", + "x-hasPhoto" => JSON::true + }, + }}, "R1"]]); + $self->assert_not_null($res); + my $notCreated = $res->[0][1]{notCreated}{"1"}; + $self->assert_not_null($notCreated); + $self->assert_num_equals(3, scalar @{$notCreated->{properties}}); + + xlog $self, "create contacts"; + $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + "1" => { + firstName => "foo", + lastName => "last1" + }, + }}, "R2"]]); + $self->assert_not_null($res); + my $contact = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($contact); + + xlog $self, "get contact x-href"; + $res = $jmap->CallMethods([['Contact/get', {}, "R3"]]); + my $href = $res->[0][1]{list}[0]{"x-href"}; + + xlog $self, "update contact with invalid properties"; + $res = $jmap->CallMethods([['Contact/set', { + update => { + $contact => { + id => "xyz", + foo => "", + "x-hasPhoto" => "yes", + "x-ref" => "abc" + }, + }}, "R4"]]); + $self->assert_not_null($res); + my $notUpdated = $res->[0][1]{notUpdated}{$contact}; + $self->assert_not_null($notUpdated); + $self->assert_num_equals(3, scalar @{$notUpdated->{properties}}); + + xlog $self, "update contact with server-set properties"; + $res = $jmap->CallMethods([['Contact/set', { + update => { + $contact => { + id => $contact, + "x-hasPhoto" => JSON::false, + "x-href" => $href + }, + }}, "R5"]]); + $self->assert_not_null($res); + $self->assert_not_null($res->[0][1]{updated}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_issue2953 b/cassandane/tiny-tests/JMAPContacts/contact_set_issue2953 new file mode 100644 index 0000000000..4d03992f8d --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_issue2953 @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_issue2953 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + 1 => { + online => [{ + type => 'username', + value => 'foo,bar', + label => 'Github', + }], + }, + }, + }, 'R1'], + ['Contact/get', { + ids => ['#1'], properties => ['online'], + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}{1}); + $self->assert_str_equals('foo,bar', $res->[1][1]{list}[0]{online}[0]{value}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_multicontact b/cassandane/tiny-tests/JMAPContacts/contact_set_multicontact new file mode 100644 index 0000000000..dd490e4afc --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_multicontact @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_multicontact + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([['Contact/set', { + create => { + "1" => {firstName => "first", lastName => "last"}, + "2" => {firstName => "second", lastName => "last"}, + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + + my $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id1, 'notacontact']}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('first', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_not_null($fetch->[0][1]{notFound}); + $self->assert_str_equals('notacontact', $fetch->[0][1]{notFound}[0]); + + $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id2]}, "R3"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R3', $fetch->[0][2]); + $self->assert_str_equals('second', $fetch->[0][1]{list}[0]{firstName}); + $self->assert_deep_equals([], $fetch->[0][1]{notFound}); + + $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id1, $id2]}, "R4"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R4', $fetch->[0][2]); + $self->assert_num_equals(2, scalar @{$fetch->[0][1]{list}}); + $self->assert_deep_equals([], $fetch->[0][1]{notFound}); + + $fetch = $jmap->CallMethods([['Contact/get', {}, "R5"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R5', $fetch->[0][2]); + $self->assert_num_equals(2, scalar @{$fetch->[0][1]{list}}); + $self->assert_deep_equals([], $fetch->[0][1]{notFound}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_nickname b/cassandane/tiny-tests/JMAPContacts/contact_set_nickname new file mode 100644 index 0000000000..520ef53af6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_nickname @@ -0,0 +1,29 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_nickname + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "1" => { firstName => "foo", lastName => "last1", nickname => "" }, + "2" => { firstName => "bar", lastName => "last2", nickname => "string" }, + "3" => { firstName => "bar", lastName => "last3", nickname => "string,list" }, + }}, "R1"]]); + $self->assert_not_null($res); + my $contact1 = $res->[0][1]{created}{"1"}{id}; + my $contact2 = $res->[0][1]{created}{"2"}{id}; + my $contact3 = $res->[0][1]{created}{"3"}{id}; + $self->assert_not_null($contact1); + $self->assert_not_null($contact2); + $self->assert_not_null($contact3); + + $res = $jmap->CallMethods([['Contact/set', {update => { + $contact2 => { nickname => "" }, + }}, "R2"]]); + $self->assert_not_null($res); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_preserve_xprops b/cassandane/tiny-tests/JMAPContacts/contact_set_preserve_xprops new file mode 100644 index 0000000000..bade9479e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_preserve_xprops @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; +use Encode qw(decode); + +sub test_contact_set_preserve_xprops + : needs_component_jmap { + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + xlog $self, "Create vCard with x-property"; + my $card = decode( + 'utf-8', <Request('PUT', 'Default/test.vcf', $card, 'Content-Type' => 'text/vcard'); + + xlog $self, "Update some contact property"; + my $res = $jmap->CallMethods([ + [ 'Contact/query', {}, 'R1' ], + [ + 'Contact/get', + { + '#ids' => { + resultOf => 'R1', + path => '/ids', + name => 'Contact/query', + }, + properties => ['lastName'], + }, + 'R2' + ], + ]); + + my $contactId = $res->[0][1]{ids}[0]; + $self->assert_not_null($contactId); + $self->assert_str_equals('Smith', $res->[1][1]{list}[0]{lastName}); + + $res = $jmap->CallMethods([ + [ + 'Contact/set', + { + update => { + $contactId => { + lastName => 'Kraut', + } + }, + }, + 'R1' + ], + [ + 'Contact/get', + { + ids => [$contactId], + properties => ['lastName'], + }, + 'R2' + ], + ]); + $self->assert_str_equals('Kraut', $res->[1][1]{list}[0]{lastName}); + + xlog $self, "Update x-property is preserved in vCard"; + $res = $carddav->Request('GET', 'Default/test.vcf'); + $self->assert_matches(qr/^X-FOO;X-BAZ=Bam:Bar\r?$/m, $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_reject_duplicate_uid b/cassandane/tiny-tests/JMAPContacts/contact_set_reject_duplicate_uid new file mode 100644 index 0000000000..317f8ed38c --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_reject_duplicate_uid @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_reject_duplicate_uid + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + $carddav->NewAddressBook('addrbookB') or die; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contactA => { + uid => '123456789', + lastName => 'contactA', + }, + } + }, 'R1'], + ]); + my $contactA = $res->[0][1]{created}{contactA}{id}; + $self->assert_not_null($contactA); + + $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contactB => { + addressbookId => 'addrbookB', + uid => '123456789', + lastName => 'contactB', + }, + } + }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notCreated}{contactB}{type}); + $self->assert_deep_equals(['uid'], + $res->[0][1]{notCreated}{contactB}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_state b/cassandane/tiny-tests/JMAPContacts/contact_set_state new file mode 100644 index 0000000000..683cb0339c --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_state @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_state + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contact"; + my $res = $jmap->CallMethods([['Contact/set', {create => {"1" => {firstName => "first", lastName => "last"}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + my $state = $res->[0][1]{newState}; + + xlog $self, "get contact $id"; + $res = $jmap->CallMethods([['Contact/get', {}, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/get', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + $self->assert_str_equals('first', $res->[0][1]{list}[0]{firstName}); + $self->assert_str_equals($state, $res->[0][1]{state}); + + xlog $self, "update $id with state token $state"; + $res = $jmap->CallMethods([['Contact/set', { + ifInState => $state, + update => {$id => + {firstName => "first", lastName => "last"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert(exists $res->[0][1]{updated}{$id}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "update $id with expired state token $oldState"; + $res = $jmap->CallMethods([['Contact/set', { + ifInState => $oldState, + update => {$id => + {firstName => "first", lastName => "last"} + }}, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "get contact $id to make sure state didn't change"; + $res = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]{state}); + + xlog $self, "destroy $id with expired state token $oldState"; + $res = $jmap->CallMethods([['Contact/set', { + ifInState => $oldState, + destroy => [$id] + }, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "destroy contact $id with current state"; + $res = $jmap->CallMethods([ + ['Contact/set', { + ifInState => $state, + destroy => [$id] + }, "R1"] + ]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_toolarge b/cassandane/tiny-tests/JMAPContacts/contact_set_toolarge new file mode 100644 index 0000000000..d25a1d1869 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_toolarge @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_toolarge + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + 1 => { + lastName => 'name', + notes => ('x' x 100000), + }, + 2 => { + lastName => 'othername', + notes => ('x' x 10000), + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('tooLarge', $res->[0][1]{notCreated}{1}{type}); + $self->assert_not_null($res->[0][1]{created}{2}); + my $id = $res->[0][1]{created}{2}{id}; + + $res = $jmap->CallMethods([ + ['Contact/set', { + update => { + $id => { + notes => ('x' x 100000), + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('tooLarge', $res->[0][1]{notUpdated}{$id}{type}); + +# Is there a way to shutdown httpd, change vcard_ax_size and restart httpd? +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_uid b/cassandane/tiny-tests/JMAPContacts/contact_set_uid new file mode 100644 index 0000000000..c55286fede --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_uid @@ -0,0 +1,81 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_set_uid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # An empty UID generates a random uid. + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + "1" => { + firstName => "first1", + lastName => "last1", + } + } + }, "R1"], + ['Contact/get', { ids => ['#1'] }, 'R2'], + ]); + $self->assert_not_null($res->[1][1]{list}[0]{uid}); + $jmap->{CreatedIds} = {}; + + # A sane UID maps to both the JMAP id and the DAV resource. + $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + "2" => { + firstName => "first2", + lastName => "last2", + uid => '1234-56789-01234-56789', + } + } + }, "R1"], + ['Contact/get', { ids => ['#2'] }, 'R2'], + ]); + $self->assert_not_null($res->[1][1]{list}[0]{uid}); + my($filename, $dirs, $suffix) = fileparse($res->[1][1]{list}[0]{"x-href"}, ".vcf"); + $self->assert_not_null($res->[1][1]{list}[0]->{id}); + $self->assert_str_equals($res->[1][1]{list}[0]->{uid}, $res->[1][1]{list}[0]->{id}); + $self->assert_str_equals($filename, $res->[1][1]{list}[0]->{id}); + $jmap->{CreatedIds} = {}; + + # A non-pathsafe UID maps to uid but not the DAV resource. + $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + "3" => { + firstName => "first3", + lastName => "last3", + uid => 'a/bogus/path#uid', + } + } + }, "R1"], + ['Contact/get', { ids => ['#3'] }, 'R2'], + ]); + $self->assert_not_null($res->[1][1]{list}[0]{uid}); + ($filename, $dirs, $suffix) = fileparse($res->[1][1]{list}[0]{"x-href"}, ".vcf"); + $self->assert_not_null($res->[1][1]{list}[0]->{id}); + $self->assert_str_equals($res->[1][1]{list}[0]->{id}, $res->[1][1]{list}[0]->{uid}); + $self->assert_str_not_equals('path#uid', $filename); + $jmap->{CreatedIds} = {}; + + # Can't change an UID + my $contactId = $res->[0][1]{created}{3}{id}; + $self->assert_not_null($contactId); + $res = $jmap->CallMethods([ + ['Contact/set', { + update => { + $contactId => { + uid => '0000-1234-56789-01234-56789-000' + } + } + }, "R1"], + ]); + $self->assert_str_equals('uid', $res->[0][1]{notUpdated}{$contactId}{properties}[0]); + $jmap->{CreatedIds} = {}; + +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_update_grouped_property b/cassandane/tiny-tests/JMAPContacts/contact_update_grouped_property new file mode 100644 index 0000000000..2227cfa0fb --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_update_grouped_property @@ -0,0 +1,66 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_update_grouped_property + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['Contact/get', { + }, 'R1'] + ]); + + $self->assert_equals("Bubba Gump Shrimp Co.", + $res->[0][1]{list}[0]{company}); + + $res = $jmap->CallMethods([ + ['Contact/set', { + update => {$id => { company => "BGSCO" }} + }, "R1"], + ['Contact/get', { + }, 'R2'] + ]); + + $self->assert_equals("BGSCO", $res->[1][1]{list}[0]{company}); + + $res = $carddav->Request('GET', $href); + + my $newcard = $res->{content}; + $newcard =~ s/\r?\n[ \t]+//gs; # unfold long properties + + $self->assert_matches(qr/\nITEM1.ORG:BGSCO/, $newcard); + $self->assert_does_not_match(qr/\nORG:/, $newcard); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_changes b/cassandane/tiny-tests/JMAPContacts/contactgroup_changes new file mode 100644 index 0000000000..ed8532c5bd --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_changes @@ -0,0 +1,141 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_changes + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "a" => {firstName => "a", lastName => "a"}, + "b" => {firstName => "b", lastName => "b"}, + "c" => {firstName => "c", lastName => "c"}, + "d" => {firstName => "d", lastName => "d"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $contactA = $res->[0][1]{created}{"a"}{id}; + my $contactB = $res->[0][1]{created}{"b"}{id}; + my $contactC = $res->[0][1]{created}{"c"}{id}; + my $contactD = $res->[0][1]{created}{"d"}{id}; + + xlog $self, "get contact groups state"; + $res = $jmap->CallMethods([['ContactGroup/get', {}, "R2"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "create contact group 1"; + $res = $jmap->CallMethods([['ContactGroup/set', {create => { + "1" => {name => "first", contactIds => [$contactA, $contactB]}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + + + xlog $self, "get contact group updates"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "create contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', {create => { + "2" => {name => "second", contactIds => [$contactC, $contactD]}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get contact group updates (since last change)"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get contact group updates (in bulk)"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + sinceState => $oldState + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get contact group updates from initial state (maxChanges=1)"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + sinceState => $oldState, + maxChanges => 1 + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + my $interimState = $res->[0][1]{newState}; + + xlog $self, "get contact group updates from interim state (maxChanges=10)"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + sinceState => $interimState, + maxChanges => 10 + }, "R2"]]); + $self->assert_str_equals($interimState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "destroy contact group 1, update contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', { + destroy => [$id1], + update => {$id2 => {name => "second (updated)"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get contact group updates"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id2, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', {destroy => [$id2]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_changes_shared b/cassandane/tiny-tests/JMAPContacts/contactgroup_changes_shared new file mode 100644 index 0000000000..528ade65be --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_changes_shared @@ -0,0 +1,178 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_changes_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.manifold"); + + my $mantalk = Net::CardDAVTalk->new( + user => "manifold", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + $admintalk->setacl("user.manifold", admin => 'lrswipkxtecdan'); + $admintalk->setacl("user.manifold", manifold => 'lrswipkxtecdn'); + xlog $self, "share to user"; + $admintalk->setacl("user.manifold.#addressbooks.Default", "cassandane" => 'lrswipkxtecdn') or die; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', { + accountId => 'manifold', + create => { + "a" => {firstName => "a", lastName => "a"}, + "b" => {firstName => "b", lastName => "b"}, + "c" => {firstName => "c", lastName => "c"}, + "d" => {firstName => "d", lastName => "d"} + }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $contactA = $res->[0][1]{created}{"a"}{id}; + my $contactB = $res->[0][1]{created}{"b"}{id}; + my $contactC = $res->[0][1]{created}{"c"}{id}; + my $contactD = $res->[0][1]{created}{"d"}{id}; + + xlog $self, "get contact groups state"; + $res = $jmap->CallMethods([['ContactGroup/get', { accountId => 'manifold', }, "R2"]]); + my $state = $res->[0][1]{state}; + + xlog $self, "create contact group 1"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + create => { + "1" => {name => "first", contactIds => [$contactA, $contactB]}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + + + xlog $self, "get contact group updates"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + + my $oldState = $state; + $state = $res->[0][1]{newState}; + + xlog $self, "create contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + create => { + "2" => {name => "second", contactIds => [$contactC, $contactD]}}}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "get contact group updates (since last change)"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "get contact group updates (in bulk)"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + accountId => 'manifold', + sinceState => $oldState + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get contact group updates from initial state (maxChanges=1)"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + accountId => 'manifold', + sinceState => $oldState, + maxChanges => 1 + }, "R2"]]); + $self->assert_str_equals($oldState, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::true, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{created}[0]); + my $interimState = $res->[0][1]{newState}; + + xlog $self, "get contact group updates from interim state (maxChanges=10)"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + accountId => 'manifold', + sinceState => $interimState, + maxChanges => 10 + }, "R2"]]); + $self->assert_str_equals($interimState, $res->[0][1]{oldState}); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id2, $res->[0][1]{created}[0]); + $state = $res->[0][1]{newState}; + + xlog $self, "destroy contact group 1, update contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + destroy => [$id1], + update => {$id2 => {name => "second (updated)"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "get contact group updates"; + $res = $jmap->CallMethods([['ContactGroup/changes', { + accountId => 'manifold', + sinceState => $state + }, "R2"]]); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_equals(JSON::false, $res->[0][1]{hasMoreChanges}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($id2, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy contact group 2"; + $res = $jmap->CallMethods([['ContactGroup/set', { + accountId => 'manifold', + destroy => [$id2] + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_get_deduplicate_contactids b/cassandane/tiny-tests/JMAPContacts/contactgroup_get_deduplicate_contactids new file mode 100644 index 0000000000..a068432515 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_get_deduplicate_contactids @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_get_deduplicate_contactids + :min_version_3_7 :needs_component_jmap +{ + + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + # define duplicate ids + my @ids = qw ( + b48259ca-1524-4df0-af54-e65f60bf27b5 + b48259ca-1524-4df0-af54-e65f60bf27b5 + 2391c8ef-1cfc-40da-8730-cb5664005973 + b48259ca-1524-4df0-af54-e65f60bf27b5 + f1fc45d4-809a-4d10-8abd-2bfc84dcab8c + 2391c8ef-1cfc-40da-8730-cb5664005973 + ); + + # deduplicate ids + my %idhash = map { $_, 1 } @ids; + my @wantids = sort keys %idhash; + + my @wantOtherAccountIds = qw (5b3b9ce1-0b5e-4cbd-8add-018321cad51b); + + xlog $self, "create a v3 contact group with duplicate members"; + my $id = '816ad14a-f9ef-43a8-9039-b57bf321de1f'; + my $href = "Default/$id.vcf"; + my $vgroup = <Request('PUT', $href, $vgroup, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactGroup/get', { + properties => ['contactIds', 'otherAccountContactIds' ], + }, 'R1'] + ]); + my @gotids = sort @{$res->[0][1]{list}[0]{contactIds}}; + + xlog "Assert contactIds in group got deduplicated"; + $self->assert_deep_equals(\@wantids, \@gotids); + + xlog "Assert otherAccountContactIds got deduplicated"; + $self->assert_deep_equals({ foo => \@wantOtherAccountIds }, + $res->[0][1]{list}[0]{otherAccountContactIds}); + +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_get_issue2292 b/cassandane/tiny-tests/JMAPContacts/contactgroup_get_issue2292 new file mode 100644 index 0000000000..f30e8744ae --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_get_issue2292 @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_get_issue2292 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contact group"; + my $res = $jmap->CallMethods([['ContactGroup/set', {create => { + "1" => {name => "group1"} + }}, "R2"]]); + $self->assert_not_null($res->[0][1]{created}{"1"}); + + xlog $self, "get contact group with no ids"; + $res = $jmap->CallMethods([['ContactGroup/get', { }, "R3"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + + xlog $self, "get contact group with empty ids"; + $res = $jmap->CallMethods([['ContactGroup/get', { ids => [] }, "R3"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + + xlog $self, "get contact group with null ids"; + $res = $jmap->CallMethods([['ContactGroup/get', { ids => undef }, "R3"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_get_v4 b/cassandane/tiny-tests/JMAPContacts/contactgroup_get_v4 new file mode 100644 index 0000000000..2edb6efabd --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_get_v4 @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_get_v4 + :min_version_3_5 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactGroup/get', { + }, 'R1'] + ]); + $self->assert_str_equals($id, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals('Test', $res->[0][1]{list}[0]{name}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}[0]{contactIds}}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_query b/cassandane/tiny-tests/JMAPContacts/contactgroup_query new file mode 100644 index 0000000000..e9c8926dbb --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_query @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_query + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contact groups"; + my $res = $jmap->CallMethods([ + ['ContactGroup/set', { + create => { + contactGroup1 => { + name => 'dogs and cats', + }, + contactGroup2 => { + name => 'cats and bats', + }, + contactGroup3 => { + name => 'bats and hats', + }, + }, + }, 'R1'], + ]); + my $contactGroupId1 = $res->[0][1]{created}{contactGroup1}{id}; + $self->assert_not_null($contactGroupId1); + my $contactGroupUid1 = $res->[0][1]{created}{contactGroup1}{uid}; + $self->assert_not_null($contactGroupUid1); + + my $contactGroupId2 = $res->[0][1]{created}{contactGroup2}{id}; + $self->assert_not_null($contactGroupId2); + my $contactGroupUid2 = $res->[0][1]{created}{contactGroup2}{uid}; + $self->assert_not_null($contactGroupUid2); + + my $contactGroupId3 = $res->[0][1]{created}{contactGroup3}{id}; + $self->assert_not_null($contactGroupId3); + my $contactGroupUid3 = $res->[0][1]{created}{contactGroup3}{uid}; + $self->assert_not_null($contactGroupUid3); + + xlog $self, "query by exact name"; + $res = $jmap->CallMethods([ + ['ContactGroup/query', { + filter => { + name => 'dogs and cats', + }, + }, 'R2'], + ]); + $self->assert_str_equals("ContactGroup/query", $res->[0][0]); + $self->assert_deep_equals([$contactGroupId1], $res->[0][1]{ids}); + + xlog $self, "query by unknown name"; + $res = $jmap->CallMethods([ + ['ContactGroup/query', { + filter => { + name => 'nope', + }, + }, 'R2'], + ]); + $self->assert_str_equals("ContactGroup/query", $res->[0][0]); + $self->assert_deep_equals([], $res->[0][1]{ids}); + + xlog $self, "query substring of name"; + $res = $jmap->CallMethods([ + ['ContactGroup/query', { + filter => { + operator => 'OR', + conditions => [{ + name => 'bats', + }, { + text => 'hats', + }], + }, + }, 'R2'], + ]); + $self->assert_str_equals("ContactGroup/query", $res->[0][0]); + my %gotIds = map { $_ => 1 } @{$res->[0][1]{ids}}; + $self->assert_deep_equals({ $contactGroupUid2 => 1, $contactGroupUid3 => 1, }, \%gotIds); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_query_uid b/cassandane/tiny-tests/JMAPContacts/contactgroup_query_uid new file mode 100644 index 0000000000..3e4eb151d0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_query_uid @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_query_uid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contact groups"; + my $res = $jmap->CallMethods([ + ['ContactGroup/set', { + create => { + contactGroup1 => { + name => 'contactGroup1', + }, + contactGroup2 => { + name => 'contactGroup2', + }, + contactGroup3 => { + name => 'contactGroup3', + }, + }, + }, 'R1'], + ]); + my $contactGroupId1 = $res->[0][1]{created}{contactGroup1}{id}; + $self->assert_not_null($contactGroupId1); + my $contactGroupUid1 = $res->[0][1]{created}{contactGroup1}{uid}; + $self->assert_not_null($contactGroupUid1); + + my $contactGroupId2 = $res->[0][1]{created}{contactGroup2}{id}; + $self->assert_not_null($contactGroupId2); + my $contactGroupUid2 = $res->[0][1]{created}{contactGroup2}{uid}; + $self->assert_not_null($contactGroupUid2); + + my $contactGroupId3 = $res->[0][1]{created}{contactGroup3}{id}; + $self->assert_not_null($contactGroupId3); + my $contactGroupUid3 = $res->[0][1]{created}{contactGroup3}{uid}; + $self->assert_not_null($contactGroupUid3); + + xlog $self, "query by single uid"; + $res = $jmap->CallMethods([ + ['ContactGroup/query', { + filter => { + uid => $contactGroupUid2, + }, + }, 'R2'], + ]); + $self->assert_str_equals("ContactGroup/query", $res->[0][0]); + $self->assert_deep_equals([$contactGroupId2], $res->[0][1]{ids}); + + xlog $self, "query by invalid uid"; + $res = $jmap->CallMethods([ + ['ContactGroup/query', { + filter => { + uid => "notarealuid", + }, + }, 'R2'], + ]); + $self->assert_str_equals("ContactGroup/query", $res->[0][0]); + $self->assert_deep_equals([], $res->[0][1]{ids}); + + xlog $self, "query by multiple uids"; + $res = $jmap->CallMethods([ + ['ContactGroup/query', { + filter => { + operator => 'OR', + conditions => [{ + uid => $contactGroupUid1, + }, { + uid => $contactGroupUid3, + }], + }, + }, 'R2'], + ]); + $self->assert_str_equals("ContactGroup/query", $res->[0][0]); + my %gotIds = map { $_ => 1 } @{$res->[0][1]{ids}}; + $self->assert_deep_equals({ $contactGroupUid1 => 1, $contactGroupUid3 => 1, }, \%gotIds); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_set b/cassandane/tiny-tests/JMAPContacts/contactgroup_set new file mode 100644 index 0000000000..704027d0ee --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_set @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_set + :min_version_3_1 :needs_component_jmap +{ + + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create contacts"; + my $res = $jmap->CallMethods([['Contact/set', {create => { + "1" => { firstName => "foo", lastName => "last1" }, + "2" => { firstName => "bar", lastName => "last2" } + }}, "R1"]]); + my $contact1 = $res->[0][1]{created}{"1"}{id}; + my $contact2 = $res->[0][1]{created}{"2"}{id}; + + xlog $self, "create contact group with no contact ids"; + $res = $jmap->CallMethods([['ContactGroup/set', {create => { + "1" => {name => "group1"} + }}, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get contact group $id"; + $res = $jmap->CallMethods([['ContactGroup/get', { ids => [$id] }, "R3"]]); + $self->assert_not_null($res); + $self->assert_str_equals('ContactGroup/get', $res->[0][0]); + $self->assert_str_equals('R3', $res->[0][2]); + $self->assert_str_equals('group1', $res->[0][1]{list}[0]{name}); + $self->assert(exists $res->[0][1]{list}[0]{contactIds}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}[0]{contactIds}}); + + xlog $self, "update contact group with invalid contact ids"; + $res = $jmap->CallMethods([['ContactGroup/set', {update => { + $id => {name => "group1", contactIds => [$contact1, $contact2, 255]} + }}, "R4"]]); + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert(exists $res->[0][1]{notUpdated}{$id}); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notUpdated}{$id}{type}); + $self->assert_str_equals('contactIds[2]', $res->[0][1]{notUpdated}{$id}{properties}[0]); + $self->assert_str_equals('R4', $res->[0][2]); + + xlog $self, "get contact group $id"; + $res = $jmap->CallMethods([['ContactGroup/get', { ids => [$id] }, "R3"]]); + $self->assert(exists $res->[0][1]{list}[0]{contactIds}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}[0]{contactIds}}); + + + xlog $self, "update contact group with valid contact ids"; + $res = $jmap->CallMethods([['ContactGroup/set', {update => { + $id => {name => "group1", contactIds => [$contact1, $contact2]} + }}, "R4"]]); + + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "get contact group $id"; + $res = $jmap->CallMethods([['ContactGroup/get', { ids => [$id] }, "R3"]]); + $self->assert(exists $res->[0][1]{list}[0]{contactIds}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}[0]{contactIds}}); + $self->assert_str_equals($contact1, $res->[0][1]{list}[0]{contactIds}[0]); + $self->assert_str_equals($contact2, $res->[0][1]{list}[0]{contactIds}[1]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_set_patch b/cassandane/tiny-tests/JMAPContacts/contactgroup_set_patch new file mode 100644 index 0000000000..d8a9c2a757 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_set_patch @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_set_patch + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['ContactGroup/set', { + create => { + 1 => { + name => 'name1', + otherAccountContactIds => { + other1 => ['contact1'], + other2 => ['contact2'] + } + } + } + }, "R1"], + ['ContactGroup/get', { ids => ['#1'] }, 'R2'], + ]); + $self->assert_str_equals('name1', $res->[1][1]{list}[0]{name}); + $self->assert_deep_equals({ + other1 => ['contact1'], + other2 => ['contact2'] + }, $res->[1][1]{list}[0]{otherAccountContactIds}); + my $groupId1 = $res->[1][1]{list}[0]{id}; + + $res = $jmap->CallMethods([ + ['ContactGroup/set', { + update => { + $groupId1 => { + name => 'updatedname1', + 'otherAccountContactIds/other2' => undef, + } + } + }, "R1"], + ['ContactGroup/get', { ids => [$groupId1] }, 'R2'], + ]); + $self->assert_str_equals('updatedname1', $res->[1][1]{list}[0]{name}); + $self->assert_deep_equals({ + other1 => ['contact1'], + }, $res->[1][1]{list}[0]{otherAccountContactIds}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_set_uid b/cassandane/tiny-tests/JMAPContacts/contactgroup_set_uid new file mode 100644 index 0000000000..bde9c49a30 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_set_uid @@ -0,0 +1,78 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_set_uid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # An empty UID generates a random uid. + my $res = $jmap->CallMethods([ + ['ContactGroup/set', { + create => { + "1" => { + name => "name1", + } + } + }, "R1"], + ['ContactGroup/get', { ids => ['#1'] }, 'R2'], + ]); + $self->assert_not_null($res->[1][1]{list}[0]{uid}); + $jmap->{CreatedIds} = {}; + + # A sane UID maps to both the JMAP id and the DAV resource. + $res = $jmap->CallMethods([ + ['ContactGroup/set', { + create => { + "2" => { + name => "name2", + uid => '1234-56789-01234-56789', + } + } + }, "R1"], + ['ContactGroup/get', { ids => ['#2'] }, 'R2'], + ]); + $self->assert_not_null($res->[1][1]{list}[0]{uid}); + my($filename, $dirs, $suffix) = fileparse($res->[1][1]{list}[0]{"x-href"}, ".vcf"); + $self->assert_not_null($res->[1][1]{list}[0]->{id}); + $self->assert_str_equals($res->[1][1]{list}[0]->{uid}, $res->[1][1]{list}[0]->{id}); + $self->assert_str_equals($filename, $res->[1][1]{list}[0]->{id}); + $jmap->{CreatedIds} = {}; + + # A non-pathsafe UID maps to uid but not the DAV resource. + $res = $jmap->CallMethods([ + ['ContactGroup/set', { + create => { + "3" => { + name => "name3", + uid => 'a/bogus/path#uid', + } + } + }, "R1"], + ['ContactGroup/get', { ids => ['#3'] }, 'R2'], + ]); + $self->assert_not_null($res->[1][1]{list}[0]{uid}); + ($filename, $dirs, $suffix) = fileparse($res->[1][1]{list}[0]{"x-href"}, ".vcf"); + $self->assert_not_null($res->[1][1]{list}[0]->{id}); + $self->assert_str_equals($res->[1][1]{list}[0]->{id}, $res->[1][1]{list}[0]->{uid}); + $self->assert_str_not_equals('path#uid', $filename); + $jmap->{CreatedIds} = {}; + + # Can't change an UID + my $contactId = $res->[0][1]{created}{3}{id}; + $self->assert_not_null($contactId); + $res = $jmap->CallMethods([ + ['ContactGroup/set', { + update => { + $contactId => { + uid => '0000-1234-56789-01234-56789-000' + } + } + }, "R1"], + ]); + $self->assert_str_equals('uid', $res->[0][1]{notUpdated}{$contactId}{properties}[0]); + $jmap->{CreatedIds} = {}; + +} diff --git a/cassandane/tiny-tests/JMAPContacts/contactgroup_set_update_v4 b/cassandane/tiny-tests/JMAPContacts/contactgroup_set_update_v4 new file mode 100644 index 0000000000..4f6668616a --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contactgroup_set_update_v4 @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_contactgroup_set_update_v4 + :min_version_3_9 :needs_component_jmap +{ + + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $contact1 = '60f60d95-1f33-480c-bfd6-02b93a07aefc'; + my $contact2 = '3e7cfbaf-3199-41bd-8749-38b8d1c89605'; + my $contact3 = '5b3b9ce1-0b5e-4cbd-8add-018321cad51b'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + ['ContactGroup/get', { + }, 'R1'] + ]); + $self->assert_str_equals($id, $res->[0][1]{list}[0]{id}); + $self->assert_str_equals('Test', $res->[0][1]{list}[0]{name}); + $self->assert_num_equals(3, scalar @{$res->[0][1]{list}[0]{contactIds}}); + $self->assert_str_equals($contact1, $res->[0][1]{list}[0]{contactIds}[0]); + $self->assert_str_equals($contact2, $res->[0][1]{list}[0]{contactIds}[1]); + $self->assert_str_equals($contact3, $res->[0][1]{list}[0]{contactIds}[2]); + + xlog $self, "update contact group by removing a member and reordering"; + $res = $jmap->CallMethods([['ContactGroup/set', {update => { + $id => {name => "group1", contactIds => [$contact3, $contact1]} + }}, "R4"]]); + + $self->assert_str_equals('ContactGroup/set', $res->[0][0]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "get contact group $id"; + $res = $jmap->CallMethods([['ContactGroup/get', { ids => [$id] }, "R3"]]); + $self->assert(exists $res->[0][1]{list}[0]{contactIds}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}[0]{contactIds}}); + $self->assert_str_equals($contact3, $res->[0][1]{list}[0]{contactIds}[0]); + $self->assert_str_equals($contact1, $res->[0][1]{list}[0]{contactIds}[1]); +} diff --git a/cassandane/tiny-tests/JMAPContacts/misc_categories b/cassandane/tiny-tests/JMAPContacts/misc_categories new file mode 100644 index 0000000000..8512e08a8a --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/misc_categories @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_categories + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $service = $self->{instance}->get_service("http"); + $ENV{DEBUGDAV} = 1; + my $carddav = Net::CardDAVTalk->new( + user => 'cassandane', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + + xlog $self, "create a contact with two categories"; + my $id = 'ae2640cc-234a-4dd9-95cc-3106258445b9'; + my $href = "Default/$id.vcf"; + my $card = <Request('PUT', $href, $card, 'Content-Type' => 'text/vcard'); + + my $data = $carddav->Request('GET', $href); + $self->assert_matches(qr/cat1,cat2/, $data->{content}); + + my $fetch = $jmap->CallMethods([['Contact/get', {ids => [$id]}, "R2"]]); + $self->assert_not_null($fetch); + $self->assert_str_equals('Contact/get', $fetch->[0][0]); + $self->assert_str_equals('R2', $fetch->[0][2]); + $self->assert_str_equals('Forrest', $fetch->[0][1]{list}[0]{firstName}); + + my $res = $jmap->CallMethods([['Contact/set', { + update => {$id => {firstName => "foo"}} + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + $data = $carddav->Request('GET', $href); + $self->assert_matches(qr/cat1,cat2/, $data->{content}); + +} diff --git a/cassandane/tiny-tests/JMAPContacts/misc_creationids b/cassandane/tiny-tests/JMAPContacts/misc_creationids new file mode 100644 index 0000000000..88735e52fb --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/misc_creationids @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_creationids + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create and get contact group and contact"; + my $res = $jmap->CallMethods([ + ['Contact/set', {create => { "c1" => { firstName => "foo", lastName => "last1" }, }}, "R2"], + ['ContactGroup/set', {create => { "g1" => {name => "group1", contactIds => ["#c1"]} }}, "R2"], + ['Contact/get', {ids => ["#c1"]}, "R3"], + ['ContactGroup/get', {ids => ["#g1"]}, "R4"], + ]); + my $contact = $res->[2][1]{list}[0]; + $self->assert_str_equals("foo", $contact->{firstName}); + + my $group = $res->[3][1]{list}[0]; + $self->assert_str_equals("group1", $group->{name}); + + $self->assert_str_equals($contact->{id}, $group->{contactIds}[0]); +} diff --git a/cassandane/tiny-tests/JMAPCore/bearer-auth-jwt b/cassandane/tiny-tests/JMAPCore/bearer-auth-jwt new file mode 100644 index 0000000000..6922d180d9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/bearer-auth-jwt @@ -0,0 +1,38 @@ +#!perl +use Cassandane::Tiny; + +sub test_bearer_auth_jwt + :min_version_3_5 :needs_component_jmap :NoAltNameSpace :HttpJWTAuthRSA +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $http = $self->{instance}->get_service("http"); + + my $token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjYXNzYW5kYW5lIn0.Eoa-9imqmFVYKU19yMaHZGEwiOWE3rSKQDw598rZYJvLqjrF8bG2fvMAUB6VeXxoJLca-uXAtTNHKBWYye9uvzTO3e8VMQOHHIb2RbBVyC7UxUEkbN8KC8YVrMNQoJDuugxeANKSrbmL8l6AtGEBK8iCoBnedleCzQ-nE7KtnwD356F63teK6jIoGW9KI0zNIeTe1k5Wh6NM3hZKC12mfU2JsOHTes-XH8lig2RQraBmdR1t9EKMTVztq-hXiVxvYtc3eIghdz5Ss52qr3VaCJJXExOXbnp0LwbUNUOFn1GCPfhRyEZdQxhGV19cO-RceIV1aawZnegdQS_kWERQNg"; + + xlog "Use valid RS256 token"; + my $RawRequest = { + headers => { + 'Authorization' => 'Bearer ' . $token, + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + + xlog "Use invalid RS256 token"; + $RawRequest = { + headers => { + 'Authorization' => 'Bearer ' . substr $token, 0, -3 + }, + content => '', + }; + $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('401', $RawResponse->{status}); +} diff --git a/cassandane/tiny-tests/JMAPCore/blob-download b/cassandane/tiny-tests/JMAPCore/blob-download new file mode 100644 index 0000000000..5f665a9a78 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/blob-download @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_download + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $data = $jmap->Upload("some test", "text/plain"); + + my $resp = $jmap->Download('cassandane', $data->{blobId}); + + $self->assert_str_equals('application/octet-stream', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); + + $resp = $jmap->Download({ accept => 'text/plain' }, 'cassandane', $data->{blobId}); + $self->assert_str_equals('text/plain', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); + + $resp = $jmap->Download({ accept => 'text/plain;q=0.9, text/html' }, 'cassandane', $data->{blobId}); + $self->assert_str_equals('text/html', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); + + $resp = $jmap->Download({ accept => '*/*' }, 'cassandane', $data->{blobId}); + $self->assert_str_equals('application/octet-stream', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); + + $resp = $jmap->Download({ accept => 'foo' }, 'cassandane', $data->{blobId}); + $self->assert_str_equals('application/octet-stream', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); + + $resp = $jmap->Download({ accept => 'foo*/bar' }, 'cassandane', $data->{blobId}); + $self->assert_str_equals('application/octet-stream', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); + + $resp = $jmap->Download({ accept => 'foo/(bar)' }, 'cassandane', $data->{blobId}); + $self->assert_str_equals('application/octet-stream', $resp->{headers}{'content-type'}); + $self->assert_str_equals('some test', $resp->{content}); +} diff --git a/cassandane/tiny-tests/JMAPCore/blob-download-name b/cassandane/tiny-tests/JMAPCore/blob-download-name new file mode 100644 index 0000000000..b9932151af --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/blob-download-name @@ -0,0 +1,19 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_download_name + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $data = $jmap->Upload("some test", "text/plain"); + + my $resp = $jmap->Download('cassandane', $data->{blobId}, 'foo'); + $self->assert_str_equals('attachment; filename="foo"', + $resp->{headers}{'content-disposition'}); + + $resp = $jmap->Download('cassandane', $data->{blobId}, decode_utf8('тест.txt')); + $self->assert_str_equals("attachment; filename*=utf-8''%D1%82%D0%B5%D1%81%D1%82.txt", + $resp->{headers}{'content-disposition'}); +} diff --git a/cassandane/tiny-tests/JMAPCore/blob-upload-bad-url b/cassandane/tiny-tests/JMAPCore/blob-upload-bad-url new file mode 100644 index 0000000000..5367043a38 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/blob-upload-bad-url @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_upload_bad_url + :min_version_3_9 :needs_component_jmap :JMAPExtensions +{ + my $self = shift; + my $jmap = $self->{jmap}; + + xlog "Assert Problem Details report"; + my $httpReq = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => 'Hello World', + }; + my $httpRes = $jmap->ua->post($jmap->uploaduri('cassandane') . 'X', $httpReq); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($httpReq, $httpRes); + } + $self->assert_str_equals("404", $httpRes->{status}); + my $res = eval { decode_json($httpRes->{content}) }; + $self->assert_str_equals("404", $res->{status}); + $self->assert_str_equals("Not Found", $res->{title}); + $self->assert_str_equals("about:blank", $res->{type}); + $self->assert_str_equals("unknown uploadUrl", $res->{detail}); +} diff --git a/cassandane/tiny-tests/JMAPCore/blob-upload-repair-acl b/cassandane/tiny-tests/JMAPCore/blob-upload-repair-acl new file mode 100644 index 0000000000..71bd943b4c --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/blob-upload-repair-acl @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_upload_repair_acl + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my $self = shift; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + $jmap->Upload("hello", "application/data"); + + my $binary = slurp_file(abs_path('data/mime/repair_acl.eml')); + + xlog "Assert that uploading duplicates does not fail"; + $admin->setacl("user.cassandane.#jmap", "cassandane", "lrswkcni") or die; + my $res = $jmap->Upload($binary); + my $blobId = $res->{blobId}; + $res = $jmap->Upload($binary, "message/rfc822"); + $self->assert_str_equals($blobId, $res->{blobId}); + + xlog "Assert ACLs got repaired"; + my %acl = @{$admin->getacl("user.cassandane.#jmap")}; + $self->assert_str_equals("lrswitedn", $acl{cassandane}); +} diff --git a/cassandane/tiny-tests/JMAPCore/blob-upload-too-large b/cassandane/tiny-tests/JMAPCore/blob-upload-too-large new file mode 100644 index 0000000000..908fcba36b --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/blob-upload-too-large @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_upload_too_large + :min_version_3_9 :needs_component_jmap :JMAPExtensions +{ + my $self = shift; + my $jmap = $self->{jmap}; + + xlog "Assert Problem Details report"; + my $httpReq = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => 'X' x 1025, + }; + my $httpRes = $jmap->ua->post($jmap->uploaduri('cassandane'), $httpReq); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($httpReq, $httpRes); + } + $self->assert_str_equals("413", $httpRes->{status}); + my $res = eval { decode_json($httpRes->{content}) }; + $self->assert_str_equals("413", $res->{status}); + $self->assert_str_equals("Content Too Large", $res->{title}); + $self->assert_str_equals("urn:ietf:params:jmap:error:limit", $res->{type}); + $self->assert_str_equals("maxSizeUpload", $res->{limit}); +} diff --git a/cassandane/tiny-tests/JMAPCore/blob-upload-type b/cassandane/tiny-tests/JMAPCore/blob-upload-type new file mode 100644 index 0000000000..025bc73b51 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/blob-upload-type @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_upload_type + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my $self = shift; + my $jmap = $self->{jmap}; + + xlog "Assert client-supplied type is returned"; + my $res = $jmap->Upload("blob1", "text/plain"); + $self->assert_str_equals("text/plain", $res->{type}); + + xlog "Assert client-supplied type is normalized"; + $res = $jmap->Upload("blob1", "text/plain;charset=latin1"); + $self->assert_str_equals("text/plain", $res->{type}); + + xlog "Assert default server type"; + my $httpReq = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => 'blob2', + }; + my $httpRes = $jmap->ua->post($jmap->uploaduri('cassandane'), $httpReq); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($httpReq, $httpRes); + } + $res = eval { decode_json($httpRes->{content}) }; + $self->assert_str_equals("application/octet-stream", $res->{type}); +} diff --git a/cassandane/tiny-tests/JMAPCore/capabilities b/cassandane/tiny-tests/JMAPCore/capabilities new file mode 100644 index 0000000000..2b5b093d06 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/capabilities @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_capabilities + :min_version_3_1 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + $self->{instance}->create_user("other"); + $admintalk->create("user.other.box1") or die; + $admintalk->setacl("user.other.box1", "cassandane", "lrswp") or die; + + # Missing capability in 'using' + my $res = $jmap->CallMethods([ + ['Core/echo', { hello => 'world' }, "R1"] + ], []); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('unknownMethod', $res->[0][1]{type}); + + # Missing capability in account capabilities + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + accountId => 'other' + }, "R1"] + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('accountNotSupportedByMethod', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPCore/created-ids b/cassandane/tiny-tests/JMAPCore/created-ids new file mode 100644 index 0000000000..f4b57339a3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/created-ids @@ -0,0 +1,108 @@ +#!perl +use Cassandane::Tiny; + +sub test_created_ids + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "send bogus creation ids map"; + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + }, + content => encode_json({ + using => ['urn:ietf:params:jmap:mail'], + methodCalls => [['Identity/get', {}, 'R1']], + createdIds => 'bogus', + }), + }; + my $RawResponse = $jmap->ua->post($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('400', $RawResponse->{status}); + + xlog $self, "create a mailbox without any client-supplied creation ids"; + my $JMAPRequest = { + using => ['urn:ietf:params:jmap:mail'], + methodCalls => [['Mailbox/set', { + create => { + "1" => { + name => "foo", + parentId => undef, + role => undef + } + } + }, "R1"]], + }; + my $JMAPResponse = $jmap->Request($JMAPRequest); + my $mboxid1 = $JMAPResponse->{methodResponses}->[0][1]{created}{1}{id}; + $self->assert_not_null($mboxid1); + $self->assert_null($JMAPResponse->{createdIds}); + + xlog $self, "get mailbox using client-supplied creation id"; + $JMAPRequest = { + using => ['urn:ietf:params:jmap:mail'], + methodCalls => [['Mailbox/get', { ids => ['#1'] }, 'R1']], + createdIds => { 1 => $mboxid1 }, + }; + $JMAPResponse = $jmap->Request($JMAPRequest); + $self->assert_str_equals($mboxid1, $JMAPResponse->{methodResponses}->[0][1]{list}[0]{id}); + $self->assert_not_null($JMAPResponse->{createdIds}); + $self->assert_str_equals($mboxid1, $JMAPResponse->{createdIds}{1}); + + xlog $self, "create a mailbox with empty client-supplied creation ids"; + $JMAPRequest = { + using => ['urn:ietf:params:jmap:mail'], + methodCalls => [['Mailbox/set', { + create => { + "2" => { + name => "bar", + parentId => undef, + role => undef + } + } + }, "R1"]], + createdIds => {}, + }; + $JMAPResponse = $jmap->Request($JMAPRequest); + my $mboxid2 = $JMAPResponse->{methodResponses}->[0][1]{created}{2}{id}; + $self->assert_str_equals($mboxid2, $JMAPResponse->{createdIds}{2}); + + xlog $self, "create a mailbox with client-supplied creation ids"; + $JMAPRequest = { + using => ['urn:ietf:params:jmap:mail'], + methodCalls => [['Mailbox/set', { + create => { + "3" => { + name => "baz", + parentId => "#2", + role => undef + } + } + }, "R1"]], + createdIds => { + 1 => $mboxid1, + 2 => $mboxid2, + }, + }; + $JMAPResponse = $jmap->Request($JMAPRequest); + my $mboxid3 = $JMAPResponse->{methodResponses}->[0][1]{created}{3}{id}; + $self->assert_str_equals($mboxid1, $JMAPResponse->{createdIds}{1}); + $self->assert_str_equals($mboxid2, $JMAPResponse->{createdIds}{2}); + $self->assert_str_equals($mboxid3, $JMAPResponse->{createdIds}{3}); + + xlog $self, "get mailbox and check parentid"; + $JMAPRequest = { + using => ['urn:ietf:params:jmap:mail'], + methodCalls => [['Mailbox/get', { ids => [$mboxid3], properties => ['parentId'] }, 'R1']], + }; + $JMAPResponse = $jmap->Request($JMAPRequest); + $self->assert_str_equals($mboxid3, $JMAPResponse->{methodResponses}->[0][1]{list}[0]{id}); + $self->assert_str_equals($mboxid2, $JMAPResponse->{methodResponses}->[0][1]{list}[0]{parentId}); + $self->assert_null($JMAPResponse->{createdIds}); +} diff --git a/cassandane/tiny-tests/JMAPCore/echo b/cassandane/tiny-tests/JMAPCore/echo new file mode 100644 index 0000000000..7541a8aef5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/echo @@ -0,0 +1,26 @@ +#!perl +use Cassandane::Tiny; + +sub test_echo + :min_version_3_1 :needs_component_jmap +{ + + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $req = { + hello => JSON::true, + max => 5, + stuff => { foo => "bar", empty => JSON::null } + }; + + xlog $self, "send ping"; + my $res = $jmap->CallMethods([['Core/echo', $req, "R1"]]); + + xlog $self, "check pong"; + $self->assert_not_null($res); + $self->assert_str_equals('Core/echo', $res->[0][0]); + $self->assert_deep_equals($req, $res->[0][1]); + $self->assert_str_equals('R1', $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPCore/eventsource b/cassandane/tiny-tests/JMAPCore/eventsource new file mode 100644 index 0000000000..55164dad1d --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/eventsource @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_eventsource + :min_version_3_5 :needs_component_jmap :JMAPExtensions :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $http = $self->{instance}->get_service("http"); + + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + my $session = eval { decode_json($RawResponse->{content}) }; + $self->assert_not_null($session); + my $url = $session->{eventSourceUrl}; + $self->assert_not_null($url); + + $self->assert_num_equals(1, $url =~ s/\{types\}/Email/g); + $self->assert_num_equals(1, $url =~ s/\{closeafter\}/state/g); + $self->assert_num_equals(1, $url =~ s/\{ping\}/0/g); + + if (not $url =~ /^http/) { + $url = "http://".$http->host().":".$http->port().$url; + } + + $RawRequest->{headers}->{'Last-Event-Id'} = '0'; + $RawResponse = $jmap->ua->get($url, $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + $self->assert_str_equals('text/event-stream', + $RawResponse->{headers}{'content-type'}); + $self->assert_null($RawResponse->{headers}{'content-length'}); + + my %event = $RawResponse->{content} =~ /^(\w+): ?(.*)$/mg; + $self->assert_not_null($event{id}); + $self->assert_str_equals('state', $event{event}); + + my $data = eval { decode_json($event{data}) }; + $self->assert_not_null($data); + $self->assert_str_equals('StateChange', $data->{'@type'}); + $self->assert_not_null($data->{changed}); + $self->assert_not_null($data->{changed}->{cassandane}); + $self->assert_not_null($data->{changed}->{cassandane}->{Email}); +} diff --git a/cassandane/tiny-tests/JMAPCore/get-session b/cassandane/tiny-tests/JMAPCore/get-session new file mode 100644 index 0000000000..0fa15a69c3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/get-session @@ -0,0 +1,190 @@ +#!perl +use Cassandane::Tiny; + +sub test_get_session + :min_version_3_1 :needs_component_jmap :JMAPExtensions :NoAltNameSpace + :want_smtpdaemon +{ + my ($self) = @_; + + # need to version-gate jmap features that aren't in 3.2... + my ($maj, $min) = Cassandane::Instance->get_version(); + + my $buildinfo = Cassandane::BuildInfo->new(); + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "setup shared accounts"; + $self->{instance}->create_user("account1"); + $self->{instance}->create_user("account2"); + $self->{instance}->create_user("account3"); + $self->{instance}->create_user("account4"); + + # Account 1: read-only mail, calendars. No contacts. + my $httpService = $self->{instance}->get_service("http"); + my $account1CalDAVTalk = Net::CalDAVTalk->new( + user => "account1", + password => 'pass', + host => $httpService->host(), + port => $httpService->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $account1CalendarId = $account1CalDAVTalk->NewCalendar({name => 'calendar1'}); + $admintalk->setacl("user.account1", "cassandane", "lr") or die; + $admintalk->setacl("user.account1.#calendars.Default", "cassandane" => 'lr') or die; + $admintalk->setacl("user.account1.#addressbooks.Default", "cassandane" => '') or die; + # Account 2: read/write mail + $admintalk->setacl("user.account2", "cassandane", "lrswipkxtecdn") or die; + # Account 3: no access + + # GET session + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + my $session = eval { decode_json($RawResponse->{content}) }; + $self->assert_not_null($session); + + # Validate session + $self->assert_not_null($session->{username}); + $self->assert_not_null($session->{apiUrl}); + $self->assert_not_null($session->{downloadUrl}); + $self->assert_not_null($session->{uploadUrl}); + if ($maj > 3 || ($maj == 3 && $min >= 3)) { + $self->assert_not_null($session->{eventSourceUrl}); + } + $self->assert_not_null($session->{state}); + + # Validate server capabilities + my $capabilities = $session->{capabilities}; + $self->assert_not_null($capabilities); + my $coreCapability = $capabilities->{'urn:ietf:params:jmap:core'}; + $self->assert_not_null($coreCapability); + $self->assert($coreCapability->{maxSizeUpload} > 0); + $self->assert($coreCapability->{maxConcurrentUpload} > 0); + $self->assert($coreCapability->{maxSizeRequest} > 0); + $self->assert($coreCapability->{maxConcurrentRequests} > 0); + $self->assert($coreCapability->{maxCallsInRequest} > 0); + $self->assert($coreCapability->{maxObjectsInGet} > 0); + $self->assert($coreCapability->{maxObjectsInSet} > 0); + $self->assert(exists $coreCapability->{collationAlgorithms}); + $self->assert_deep_equals({}, $capabilities->{'urn:ietf:params:jmap:blob'}); + $self->assert_deep_equals({}, $capabilities->{'urn:ietf:params:jmap:mail'}); + $self->assert_deep_equals({}, $capabilities->{'urn:ietf:params:jmap:submission'}); + $self->assert_deep_equals({}, $capabilities->{'urn:ietf:params:jmap:calendars'}); + $self->assert_deep_equals({}, $capabilities->{'https://cyrusimap.org/ns/jmap/contacts'}); + $self->assert_deep_equals({ isRFC => JSON::true }, + , $capabilities->{'https://cyrusimap.org/ns/jmap/calendars'}); + if ($buildinfo->get('component', 'sieve')) { + $self->assert_deep_equals({}, $capabilities->{'urn:ietf:params:jmap:vacationresponse'}); + if ($maj > 3 || ($maj == 3 && $min >= 3)) { + # jmap sieve added in 3.3 + $self->assert_not_null($capabilities->{'urn:ietf:params:jmap:sieve'}->{implementation}); + $self->assert_deep_equals({}, $capabilities->{'https://cyrusimap.org/ns/jmap/sieve'}); + } + } + if ($buildinfo->get('dependency', 'icalvcard')) { + $self->assert_deep_equals({}, $capabilities->{'urn:ietf:params:jmap:contacts'}); + } + + # primaryAccounts + my $expect_primaryAccounts = { + 'urn:ietf:params:jmap:blob' => 'cassandane', + 'urn:ietf:params:jmap:mail' => 'cassandane', + 'urn:ietf:params:jmap:submission' => 'cassandane', + 'urn:ietf:params:jmap:calendars' => 'cassandane', + 'urn:ietf:params:jmap:principals' => 'cassandane', + 'https://cyrusimap.org/ns/jmap/contacts' => 'cassandane', + 'https://cyrusimap.org/ns/jmap/calendars' => 'cassandane', + }; + if ($maj > 3 || ($maj == 3 && $min >= 3)) { + # jmap backup and sieve added in 3.3 + $expect_primaryAccounts->{'https://cyrusimap.org/ns/jmap/backup'} + = 'cassandane'; + } + if ($buildinfo->get('component', 'sieve')) { + $expect_primaryAccounts->{'urn:ietf:params:jmap:vacationresponse'} + = 'cassandane'; + if ($maj > 3 || ($maj == 3 && $min >= 3)) { + # jmap sieve added in 3.3 + $expect_primaryAccounts->{'urn:ietf:params:jmap:sieve'} + = 'cassandane'; + $expect_primaryAccounts->{'https://cyrusimap.org/ns/jmap/sieve'} + = 'cassandane'; + } + } + if ($buildinfo->get('dependency', 'icalvcard')) { + $expect_primaryAccounts->{'urn:ietf:params:jmap:contacts'} + = 'cassandane'; + } + $self->assert_deep_equals($expect_primaryAccounts, + $session->{primaryAccounts}); + + $self->assert_num_equals(3, scalar keys %{$session->{accounts}}); + $self->assert_not_null($session->{accounts}{cassandane}); + + my $primaryAccount = $session->{accounts}{cassandane}; + $self->assert_not_null($primaryAccount); + my $account1 = $session->{accounts}{account1}; + $self->assert_not_null($account1); + my $account2 = $session->{accounts}{account2}; + $self->assert_not_null($account2); + + $self->assert_str_equals('cassandane', $primaryAccount->{name}); + $self->assert_equals(JSON::false, $primaryAccount->{isReadOnly}); + $self->assert_equals(JSON::true, $primaryAccount->{isPersonal}); + my $accountCapabilities = $primaryAccount->{accountCapabilities}; + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:blob'}); + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:mail'}); + $self->assert_equals(JSON::true, $accountCapabilities->{'urn:ietf:params:jmap:mail'}{mayCreateTopLevelMailbox}); + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:submission'}); + if ($buildinfo->get('component', 'sieve')) { + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:vacationresponse'}); + } + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:calendars'}); + $self->assert_not_null($accountCapabilities->{'https://cyrusimap.org/ns/jmap/contacts'}); + $self->assert_not_null($accountCapabilities->{'https://cyrusimap.org/ns/jmap/calendars'}); + + # Account 1: read-only mail, calendars. No contacts. + $self->assert_str_equals('account1', $account1->{name}); + $self->assert_equals(JSON::true, $account1->{isReadOnly}); + $self->assert_equals(JSON::false, $account1->{isPersonal}); + $accountCapabilities = $account1->{accountCapabilities}; + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:blob'}); + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:mail'}); + $self->assert_equals(JSON::false, $accountCapabilities->{'urn:ietf:params:jmap:mail'}{mayCreateTopLevelMailbox}); + $self->assert_null($accountCapabilities->{'urn:ietf:params:jmap:submission'}); + if ($buildinfo->get('component', 'sieve')) { + $self->assert_null($accountCapabilities->{'urn:ietf:params:jmap:vacationresponse'}); + } + $self->assert_null($accountCapabilities->{'https://cyrusimap.org/ns/jmap/contacts'}); + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:calendars'}); + $self->assert_not_null($accountCapabilities->{'https://cyrusimap.org/ns/jmap/calendars'}); + + # Account 2: read/write mail + $self->assert_str_equals('account2', $account2->{name}); + $self->assert_equals(JSON::false, $account2->{isReadOnly}); + $self->assert_equals(JSON::false, $account2->{isPersonal}); + $accountCapabilities = $account2->{accountCapabilities}; + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:blob'}); + $self->assert_not_null($accountCapabilities->{'urn:ietf:params:jmap:mail'}); + $self->assert_equals(JSON::true, $accountCapabilities->{'urn:ietf:params:jmap:mail'}{mayCreateTopLevelMailbox}); + $self->assert_null($accountCapabilities->{'urn:ietf:params:jmap:submission'}); + if ($buildinfo->get('component', 'sieve')) { + $self->assert_null($accountCapabilities->{'urn:ietf:params:jmap:vacationresponse'}); + } + $self->assert_null($accountCapabilities->{'urn:ietf:params:jmap:calendars'}); + $self->assert_null($accountCapabilities->{'https://cyrusimap.org/ns/jmap/contacts'}); + $self->assert_null($accountCapabilities->{'https://cyrusimap.org/ns/jmap/calendars'}); +} diff --git a/cassandane/tiny-tests/JMAPCore/get-session-rename-race b/cassandane/tiny-tests/JMAPCore/get-session-rename-race new file mode 100644 index 0000000000..2091b4d508 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/get-session-rename-race @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_get_session_rename_race + :AllowMoves :min_version_3_7 :needs_component_jmap +{ + # Test fetching the JMAP session during/after a user has been renamed + # but before the authentication credentials have been changed. + # In this case, we should return 503 rather than 500. + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + + # GET session + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + }, + content => '', + }; + my $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('200', $RawResponse->{status}); + my $session = eval { decode_json($RawResponse->{content}) }; + $self->assert_not_null($session); + + my $res = $admintalk->rename('user.cassandane', 'user.newuser'); + $self->assert(not $admintalk->get_last_error()); + + # Try to GET session + $RawResponse = $jmap->ua->get($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('503', $RawResponse->{status}); + $self->assert_not_null($RawResponse->{headers}{'retry-after'}); +} diff --git a/cassandane/tiny-tests/JMAPCore/identity-get b/cassandane/tiny-tests/JMAPCore/identity-get new file mode 100644 index 0000000000..3db18b78a6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/identity-get @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_identity_get + :min_version_3_1 :needs_component_jmap + :want_smtpdaemon +{ + + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:submission', + ]; + + my $res = $jmap->CallMethods([ + ['Identity/get', { }, 'R1'], + ['Identity/get', { ids => undef }, 'R2'], + ['Identity/get', { ids => [] }, 'R3'], + ], $using); + + $self->assert_str_equals('Identity/get', $res->[0][0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('cassandane', $res->[0][1]{list}[0]{id}); + $self->assert_not_null($res->[0][1]->{state}); + $self->assert_str_equals('R1', $res->[0][2]); + + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_str_equals('cassandane', $res->[1][1]{list}[0]{id}); + $self->assert_not_null($res->[1][1]->{state}); + + $self->assert_deep_equals([], $res->[2][1]{list}); + $self->assert_not_null($res->[2][1]->{state}); +} diff --git a/cassandane/tiny-tests/JMAPCore/require-conversations b/cassandane/tiny-tests/JMAPCore/require-conversations new file mode 100644 index 0000000000..403c7bdb02 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/require-conversations @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_require_conversations + :min_version_3_1 :needs_component_jmap :NoStartInstances +{ + my ($self) = @_; + + my $instance = $self->{instance}; + $instance->{config}->set(conversations => 'no'); + + $self->_start_instances(); + $self->_setup_http_service_objects(); + + my $jmap = $self->{jmap}; + my $JMAPRequest = { + using => ['urn:ietf:params:jmap:core'], + methodCalls => [['Core/echo', { }, 'R1']], + }; + + # request should fail + my ($response, undef) = $jmap->Request($JMAPRequest); + $self->assert(not $response->{success}); + + # httpd should syslog an error + $self->assert_syslog_matches( + $self->{instance}, + qr/ERROR: cannot enable \w+ module with conversations disabled/, + ); +} diff --git a/cassandane/tiny-tests/JMAPCore/sessionstate b/cassandane/tiny-tests/JMAPCore/sessionstate new file mode 100644 index 0000000000..d6c472ae74 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/sessionstate @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_sessionstate + :min_version_3_1 :needs_component_jmap :ReverseACLs +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + $self->{instance}->create_user("other"); + + # Fetch sessionState + my $JMAPRequest = { + using => ['urn:ietf:params:jmap:core'], + methodCalls => [['Core/echo', { }, 'R1']], + }; + my $JMAPResponse = $jmap->Request($JMAPRequest); + $self->assert_not_null($JMAPResponse->{sessionState}); + my $sessionState = $JMAPResponse->{sessionState}; + + # Update ACL + $admintalk->setacl("user.other", "cassandane", "lr") or die; + + # Fetch sessionState + $JMAPResponse = $jmap->Request($JMAPRequest); + $self->assert_str_not_equals($sessionState, $JMAPResponse->{sessionState}); + $sessionState = $JMAPResponse->{sessionState}; + + # Update ACL + $admintalk->setacl("user.other", "cassandane", "") or die; + + # Fetch sessionState + $JMAPResponse = $jmap->Request($JMAPRequest); + $self->assert_str_not_equals($sessionState, $JMAPResponse->{sessionState}); + $sessionState = $JMAPResponse->{sessionState}; +} diff --git a/cassandane/tiny-tests/JMAPCore/using-unknown-capability b/cassandane/tiny-tests/JMAPCore/using-unknown-capability new file mode 100644 index 0000000000..8aa7d86f41 --- /dev/null +++ b/cassandane/tiny-tests/JMAPCore/using-unknown-capability @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_unknown_capability + :min_version_3_1 :needs_component_jmap +{ + + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + }, + content => encode_json({ + using => [ + 'urn:ietf:params:jmap:core', + 'urn:foo' # Unknown capability + ], + methodCalls => [['Core/echo', { hello => JSON::true }, 'R1']], + }), + }; + my $RawResponse = $jmap->ua->post($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert_str_equals('400', $RawResponse->{status}); + + my $Response = eval { decode_json($RawResponse->{content}) }; + $self->assert_str_equals('urn:ietf:params:jmap:error:unknownCapability', $Response->{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/attach_base64_email b/cassandane/tiny-tests/JMAPEmail/attach_base64_email new file mode 100644 index 0000000000..660947b9d5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/attach_base64_email @@ -0,0 +1,99 @@ +#!perl +use Cassandane::Tiny; + +sub test_attach_base64_email + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + open(my $F, 'data/mime/base64-body.eml') || die $!; + $imap->append('INBOX', $F) || die $@; + close($F); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + }, "R2"], + ['Mailbox/get', {}, 'R3'], + ]); + + my $blobId = $res->[1][1]{list}[0]{blobId}; + my $size = $res->[1][1]{list}[0]{size}; + my $name = $res->[1][1]{list}[0]{subject} . ".eml"; + + my $mailboxId = $res->[2][1]{list}[0]{id}; + + xlog $self, "Now we create an email which includes this"; + + $res = $jmap->CallMethods([ + ['Email/set', { create => { 1 => { + bcc => undef, + bodyStructure => { + subParts => [{ + partId => "text", + type => "text/plain" + },{ + blobId => $blobId, + cid => undef, + disposition => "attachment", + name => $name, + size => $size, + type => "message/rfc822" + }], + type => "multipart/mixed", + }, + bodyValues => { + text => { + isTruncated => $JSON::false, + value => "Hello World", + }, + }, + cc => undef, + from => [{ + email => "foo\@example.com", + name => "Captain Foo", + }], + keywords => { + '$draft' => $JSON::true, + '$seen' => $JSON::true, + }, + mailboxIds => { + $mailboxId => $JSON::true, + }, + messageId => ["9048d4db-bd84-4ea4-9be3-ae4a136c532d\@example.com"], + receivedAt => "2019-05-09T12:48:08Z", + references => undef, + replyTo => undef, + sentAt => "2019-05-09T14:48:08+02:00", + subject => "Hello again", + to => [{ + email => "bar\@example.com", + name => "Private Bar", + }], + }}}, "S1"], + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + }, "R2"], + ]); + + $imap->select("INBOX"); + my $ires = $imap->fetch('1:*', '(BODYSTRUCTURE)'); + + $self->assert_str_equals('RE: Hello.eml', $ires->{2}{bodystructure}{'MIME-Subparts'}[1]{'Content-Disposition'}{filename}); + $self->assert_str_not_equals('BINARY', $ires->{2}{bodystructure}{'MIME-Subparts'}[1]{'Content-Transfer-Encoding'}); + + my ($replyEmail) = grep { $_->{subject} eq 'Hello again' } @{$res->[2][1]{list}}; + $self->assert_str_equals($blobId, $replyEmail->{attachments}[0]{blobId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/base64_forward b/cassandane/tiny-tests/JMAPEmail/base64_forward new file mode 100644 index 0000000000..a6aaaeb297 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/base64_forward @@ -0,0 +1,112 @@ +#!perl +use Cassandane::Tiny; + +sub test_base64_forward + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + # Generate an email to have some blob ids + xlog $self, "Generate an email in $inbox via IMAP"; + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: image/jpeg\r\n" + . "Content-Transfer-Encoding: base64\r\n" . "\r\n" + . "beefc0de" + . "\r\n--sub--\r\n", + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { + ids => $ids, + properties => ['bodyStructure', 'mailboxIds'], + }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $blobid = $msg->{bodyStructure}{subParts}[1]{blobId}; + $self->assert_not_null($blobid); + my $size = $msg->{bodyStructure}{subParts}[1]{size}; + $self->assert_num_equals(6, $size); + + $res = $jmap->Download('cassandane', $blobid); + $self->assert_str_equals("beefc0de", encode_base64($res->{content}, '')); + + # now create a new message referencing this blobId: + + $res = $jmap->CallMethods([['Email/set', { + create => { + k1 => { + bcc => undef, + bodyStructure => { + subParts => [{ + partId => 'text', + type => 'text/plain', + },{ + blobId => $blobid, + cid => undef, + disposition => 'attachment', + height => undef, + name => 'foobar.jpg', + size => $size, + type => 'image/jpeg', + width => undef, + }], + type => 'multipart/mixed', + }, + bodyValues => { + text => { + isTruncated => $JSON::false, + value => "Hello world", + }, + }, + cc => undef, + inReplyTo => undef, + mailboxIds => $msg->{mailboxIds}, + from => [ {email => 'foo@example.com', name => 'foo' } ], + keywords => { '$draft' => $JSON::true, '$seen' => $JSON::true }, + receivedAt => '2018-06-26T03:10:07Z', + references => undef, + replyTo => undef, + sentAt => '2018-06-26T03:10:07Z', + subject => 'test email', + to => [ {email => 'foo@example.com', name => 'foo' } ], + }, + }, + }, "R1"]]); + + my $id = $res->[0][1]{created}{k1}{id}; + $self->assert_not_null($id); + + $res = $jmap->CallMethods([['Email/get', { + ids => [$id], + properties => ['bodyStructure'], + }, "R1"]]); + $msg = $res->[0][1]{list}[0]; + + my $newpart = $msg->{bodyStructure}{subParts}[1]; + $self->assert_str_equals("foobar.jpg", $newpart->{name}); + $self->assert_str_equals("image/jpeg", $newpart->{type}); + $self->assert_num_equals(6, $newpart->{size}); + + # XXX - in theory, this IS allowed to change + if ($newpart->{blobId} ne $blobid) { + $res = $jmap->Download('cassandane', $blobid); + # but this isn't! + $self->assert_str_equals("beefc0de", encode_base64($res->{content}, '')); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/blob_copy b/cassandane/tiny-tests/JMAPEmail/blob_copy new file mode 100644 index 0000000000..8b882f56a9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/blob_copy @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_copy + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # FIXME how to share just #jmap folder? + xlog $self, "create user foo and share inbox"; + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lrkintex") or die; + + xlog $self, "upload blob in main account"; + my $data = $jmap->Upload('somedata', "text/plain"); + $self->assert_not_null($data); + + xlog $self, "attempt to download from shared account (should fail)"; + my $res = $self->download('foo', $data->{blobId}); + $self->assert_str_equals('404', $res->{status}); + + xlog $self, "copy blob to shared account"; + $res = $jmap->CallMethods([['Blob/copy', { + fromAccountId => 'cassandane', + accountId => 'foo', + blobIds => [ $data->{blobId} ], + }, 'R1']]); + + xlog $self, "download from shared account"; + $res = $self->download('foo', $data->{blobId}); + $self->assert_str_equals('200', $res->{status}); + + xlog $self, "generate an email in INBOX via IMAP"; + $self->make_message("Email A") || die; + + xlog $self, "get email blob id"; + $res = $jmap->CallMethods([ + ['Email/query', {}, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'blobId' ], + }, 'R2'] + ]); + my $msgblobId = $res->[1][1]->{list}[0]{blobId}; + + xlog $self, "copy Email blob to shared account"; + $res = $jmap->CallMethods([['Blob/copy', { + fromAccountId => 'cassandane', + accountId => 'foo', + blobIds => [ $msgblobId ], + }, 'R1']]); + + xlog $self, "download Email blob from shared account"; + $res = $self->download('foo', $msgblobId); + $self->assert_str_equals('200', $res->{status}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/blob_download b/cassandane/tiny-tests/JMAPEmail/blob_download new file mode 100644 index 0000000000..b88ab73b84 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/blob_download @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_download + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $binary = slurp_file(abs_path('data/logo.gif')); + my $data = $jmap->Upload($binary, "image/gif"); + + my $blob = $jmap->Download({ accept => 'image/gif' }, 'cassandane', $data->{blobId}); + $self->assert_str_equals('image/gif', $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_equals($binary, $blob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/blob_get b/cassandane/tiny-tests/JMAPEmail/blob_get new file mode 100644 index 0000000000..f8d7edac74 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/blob_get @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_blob_get + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + $self->make_message("foo") || die; + + my $res = $jmap->CallMethods([ + ['Email/query', {}, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2'], + ]); + + my $blobId = $res->[1][1]{list}[0]{blobId}; + $self->assert_not_null($blobId); + + my $wantMailboxIds = [keys %{$res->[1][1]{list}[0]{mailboxIds}}]; + my $wantEmailIds = [$res->[1][1]{list}[0]{id}]; + my $wantThreadIds = [$res->[1][1]{list}[0]{threadId}]; + + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/blob'; + $jmap->DefaultUsing(\@using); + + $res = $jmap->CallMethods([ + ['Blob/lookup', { ids => [$blobId], types => ['Mailbox', 'Thread', 'Email']}, "R1"], + ]); + + my $blob = $res->[0][1]{list}[0]; + $self->assert_deep_equals($wantMailboxIds, $blob->{types}{Mailbox}); + $self->assert_deep_equals($wantEmailIds, $blob->{types}{Email}); + $self->assert_deep_equals($wantThreadIds, $blob->{types}{Thread}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_attach_contact_by_blobid b/cassandane/tiny-tests/JMAPEmail/email_attach_contact_by_blobid new file mode 100644 index 0000000000..75330f3efe --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_attach_contact_by_blobid @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_attach_contact_by_blobid + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + push @using, 'https://cyrusimap.org/ns/jmap/contacts'; + $jmap->DefaultUsing(\@using); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + my $contact = { + firstName => "first", + lastName => "last" + }; + + $res = $jmap->CallMethods([['Contact/set', + {create => {"1" => $contact }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + + my $blobid = $res->[0][1]{created}{"1"}{blobId}; + my $size = $res->[0][1]{created}{"1"}{size}; + + $res = $jmap->CallMethods([['Email/set', { + create => { + k1 => { + bcc => undef, + bodyStructure => { + subParts => [{ + partId => 'text', + type => 'text/plain', + },{ + blobId => $blobid, + cid => undef, + disposition => 'attachment', + height => undef, + name => 'last.vcf', + size => $size, + type => 'text/vcard', + width => undef, + }], + type => 'multipart/mixed', + }, + bodyValues => { + text => { + isTruncated => $JSON::false, + value => "Hello world", + }, + }, + mailboxIds => { $inboxid => JSON::true }, + subject => 'email with vCard', + from => [ {email => 'foo@example.com', name => 'foo' } ], + to => [ {email => 'foo@example.com', name => 'foo' } ], + }, + }, + }, "R1"]]); + + my $id = $res->[0][1]{created}{k1}{id}; + $self->assert_not_null($id); + + $res = $jmap->CallMethods([['Email/get', { + ids => [$id], + properties => ['bodyStructure'], + }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $newpart = $msg->{bodyStructure}{subParts}[1]; + $self->assert_str_equals("last.vcf", $newpart->{name}); + $self->assert_str_equals("text/vcard", $newpart->{type}); + $self->assert_num_equals($size, $newpart->{size}); + +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_bimi_blob b/cassandane/tiny-tests/JMAPEmail/email_bimi_blob new file mode 100644 index 0000000000..e7da854d26 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_bimi_blob @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_bimi_blob + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # bimiBlobId property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $binary = slurp_file(abs_path('data/FM_BIMI.svg')); + + $self->make_message("foo", + mime_type => 'text/plain', + extra_headers => [ + ['BIMI-Indicator', encode_base64($binary, '')], + ], + body => 'foo', + ) || die; + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { + ids => $ids, + properties => ['bimiBlobId'], + }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $blobid = $msg->{bimiBlobId}; + $self->assert_not_null($blobid); + + my $blob = $jmap->Download({ accept => 'image/svg+xml' }, + 'cassandane', $blobid); + $self->assert_str_equals('image/svg+xml', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_equals($binary, $blob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_bimi_blob_as_contact_avatar b/cassandane/tiny-tests/JMAPEmail/email_bimi_blob_as_contact_avatar new file mode 100644 index 0000000000..b8a6ebf058 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_bimi_blob_as_contact_avatar @@ -0,0 +1,67 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_bimi_blob_as_contact_avatar + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # bimiBlobId property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + push @using, 'https://cyrusimap.org/ns/jmap/contacts'; + $jmap->DefaultUsing(\@using); + + my $binary = slurp_file(abs_path('data/FM_BIMI.svg')); + + $self->make_message("foo", + mime_type => 'text/plain', + extra_headers => [ + ['BIMI-Indicator', encode_base64($binary, '')], + ], + body => 'foo', + ) || die; + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { + ids => $ids, + properties => ['bimiBlobId'], + }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $blobid = $msg->{bimiBlobId}; + $self->assert_not_null($blobid); + + my $blob = $jmap->Download({ accept => 'image/svg+xml' }, + 'cassandane', $blobid); + $self->assert_str_equals('image/svg+xml', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_equals($binary, $blob->{content}); + + my $contact = { + firstName => "first", + lastName => "last", + avatar => { + blobId => $blobid, + size => $blob->{headers}->{'content-length'}, + type => 'image/svg+xml', + name => JSON::null + } + }; + + $res = $jmap->CallMethods([['Contact/set', + {create => {"1" => $contact }}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Contact/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + $self->assert_not_null($res->[0][1]{created}{"1"}{avatar}{blobId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_blob_set_singlecommand b/cassandane/tiny-tests/JMAPEmail/email_blob_set_singlecommand new file mode 100644 index 0000000000..546c7ba85e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_blob_set_singlecommand @@ -0,0 +1,93 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_blob_set_singlecommand + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email =~ s/\r?\n/\r\n/gs; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/blob', + ]; + + xlog $self, "do the lot!"; + $res = $jmap->CallMethods([ + ['Blob/upload', { create => { "a" => { data => [{'data:asText' => $email }] } } }, 'R0'], + ['Email/import', { + emails => { + "1" => { + blobId => '#a', + mailboxIds => { $draftsmbox => JSON::true}, + keywords => { + '$draft' => JSON::true, + }, + }, + }, + }, "R1"] + ], $using); + + my $msg = $res->[1][1]->{created}{"1"}; + $self->assert_not_null($msg); + + my $binary = slurp_file(abs_path('data/logo.gif')); + + $res = $jmap->CallMethods([ + ['Blob/upload', { create => { "img" => { data => [{'data:asBase64' => encode_base64($binary, '')}], type => 'image/gif' } } }, 'R0'], + ['Email/set', { + create => { + "2" => { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + ], + subject => "Memo", + textBody => [{ partId => '1' }], + bodyValues => { + '1' => { + value => "I'm givin' ya one last chance ta surrenda!" + } + }, + attachments => [{ + blobId => '#img', + name => "logo.gif", + type => 'image/gif', + }], + keywords => { '$draft' => JSON::true }, + } } }, 'R1'], + ], $using); + + $msg = $res->[1][1]->{created}{"2"}; + $self->assert_not_null($msg); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_body_alternative_without_html b/cassandane/tiny-tests/JMAPEmail/email_body_alternative_without_html new file mode 100644 index 0000000000..def048d656 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_body_alternative_without_html @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_body_alternative_without_html + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my %exp_sub; + $store->set_folder("INBOX"); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body = "". + "--sub\r\n". + "Content-Type: text/plain\r\n". + "\r\n" . + "plain text". + "\r\n--sub\r\n". + "Content-Type: some/part\r\n". + "Content-Transfer-Encoding: base64\r\n". + "\r\n" . + "abc=". + "\r\n--sub--\r\n"; + + $exp_sub{A} = $self->make_message("foo", + mime_type => "multipart/alternative", + mime_boundary => "sub", + body => $body + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { + ids => $ids, + properties => ['textBody', 'htmlBody', 'bodyStructure'], + fetchAllBodyValues => JSON::true + }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + $self->assert_num_equals(1, scalar @{$msg->{textBody}}); + $self->assert_num_equals(1, scalar @{$msg->{htmlBody}}); + $self->assert_str_equals($msg->{textBody}[0]->{partId}, $msg->{htmlBody}[0]->{partId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_changes b/cassandane/tiny-tests/JMAPEmail/email_changes new file mode 100644 index 0000000000..9632769dc3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_changes @@ -0,0 +1,171 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_changes + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $draftsmbox; + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get email updates (expect error)"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => 0 }, "R1"]]); + $self->assert_str_equals("cannotCalculateChanges", $res->[0][1]->{type}); + + xlog $self, "get email state"; + $res = $jmap->CallMethods([['Email/get', { ids => []}, "R1"]]); + $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "get email updates"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("Email A") || die; + + xlog $self, "Get email id"; + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + + xlog $self, "get email updates"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals($ida, $res->[0][1]{created}[0]); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + + xlog $self, "get email updates (expect no changes)"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + xlog $self, "update email $ida"; + $res = $jmap->CallMethods([['Email/set', { + update => { $ida => { keywords => { '$seen' => JSON::true }}} + }, "R1"]]); + $self->assert(exists $res->[0][1]->{updated}{$ida}); + + xlog $self, "get email updates"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($ida, $res->[0][1]{updated}[0]); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + + xlog $self, "delete email $ida"; + $res = $jmap->CallMethods([['Email/set', {destroy => [ $ida ] }, "R1"]]); + $self->assert_str_equals($ida, $res->[0][1]->{destroyed}[0]); + + xlog $self, "get email updates"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($ida, $res->[0][1]{destroyed}[0]); + $state = $res->[0][1]->{newState}; + + xlog $self, "get email updates (expect no changes)"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + xlog $self, "create email B"; + $res = $jmap->CallMethods( + [[ 'Email/set', { create => { "1" => { + mailboxIds => {$draftsmbox => JSON::true}, + from => [ { name => "", email => "sam\@acme.local" } ], + to => [ { name => "", email => "bugs\@acme.local" } ], + subject => "Email B", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "I'm givin' ya one last chance ta surrenda!" }}, + keywords => { '$draft' => JSON::true }, + }}}, "R1" ]]); + my $idb = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($idb); + + xlog $self, "create email C"; + $res = $jmap->CallMethods( + [[ 'Email/set', { create => { "1" => { + mailboxIds => {$draftsmbox => JSON::true}, + from => [ { name => "", email => "sam\@acme.local" } ], + to => [ { name => "", email => "bugs\@acme.local" } ], + subject => "Email C", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "I *hate* that rabbit!" } }, + keywords => { '$draft' => JSON::true }, + }}}, "R1" ]]); + my $idc = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($idc); + + xlog $self, "get max 1 email updates"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state, maxChanges => 1 }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::true, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals($idb, $res->[0][1]{created}[0]); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + + xlog $self, "get max 1 email updates"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state, maxChanges => 1 }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals($idc, $res->[0][1]{created}[0]); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + + xlog $self, "get email updates (expect no changes)"; + $res = $jmap->CallMethods([['Email/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_changes_shared b/cassandane/tiny-tests/JMAPEmail/email_changes_shared new file mode 100644 index 0000000000..41832c3781 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_changes_shared @@ -0,0 +1,96 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_changes_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + + my $store = $self->{store}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "create user and share inbox"; + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lrwkxd") or die; + + xlog $self, "create non-shared mailbox box1"; + $admintalk->create("user.foo.box1") or die; + $admintalk->setacl("user.foo.box1", "cassandane", "") or die; + + xlog $self, "get email state"; + $res = $jmap->CallMethods([['Email/get', { accountId => 'foo', ids => []}, "R1"]]); + my $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "get email updates (expect empty changes)"; + $res = $jmap->CallMethods([['Email/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + # This could be the same as oldState, or not, as we might leak + # unshared modseqs (but not the according mail!). + $self->assert_not_null($res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + xlog $self, "Generate an email in shared account INBOX via IMAP"; + $self->{adminstore}->set_folder('user.foo'); + $self->make_message("Email A", store => $self->{adminstore}) || die; + + xlog $self, "get email updates"; + $res = $jmap->CallMethods([['Email/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + my $ida = $res->[0][1]{created}[0]; + + xlog $self, "create email in non-shared mailbox"; + $self->{adminstore}->set_folder('user.foo.box1'); + $self->make_message("Email B", store => $self->{adminstore}) || die; + + xlog $self, "get email updates (expect empty changes)"; + $res = $jmap->CallMethods([['Email/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + # This could be the same as oldState, or not, as we might leak + # unshared modseqs (but not the according mail!). + $self->assert_not_null($res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + xlog $self, "share private mailbox box1"; + $admintalk->setacl("user.foo.box1", "cassandane", "lr") or die; + + xlog $self, "get email updates"; + $res = $jmap->CallMethods([['Email/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + + xlog $self, "delete email $ida"; + $res = $jmap->CallMethods([['Email/set', { accountId => 'foo', destroy => [ $ida ] }, "R1"]]); + $self->assert_str_equals($ida, $res->[0][1]->{destroyed}[0]); + + xlog $self, "get email updates"; + $res = $jmap->CallMethods([['Email/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($ida, $res->[0][1]{destroyed}[0]); + $state = $res->[0][1]->{newState}; +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_copy b/cassandane/tiny-tests/JMAPEmail/email_copy new file mode 100644 index 0000000000..020292f67f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_copy @@ -0,0 +1,182 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_copy + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create user and share mailbox read-only"; + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lrs") or die; + + my $srcInboxId = $self->getinbox()->{id}; + $self->assert_not_null($srcInboxId); + + my $dstInboxId = $self->getinbox({accountId => 'other'})->{id}; + $self->assert_not_null($dstInboxId); + + xlog $self, "create email"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 1 => { + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'foo' => JSON::true, + }, + subject => 'hello', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1'], + ]); + my $emailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($emailId); + + my $email = $res = $jmap->CallMethods([ + ['Email/get', { + ids => [$emailId], + properties => ['receivedAt'], + }, 'R1'] + ]); + my $receivedAt = $res->[0][1]{list}[0]{receivedAt}; + $self->assert_not_null($receivedAt); + + # Safeguard receivedAt asserts. + sleep 1; + + xlog $self, "attempt to move email - fail to copy and no /set sub-request"; + $res = $jmap->CallMethods([ + ['Email/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $emailId, + mailboxIds => { + $dstInboxId => JSON::true, + }, + keywords => { + 'bar' => JSON::true, + }, + }, + }, + onSuccessDestroyOriginal => JSON::true, + }, 'R1'], + ]); + + $self->assert_num_equals(1, scalar @{$res}); + $self->assert_not_null($res->[0][1]->{notCreated}{1}); + + xlog $self, "share mailbox read-write"; + $admintalk->setacl("user.other", "cassandane", "lrsiwntex") or die; + + xlog $self, "move email"; + $res = $jmap->CallMethods([ + ['Email/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $emailId, + mailboxIds => { + $dstInboxId => JSON::true, + }, + keywords => { + 'bar' => JSON::true, + }, + }, + }, + onSuccessDestroyOriginal => JSON::true, + }, 'R1'], + ]); + + my $copiedEmailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($copiedEmailId); + $self->assert_str_equals('Email/set', $res->[1][0]); + $self->assert_str_equals($emailId, $res->[1][1]{destroyed}[0]); + + xlog $self, "get copied email"; + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'other', + ids => [$copiedEmailId], + properties => ['keywords', 'receivedAt'], + }, 'R1'] + ]); + my $wantKeywords = { 'bar' => JSON::true }; + $self->assert_deep_equals($wantKeywords, $res->[0][1]{list}[0]{keywords}); + $self->assert_str_equals($receivedAt, $res->[0][1]{list}[0]{receivedAt}); + + xlog $self, "copy email back"; + $receivedAt = '2020-02-01T00:00:00Z'; + $res = $jmap->CallMethods([ + ['Email/copy', { + accountId => 'cassandane', + fromAccountId => 'other', + create => { + 1 => { + id => $copiedEmailId, + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'bar' => JSON::true, + }, + receivedAt => $receivedAt + }, + }, + }, 'R1'], + ]); + + $self->assert_str_equals($copiedEmailId, $res->[0][1]->{created}{1}{id}); + + xlog $self, "get copied email"; + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'cassandane', + ids => [$copiedEmailId], + properties => ['keywords', 'receivedAt'], + }, 'R1'] + ]); + + my $wantKeywords = { 'bar' => JSON::true }; + $self->assert_deep_equals($wantKeywords, $res->[0][1]{list}[0]{keywords}); + $self->assert_str_equals($receivedAt, $res->[0][1]{list}[0]{receivedAt}); + + xlog $self, "copy email back (again)"; + $res = $jmap->CallMethods([ + ['Email/copy', { + accountId => 'cassandane', + fromAccountId => 'other', + create => { + 1 => { + id => $copiedEmailId, + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'bar' => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + + $self->assert_str_equals('alreadyExists', $res->[0][1]->{notCreated}{1}{type}); + $self->assert_not_null($res->[0][1]->{notCreated}{1}{existingId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_copy_has_expunged b/cassandane/tiny-tests/JMAPEmail/email_copy_has_expunged new file mode 100644 index 0000000000..2f8ccfdd8d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_copy_has_expunged @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_copy_has_expunged + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create user and share mailbox"; + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lrsiwntex") or die; + + my $srcInboxId = $self->getinbox()->{id}; + $self->assert_not_null($srcInboxId); + + my $dstInboxId = $self->getinbox({accountId => 'other'})->{id}; + $self->assert_not_null($dstInboxId); + + xlog $self, "create email"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 1 => { + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'foo' => JSON::true, + }, + subject => 'hello', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1'], + ]); + + my $emailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($emailId); + + # move to Trash and back + $imaptalk->create("INBOX.Trash"); + $imaptalk->select("INBOX"); + $imaptalk->move('1:*', "INBOX.Trash"); + $imaptalk->select("INBOX.Trash"); + $imaptalk->move('1:*', "INBOX"); + + # move into Temp + $imaptalk->create("INBOX.Temp"); + $imaptalk->select("INBOX"); + $imaptalk->move('1:*', "INBOX.Temp"); + + # Copy to other account, with mailbox identified by role + $res = $jmap->CallMethods([ + ['Email/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $emailId, + mailboxIds => { + '$inbox' => JSON::true, + }, + }, + }, + }, 'R1'], + ['Email/get', { + accountId => 'other', + ids => ['#1'], + properties => ['mailboxIds'], + }, 'R2'] + ]); + $self->assert_not_null($res->[1][1]{list}[0]{mailboxIds}{$dstInboxId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_copy_hasattachment b/cassandane/tiny-tests/JMAPEmail/email_copy_hasattachment new file mode 100644 index 0000000000..cd1af4edb4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_copy_hasattachment @@ -0,0 +1,129 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_copy_hasattachment + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPNoHasAttachment +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create user and share mailbox"; + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lrsiwntex") or die; + + my $srcInboxId = $self->getinbox()->{id}; + $self->assert_not_null($srcInboxId); + + my $dstInboxId = $self->getinbox({accountId => 'other'})->{id}; + $self->assert_not_null($dstInboxId); + + xlog $self, "create emails"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 1 => { + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'foo' => JSON::true, + '$seen' => JSON::true, + }, + subject => 'email1', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'part1', + } + }, + }, + 2 => { + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'foo' => JSON::true, + '$seen' => JSON::true, + }, + subject => 'email2', + bodyStructure => { + type => 'text/plain', + partId => 'part2', + }, + bodyValues => { + part2 => { + value => 'part2', + } + }, + }, + }, + }, 'R1'], + ]); + my $emailId1 = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($emailId1); + my $emailId2 = $res->[0][1]->{created}{2}{id}; + $self->assert_not_null($emailId2); + + xlog $self, "set hasAttachment"; + my $store = $self->{store}; + $store->set_folder('INBOX'); + $store->_select(); + my $talk = $store->get_client(); + $talk->store('1:2', '+flags', '($HasAttachment)') or die; + + xlog $self, "copy email"; + $res = $jmap->CallMethods([ + ['Email/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $emailId1, + mailboxIds => { + $dstInboxId => JSON::true, + }, + }, + 2 => { + id => $emailId2, + mailboxIds => { + $dstInboxId => JSON::true, + }, + keywords => { + 'baz' => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + + my $copiedEmailId1 = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($copiedEmailId1); + my $copiedEmailId2 = $res->[0][1]->{created}{2}{id}; + $self->assert_not_null($copiedEmailId2); + + xlog $self, "get copied email"; + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'other', + ids => [$copiedEmailId1, $copiedEmailId2], + properties => ['keywords'], + }, 'R1'] + ]); + my $wantKeywords1 = { + '$hasattachment' => JSON::true, + foo => JSON::true, + '$seen' => JSON::true, + }; + my $wantKeywords2 = { + '$hasattachment' => JSON::true, + baz => JSON::true, + }; + $self->assert_deep_equals($wantKeywords1, $res->[0][1]{list}[0]{keywords}); + $self->assert_deep_equals($wantKeywords2, $res->[0][1]{list}[1]{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_copy_intermediary b/cassandane/tiny-tests/JMAPEmail/email_copy_intermediary new file mode 100644 index 0000000000..30cfe12c43 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_copy_intermediary @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_copy_intermediary + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create user and share mailbox"; + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lrsiwntex") or die; + $admintalk->create("user.other.i1.box") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + accountId => 'other', + properties => ['name'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $dstMboxId = $mboxByName{'i1'}->{id}; + $self->assert_not_null($dstMboxId); + + my $srcInboxId = $self->getinbox()->{id}; + $self->assert_not_null($srcInboxId); + + xlog $self, "create email"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 1 => { + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'foo' => JSON::true, + }, + subject => 'hello', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1'], + ]); + my $emailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($emailId); + + xlog $self, "move email"; + $res = $jmap->CallMethods([ + ['Email/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $emailId, + mailboxIds => { + $dstMboxId => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + + my $copiedEmailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($copiedEmailId); + + xlog $self, "get copied email"; + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'other', + ids => [$copiedEmailId], + properties => ['mailboxIds'], + }, 'R1'] + ]); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{mailboxIds}{$dstMboxId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_copy_mailboxid_by_role b/cassandane/tiny-tests/JMAPEmail/email_copy_mailboxid_by_role new file mode 100644 index 0000000000..27d6e44026 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_copy_mailboxid_by_role @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_copy_mailboxid_by_role + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create user and share mailbox"; + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lrsiwntex") or die; + + my $srcInboxId = $self->getinbox()->{id}; + $self->assert_not_null($srcInboxId); + + my $dstInboxId = $self->getinbox({accountId => 'other'})->{id}; + $self->assert_not_null($dstInboxId); + + xlog $self, "create email"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 1 => { + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'foo' => JSON::true, + }, + subject => 'hello', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1'], + ]); + my $emailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($emailId); + + # Copy to other account, with mailbox identified by role + $res = $jmap->CallMethods([ + ['Email/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $emailId, + mailboxIds => { + '$inbox' => JSON::true, + }, + }, + }, + }, 'R1'], + ['Email/get', { + accountId => 'other', + ids => ['#1'], + properties => ['mailboxIds'], + }, 'R2'] + ]); + $self->assert_not_null($res->[1][1]{list}[0]{mailboxIds}{$dstInboxId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_copy_snoozed b/cassandane/tiny-tests/JMAPEmail/email_copy_snoozed new file mode 100644 index 0000000000..7c56721b47 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_copy_snoozed @@ -0,0 +1,107 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_copy_snoozed + :min_version_3_9 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # snoozed property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $inboxId = $self->getinbox()->{id}; + $self->assert_not_null($inboxId); + + xlog $self, "create snooze mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "snoozed", + parentId => undef, + role => "snoozed" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $snoozedId = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Create user and share mailbox"; + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lrsiwntex") or die; + + my $dstInboxId = $self->getinbox({accountId => 'other'})->{id}; + $self->assert_not_null($dstInboxId); + + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => 30)); + my $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + xlog $self, "create snoozed email"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 1 => { + mailboxIds => { + $snoozedId => JSON::true, + }, + keywords => { + 'foo' => JSON::true, + }, + subject => 'hello', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1'], + ]); + my $emailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($emailId); + + xlog $self, "copy email to shared mailbox - removing snoozed"; + $res = $jmap->CallMethods([ + ['Email/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + 1 => { + id => $emailId, + mailboxIds => { + $dstInboxId => JSON::true, + }, + keywords => { + 'bar' => JSON::true, + }, + snoozed => JSON::null + }, + }, + }, 'R1'], + ]); + + my $copiedEmailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($copiedEmailId); + + xlog $self, "get copied email"; + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'other', + ids => [$copiedEmailId], + properties => ['keywords', 'snoozed'], + }, 'R1'] + ]); + my $wantKeywords = { 'bar' => JSON::true }; + $self->assert_deep_equals($wantKeywords, $res->[0][1]{list}[0]{keywords}); + $self->assert_null($res->[0][1]{list}[0]{snoozed}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_copy_state b/cassandane/tiny-tests/JMAPEmail/email_copy_state new file mode 100644 index 0000000000..225db16529 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_copy_state @@ -0,0 +1,99 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_copy_state + :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create user and share mailbox"; + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lrsiwntex") or die; + + my $srcInboxId = $self->getinbox()->{id}; + $self->assert_not_null($srcInboxId); + + my $dstInboxId = $self->getinbox({accountId => 'other'})->{id}; + $self->assert_not_null($dstInboxId); + + xlog $self, "create email"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 1 => { + mailboxIds => { + $srcInboxId => JSON::true, + }, + keywords => { + 'foo' => JSON::true, + }, + subject => 'hello', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1'], + ['Email/get', { + accountId => 'other', + ids => ['foo'], # Just fetching current state for 'other' + }, 'R2'] + ]); + my $emailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($emailId); + my $fromState = $res->[0][1]->{newState}; + $self->assert_not_null($fromState); + my $state = $res->[1][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "move email"; + $res = $jmap->CallMethods([ + ['Email/copy', { + fromAccountId => 'cassandane', + accountId => 'other', + ifFromInState => $fromState, + ifInState => $state, + create => { + 1 => { + id => $emailId, + mailboxIds => { + $dstInboxId => JSON::true, + }, + }, + }, + onSuccessDestroyOriginal => JSON::true, + destroyFromIfInState => $fromState, + }, 'R1'], + ['Email/get', { + accountId => 'other', + ids => ['#1'], + properties => ['mailboxIds'], + }, 'R2'] + ]); + $self->assert_not_null($res->[0][1]{created}); + my $oldState = $res->[0][1]->{oldState}; + $self->assert_str_equals($oldState, $state); + my $newState = $res->[0][1]->{newState}; + $self->assert_not_null($newState); + $self->assert_str_equals('Email/set', $res->[1][0]); + $self->assert_str_equals($emailId, $res->[1][1]{destroyed}[0]); + $self->assert_not_null($res->[2][1]{list}[0]{mailboxIds}{$dstInboxId}); + + # Is the blobId downloadable? + my $blob = $jmap->Download({ accept => 'text/plain' }, + 'other', + $res->[0][1]{created}{"1"}{blobId}); + $self->assert_str_equals('text/plain', + $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); + $self->assert_matches(qr/\r\nSubject: hello\r\n/, $blob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_download b/cassandane/tiny-tests/JMAPEmail/email_download new file mode 100644 index 0000000000..58a89850d4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_download @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_download + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + my $body = "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $body .= "\r\n"; + $body .= "some text"; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/html;charset=\"UTF-8\"\r\n"; + $body .= "\r\n"; + $body .= "

some HTML text

"; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348--\r\n"; + $self->make_message("foo", + mime_type => "multipart/alternative", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body + ); + + xlog $self, "get email"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'blobId' ], + }, 'R2'], + ]); + my $msg = $res->[1][1]->{list}[0]; + + my $blob = $jmap->Download({ accept => 'message/rfc822' }, 'cassandane', $msg->{blobId}); + $self->assert_str_equals('message/rfc822', $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_draft_reply_new_subject_new_thrid b/cassandane/tiny-tests/JMAPEmail/email_draft_reply_new_subject_new_thrid new file mode 100644 index 0000000000..20301b3c4f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_draft_reply_new_subject_new_thrid @@ -0,0 +1,100 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_draft_reply_new_subject_new_thrid + :min_version_3_3 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $draft = { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + sender => [{ name => "Marvin the Martian", email => "marvin\@acme.local" }], + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + { name => "Rainer M\N{LATIN SMALL LETTER U WITH DIAERESIS}ller", email => "rainer\@de.local" }, + ], + cc => [ + { name => "Elmer Fudd", email => "elmer\@acme.local" }, + { name => "Porky Pig", email => "porky\@acme.local" }, + ], + bcc => [ + { name => "Wile E. Coyote", email => "coyote\@acme.local" }, + ], + replyTo => [ { name => undef, email => "the.other.sam\@acme.local" } ], + subject => "Memo", + textBody => [{ partId => '1' }], + htmlBody => [{ partId => '2' }], + bodyValues => { + '1' => { value => "I'm givin' ya one last chance ta surrenda!" }, + '2' => { value => "Oh!!! I hate that Rabbit." }, + }, + keywords => { '$draft' => JSON::true }, + }; + + xlog $self, "Create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + + $self->assert_deep_equals($msg->{mailboxIds}, $draft->{mailboxIds}); + $self->assert_deep_equals($msg->{from}, $draft->{from}); + $self->assert_deep_equals($msg->{sender}, $draft->{sender}); + $self->assert_deep_equals($msg->{to}, $draft->{to}); + $self->assert_deep_equals($msg->{cc}, $draft->{cc}); + $self->assert_deep_equals($msg->{bcc}, $draft->{bcc}); + $self->assert_deep_equals($msg->{replyTo}, $draft->{replyTo}); + $self->assert_str_equals($msg->{subject}, $draft->{subject}); + $self->assert_equals(JSON::true, $msg->{keywords}->{'$draft'}); + $self->assert_num_equals(1, scalar keys %{$msg->{keywords}}); + + xlog $self, "create sent mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "sent", + parentId => undef, + role => "sent" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $sentmbox = $res->[0][1]{created}{"1"}{id}; + + # Now change the draft keyword, which is allowed since approx ~Q1/2018. + xlog $self, "Update the email into the sent mailbox and remove draft"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $id => { + 'keywords' => { '$seen' => JSON::true }, + 'mailboxIds' => { $sentmbox => JSON::true }, + } }, + }, "R1"] + ]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "Create a new draft which is in reply"; + $draft->{inReplyTo} = $msg->{messageId}; + $draft->{subject} = "Rubbish different subject"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id2 = $res->[0][1]{created}{"1"}{id}; + my $thread2 = $res->[0][1]{created}{"1"}{threadId}; + $self->assert_str_not_equals($msg->{threadId}, $thread2); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_draft_subject_keeps_thrid b/cassandane/tiny-tests/JMAPEmail/email_draft_subject_keeps_thrid new file mode 100644 index 0000000000..317be8cb0e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_draft_subject_keeps_thrid @@ -0,0 +1,97 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_draft_subject_keeps_thrid + :min_version_3_3 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $messageId = "71cdcf3a-6dc5-4d95-b600-14e7f99719f0\@example.com"; + + my $draft = { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + sender => [{ name => "Marvin the Martian", email => "marvin\@acme.local" }], + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + { name => "Rainer M\N{LATIN SMALL LETTER U WITH DIAERESIS}ller", email => "rainer\@de.local" }, + ], + cc => [ + { name => "Elmer Fudd", email => "elmer\@acme.local" }, + { name => "Porky Pig", email => "porky\@acme.local" }, + ], + bcc => [ + { name => "Wile E. Coyote", email => "coyote\@acme.local" }, + ], + replyTo => [ { name => undef, email => "the.other.sam\@acme.local" } ], + subject => "Memo", + textBody => [{ partId => '1' }], + htmlBody => [{ partId => '2' }], + messageId => [$messageId], + bodyValues => { + '1' => { value => "I'm givin' ya one last chance ta surrenda!" }, + '2' => { value => "Oh!!! I hate that Rabbit." }, + }, + keywords => { '$draft' => JSON::true }, + }; + + xlog $self, "create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $threadId = $res->[0][1]{created}{"1"}{threadId}; + + xlog $self, "Get draft $id1"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id1] }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + + $self->assert_deep_equals($msg->{mailboxIds}, $draft->{mailboxIds}); + $self->assert_deep_equals($msg->{from}, $draft->{from}); + $self->assert_deep_equals($msg->{sender}, $draft->{sender}); + $self->assert_deep_equals($msg->{to}, $draft->{to}); + $self->assert_deep_equals($msg->{cc}, $draft->{cc}); + $self->assert_deep_equals($msg->{bcc}, $draft->{bcc}); + $self->assert_deep_equals($msg->{replyTo}, $draft->{replyTo}); + $self->assert_str_equals($msg->{subject}, $draft->{subject}); + $self->assert_str_equals($msg->{threadId}, $threadId); + $self->assert_equals(JSON::true, $msg->{keywords}->{'$draft'}); + $self->assert_num_equals(1, scalar keys %{$msg->{keywords}}); + + # change subject and prep for replace + $draft->{subject} = "Wabbit Season!"; + + xlog $self, "replace the draft with a new copy with a new subject"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }, destroy => [ $id1 ] }, "R1"]]); + my $id2 = $res->[0][1]{created}{"1"}{id}; + $self->assert_str_not_equals($id1, $id2); + $self->assert_str_equals($id1, $res->[0][1]{destroyed}[0]); + + xlog $self, "Get draft $id2"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id2] }, "R1"]]); + $msg = $res->[0][1]->{list}[0]; + + $self->assert_deep_equals($msg->{mailboxIds}, $draft->{mailboxIds}); + $self->assert_deep_equals($msg->{from}, $draft->{from}); + $self->assert_deep_equals($msg->{sender}, $draft->{sender}); + $self->assert_deep_equals($msg->{to}, $draft->{to}); + $self->assert_deep_equals($msg->{cc}, $draft->{cc}); + $self->assert_deep_equals($msg->{bcc}, $draft->{bcc}); + $self->assert_deep_equals($msg->{replyTo}, $draft->{replyTo}); + $self->assert_str_equals($msg->{subject}, $draft->{subject}); + $self->assert_str_equals($msg->{threadId}, $threadId); + $self->assert_equals(JSON::true, $msg->{keywords}->{'$draft'}); + $self->assert_num_equals(1, scalar keys %{$msg->{keywords}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_embedded_download b/cassandane/tiny-tests/JMAPEmail/email_embedded_download new file mode 100644 index 0000000000..1179588c65 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_embedded_download @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_embedded_download + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + # Generate an embedded email + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Disposition: inline\r\n" . "\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "An embedded email" + . "\r\n--sub--\r\n", + ) || die; + + xlog $self, "get blobId"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['attachments'], + }, 'R2' ], + ]); + my $blobId = $res->[1][1]->{list}[0]->{attachments}[0]{blobId}; + + my $blob = $jmap->Download({ accept => 'message/rfc822' }, 'cassandane', $blobId); + $self->assert_str_equals('message/rfc822', $blob->{headers}->{'content-type'}); + $self->assert_num_not_equals(0, $blob->{headers}->{'content-length'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_flagged_shared_twofolder_hidden b/cassandane/tiny-tests/JMAPEmail/email_flagged_shared_twofolder_hidden new file mode 100644 index 0000000000..d33ee29ca1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_flagged_shared_twofolder_hidden @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_flagged_shared_twofolder_hidden + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Share account + $self->{instance}->create_user("other"); + + # Create mailbox A + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lrsiwn") or die; + # NOTE: user cassandane does NOT get permission to see this one + $admintalk->create("user.other.A.sub") or die; + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + $self->make_message("Email", store => $self->{adminstore}) or die; + + # Set \Flagged on message A as user cassandane + $self->{store}->set_folder('user.other.A'); + $admintalk->select('user.other.A'); + $admintalk->copy('1', 'user.other.A.sub'); + $talk->select('user.other.A'); + $talk->store('1', '+flags', '(\\Flagged)'); + + # Get email and assert $seen + my $res = $jmap->CallMethods([ + ['Email/query', { + accountId => 'other', + }, 'R1'], + ['Email/get', { + accountId => 'other', + properties => ['keywords'], + '#ids' => { + resultOf => 'R1', name => 'Email/query', path => '/ids' + } + }, 'R2' ] + ]); + my $emailId = $res->[1][1]{list}[0]{id}; + my $wantKeywords = { '$flagged' => JSON::true }; + $self->assert_deep_equals($wantKeywords, $res->[1][1]{list}[0]{keywords}); + + # Set $seen via JMAP on the shared mailbox + $res = $jmap->CallMethods([ + ['Email/set', { + accountId => 'other', + update => { + $emailId => { + keywords => { }, + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + + # Assert $seen got updated + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'other', + properties => ['keywords'], + ids => [$emailId], + }, 'R1' ] + ]); + $wantKeywords = { }; + $self->assert_deep_equals($wantKeywords, $res->[0][1]{list}[0]{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get b/cassandane/tiny-tests/JMAPEmail/email_get new file mode 100644 index 0000000000..70af839c06 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get @@ -0,0 +1,104 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + my $body = ""; + $body .= "Lorem ipsum dolor sit amet, consectetur adipiscing\r\n"; + $body .= "elit. Nunc in fermentum nibh. Vivamus enim metus."; + + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => -10)); + + xlog $self, "Generate an email in INBOX via IMAP"; + my %exp_inbox; + my %params = ( + date => $maildate, + from => Cassandane::Address->new( + name => "Sally Sender", + localpart => "sally", + domain => "local" + ), + to => Cassandane::Address->new( + name => "Tom To", + localpart => 'tom', + domain => 'local' + ), + cc => Cassandane::Address->new( + name => "Cindy CeeCee", + localpart => 'cindy', + domain => 'local' + ), + bcc => Cassandane::Address->new( + name => "Benny CarbonCopy", + localpart => 'benny', + domain => 'local' + ), + messageid => 'fake.123456789@local', + extra_headers => [ + ['x-tra', "foo bar\r\n baz"], + ['sender', "Bla "], + ], + body => $body + ); + $self->make_message("Email A", %params) || die; + + xlog $self, "get email list"; + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + + my @props = $self->defaultprops_for_email_get(); + + push @props, "header:x-tra"; + + xlog $self, "get emails"; + my $ids = $res->[0][1]->{ids}; + $res = $jmap->CallMethods([['Email/get', { ids => $ids, properties => \@props }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + + $self->assert_not_null($msg->{mailboxIds}{$inboxid}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_num_equals(0, scalar keys %{$msg->{keywords}}); + + $self->assert_str_equals('fake.123456789@local', $msg->{messageId}[0]); + $self->assert_str_equals(" foo bar\r\n baz", $msg->{'header:x-tra'}); + $self->assert_deep_equals({ + name => "Sally Sender", + email => "sally\@local" + }, $msg->{from}[0]); + $self->assert_deep_equals({ + name => "Tom To", + email => "tom\@local" + }, $msg->{to}[0]); + $self->assert_num_equals(1, scalar @{$msg->{to}}); + $self->assert_deep_equals({ + name => "Cindy CeeCee", + email => "cindy\@local" + }, $msg->{cc}[0]); + $self->assert_num_equals(1, scalar @{$msg->{cc}}); + $self->assert_deep_equals({ + name => "Benny CarbonCopy", + email => "benny\@local" + }, $msg->{bcc}[0]); + $self->assert_num_equals(1, scalar @{$msg->{bcc}}); + $self->assert_null($msg->{replyTo}); + $self->assert_deep_equals([{ + name => "Bla", + email => "blu\@local" + }], $msg->{sender}); + $self->assert_str_equals("Email A", $msg->{subject}); + + my $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + $self->assert_str_equals($datestr, $msg->{receivedAt}); + $self->assert_not_null($msg->{size}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_8bit_headers b/cassandane/tiny-tests/JMAPEmail/email_get_8bit_headers new file mode 100644 index 0000000000..670a62910c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_8bit_headers @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_8bit_headers + :min_version_3_1 :needs_component_jmap :needs_dependency_chardet + :needs_component_sieve :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + # Москва - столица России. - "Moscow is the capital of Russia." + my $wantSubject = + "\xd0\x9c\xd0\xbe\xd1\x81\xd0\xba\xd0\xb2\xd0\xb0\x20\x2d\x20\xd1". + "\x81\xd1\x82\xd0\xbe\xd0\xbb\xd0\xb8\xd1\x86\xd0\xb0\x20\xd0\xa0". + "\xd0\xbe\xd1\x81\xd1\x81\xd0\xb8\xd0\xb8\x2e"; + utf8::decode($wantSubject) || die $@; + + # Фёдор Михайлович Достоевский - "Fyódor Mikháylovich Dostoyévskiy" + my $wantName = + "\xd0\xa4\xd1\x91\xd0\xb4\xd0\xbe\xd1\x80\x20\xd0\x9c\xd0\xb8\xd1". + "\x85\xd0\xb0\xd0\xb9\xd0\xbb\xd0\xbe\xd0\xb2\xd0\xb8\xd1\x87\x20". + "\xd0\x94\xd0\xbe\xd1\x81\xd1\x82\xd0\xbe\xd0\xb5\xd0\xb2\xd1\x81". + "\xd0\xba\xd0\xb8\xd0\xb9"; + utf8::decode($wantName) || die $@; + + my $wantEmail = 'fyodor@local'; + + my @testCases = ({ + file => 'data/mime/headers-utf8.bin', + }, { + file => 'data/mime/headers-koi8r.bin', + }); + + foreach (@testCases) { + open(my $F, $_->{file}) || die $!; + $imap->append('INBOX', $F) || die $@; + close($F); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['subject', 'from'], + }, 'R2' ], + ['Email/set', { + '#destroy' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + }, 'R3' ], + ]); + my $email = $res->[1][1]{list}[0]; + $self->assert_str_equals($wantSubject, $email->{subject}); + $self->assert_str_equals($wantName, $email->{from}[0]{name}); + $self->assert_str_equals($wantEmail, $email->{from}[0]{email}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_attachedemails b/cassandane/tiny-tests/JMAPEmail/email_get_attachedemails new file mode 100644 index 0000000000..62ba756712 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_attachedemails @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_attachedemails + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body = "". + "--sub\r\n". + "Content-Type: text/plain; charset=UTF-8\r\n". + "Content-Disposition: inline\r\n". + "\r\n". + "Short text". # Exactly 10 byte long body + "\r\n--sub\r\n". + "Content-Type: message/rfc822\r\n". + "\r\n" . + "Return-Path: \r\n". + "Mime-Version: 1.0\r\n". + "Content-Type: text/plain\r\n". + "Content-Transfer-Encoding: 7bit\r\n". + "Subject: bar\r\n". + "From: Ava T. Nguyen \r\n". + "Message-ID: \r\n". + "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n". + "To: Test User \r\n". + "\r\n". + "Jeez....an embedded email". + "\r\n--sub--\r\n"; + + $exp_sub{A} = $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => $body + ); + $talk->store('1', '+flags', '($HasAttachment)'); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + $self->assert_num_equals(1, scalar @{$msg->{attachments}}); + $self->assert_str_equals("message/rfc822", $msg->{attachments}[0]{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_attachment_name b/cassandane/tiny-tests/JMAPEmail/email_get_attachment_name new file mode 100644 index 0000000000..392dc23579 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_attachment_name @@ -0,0 +1,185 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_attachment_name + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body = "". + "--sub\r\n". + "Content-Type: image/jpeg\r\n". + "Content-Disposition: attachment; filename\r\n\t=\"image1.jpg\"\r\n". + "Content-Transfer-Encoding: base64\r\n". + "\r\n" . + "beefc0de". + "\r\n--sub\r\n". + "Content-Type: image/tiff\r\n". + "Content-Transfer-Encoding: base64\r\n". + "\r\n" . + "abc=". + "\r\n--sub\r\n". + "Content-Type: application/x-excel\r\n". + "Content-Transfer-Encoding: base64\r\n". + "Content-Disposition: attachment; filename\r\n\t=\"f.xls\"\r\n". + "\r\n" . + "012312312313". + "\r\n--sub\r\n". + "Content-Type: application/test1;name=y.dat\r\n". + "Content-Disposition: attachment; filename=z.dat\r\n". + "\r\n" . + "test1". + "\r\n--sub\r\n". + "Content-Type: application/test2;name*0=looo;name*1=ooong;name*2=.name\r\n". + "\r\n" . + "test2". + "\r\n--sub\r\n". + "Content-Type: application/test3\r\n". + "Content-Disposition: attachment; filename*0=cont;\r\n filename*1=inue\r\n". + "\r\n" . + "test3". + "\r\n--sub\r\n". + "Content-Type: application/test4; name=\"=?utf-8?Q?=F0=9F=98=80=2Etxt?=\"\r\n". + "\r\n" . + "test4". + "\r\n--sub\r\n". + "Content-Type: application/test5\r\n". + "Content-Disposition: attachment; filename*0*=utf-8''%F0%9F%98%80;\r\n filename*1=\".txt\"\r\n". + "\r\n" . + "test5". + "\r\n--sub\r\n". + "Content-Type: application/test6\r\n" . + "Content-Disposition: attachment;\r\n". + " filename*0*=\"Unencoded ' char\";\r\n" . + " filename*1*=\".txt\"\r\n" . + "\r\n" . + "test6". + + # RFC 2045, section 5.1. requires quoted-string for parameter + # values with tspecial or whitespace, but some clients ignore + # this. The following tests check Cyrus leniently accept this. + + "\r\n--sub\r\n". + "Content-Type: application/test7; name==?iso-8859-1?b?Q2Fm6S5kb2M=?=\r\n". + "Content-Disposition: attachment; filename==?iso-8859-1?b?Q2Fm6S5kb2M=?=\r\n". + "\r\n" . + "test7". + "\r\n--sub\r\n". + "Content-Type: application/test8; name= foo \r\n". + "\r\n" . + "test8". + "\r\n--sub\r\n". + "Content-Type: application/test9; name=foo bar\r\n". + "\r\n" . + "test9". + "\r\n--sub\r\n". + "Content-Type: application/test10; name=foo bar\r\n\t baz \r\n". + "\r\n" . + "test10". + "\r\n--sub\r\n". + "Content-Type: application/test11; name=\r\n\t baz \r\n". + "\r\n" . + "test11". + "\r\n--sub\r\n". + "Content-Type: application/test12; name= \r\n\t \r\n". + "\r\n" . + "test12". + + "\r\n--sub\r\n". + "Content-Type: application/test13\r\n". + "Content-Disposition: attachment; filename=\"q\\\".dat\"\r\n". + "\r\n" . + "test13". + + # Some clients send raw UTF-8 characters in MIME parameters. + # The following test checks Cyrus leniently accept this. + "\r\n--sub\r\n". + "Content-Type: application/test14; name=😀.txt\r\n". + "\r\n" . + "test14". + + "\r\n--sub--\r\n"; + + $exp_sub{A} = $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => $body + ); + $talk->store('1', '+flags', '($HasAttachment)'); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + $self->assert_equals(JSON::true, $msg->{hasAttachment}); + + # Assert embedded email support + my %m = map { $_->{type} => $_ } @{$msg->{attachments}}; + my $att; + + $att = $m{"image/tiff"}; + $self->assert_null($att->{name}); + + $att = $m{"application/x-excel"}; + $self->assert_str_equals("f.xls", $att->{name}); + + $att = $m{"image/jpeg"}; + $self->assert_str_equals("image1.jpg", $att->{name}); + + $att = $m{"application/test1"}; + $self->assert_str_equals("z.dat", $att->{name}); + + $att = $m{"application/test2"}; + $self->assert_str_equals("loooooong.name", $att->{name}); + + $att = $m{"application/test3"}; + $self->assert_str_equals("continue", $att->{name}); + + $att = $m{"application/test4"}; + $self->assert_str_equals("\N{GRINNING FACE}.txt", $att->{name}); + + $att = $m{"application/test5"}; + $self->assert_str_equals("\N{GRINNING FACE}.txt", $att->{name}); + + $att = $m{"application/test6"}; + $self->assert_str_equals("Unencoded ' char.txt", $att->{name}); + + $att = $m{"application/test7"}; + $self->assert_str_equals("Caf\N{LATIN SMALL LETTER E WITH ACUTE}.doc", $att->{name}); + + $att = $m{"application/test8"}; + $self->assert_str_equals("foo", $att->{name}); + + $att = $m{"application/test9"}; + $self->assert_str_equals("foo bar", $att->{name}); + + $att = $m{"application/test10"}; + $self->assert_str_equals("foo bar\t baz", $att->{name}); + + $att = $m{"application/test11"}; + $self->assert_str_equals("baz", $att->{name}); + + $att = $m{"application/test12"}; + $self->assert_null($att->{name}); + + $att = $m{"application/test13"}; + $self->assert_str_equals('q".dat', $att->{name}); + + $att = $m{"application/test14"}; + $self->assert_str_equals("\N{GRINNING FACE}.txt", $att->{name}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_body_both b/cassandane/tiny-tests/JMAPEmail/email_get_body_both new file mode 100644 index 0000000000..f0021b391d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_body_both @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_body_both + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $htmlBody = "

This is the html part.

"; + my $textBody = "This is the plain text part."; + + my $body = "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $body .= "\r\n"; + $body .= $textBody; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/html;charset=\"UTF-8\"\r\n"; + $body .= "\r\n"; + $body .= $htmlBody; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348--\r\n"; + $exp_sub{A} = $self->make_message("foo", + mime_type => "multipart/alternative", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids, fetchAllBodyValues => JSON::true }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $partId = $msg->{textBody}[0]{partId}; + $self->assert_str_equals($textBody, $msg->{bodyValues}{$partId}{value}); + $partId = $msg->{htmlBody}[0]{partId}; + $self->assert_str_equals($htmlBody, $msg->{bodyValues}{$partId}{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_body_html b/cassandane/tiny-tests/JMAPEmail/email_get_body_html new file mode 100644 index 0000000000..5d7001f2ff --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_body_html @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_body_html + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body = "

A HTML email.

"; + $exp_sub{A} = $self->make_message("foo", + mime_type => "text/html", + body => $body + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids, fetchAllBodyValues => JSON::true }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $partId = $msg->{htmlBody}[0]{partId}; + $self->assert_str_equals($body, $msg->{bodyValues}{$partId}{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_body_notext b/cassandane/tiny-tests/JMAPEmail/email_get_body_notext new file mode 100644 index 0000000000..1131400805 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_body_notext @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_body_notext + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + # Generate an email to have some blob ids + xlog $self, "Generate an email in $inbox via IMAP"; + $self->make_message("foo", + mime_type => "application/zip", + body => "boguszip", + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2'], + ]); + my $msg = $res->[1][1]->{list}[0]; + + $self->assert_deep_equals([], $msg->{textBody}); + $self->assert_deep_equals([], $msg->{htmlBody}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_body_plain b/cassandane/tiny-tests/JMAPEmail/email_get_body_plain new file mode 100644 index 0000000000..f1cc048e81 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_body_plain @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_body_plain + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body = "A plain text email."; + $exp_sub{A} = $self->make_message("foo", + body => $body + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get emails"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids, fetchAllBodyValues => JSON::true, }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $partId = $msg->{textBody}[0]{partId}; + $self->assert_str_equals($body, $msg->{bodyValues}{$partId}{value}); + $self->assert_str_equals($msg->{textBody}[0]{partId}, $msg->{htmlBody}[0]{partId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_bodystructure b/cassandane/tiny-tests/JMAPEmail/email_get_bodystructure new file mode 100644 index 0000000000..64dc0568d8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_bodystructure @@ -0,0 +1,222 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_bodystructure + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "boundary_1", + body => "" + # body A + . "\r\n--boundary_1\r\n" + . "X-Body-Id:A\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Disposition: inline\r\n" + . "\r\n" + . "A" + # multipart/mixed + . "\r\n--boundary_1\r\n" + . "Content-Type: multipart/mixed; boundary=\"boundary_1_1\"\r\n" + # multipart/alternative + . "\r\n--boundary_1_1\r\n" + . "Content-Type: multipart/alternative; boundary=\"boundary_1_1_1\"\r\n" + # multipart/mixed + . "\r\n--boundary_1_1_1\r\n" + . "Content-Type: multipart/mixed; boundary=\"boundary_1_1_1_1\"\r\n" + # body B + . "\r\n--boundary_1_1_1_1\r\n" + . "X-Body-Id:B\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Disposition: inline\r\n" + . "\r\n" + . "B" + # body C + . "\r\n--boundary_1_1_1_1\r\n" + . "X-Body-Id:C\r\n" + . "Content-Type: image/jpeg\r\n" + . "Content-Disposition: inline\r\n" + . "\r\n" + . "C" + # body D + . "\r\n--boundary_1_1_1_1\r\n" + . "X-Body-Id:D\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Disposition: inline\r\n" + . "\r\n" + . "D" + # end multipart/mixed + . "\r\n--boundary_1_1_1_1--\r\n" + # multipart/mixed + . "\r\n--boundary_1_1_1\r\n" + . "Content-Type: multipart/related; boundary=\"boundary_1_1_1_2\"\r\n" + # body E + . "\r\n--boundary_1_1_1_2\r\n" + . "X-Body-Id:E\r\n" + . "Content-Type: text/html\r\n" + . "\r\n" + . "E" + # body F + . "\r\n--boundary_1_1_1_2\r\n" + . "X-Body-Id:F\r\n" + . "Content-Type: image/jpeg\r\n" + . "\r\n" + . "F" + # end multipart/mixed + . "\r\n--boundary_1_1_1_2--\r\n" + # end multipart/alternative + . "\r\n--boundary_1_1_1--\r\n" + # body G + . "\r\n--boundary_1_1\r\n" + . "X-Body-Id:G\r\n" + . "Content-Type: image/jpeg\r\n" + . "Content-Disposition: attachment\r\n" + . "\r\n" + . "G" + # body H + . "\r\n--boundary_1_1\r\n" + . "X-Body-Id:H\r\n" + . "Content-Type: application/x-excel\r\n" + . "\r\n" + . "H" + # body J + . "\r\n--boundary_1_1\r\n" + . "Content-Type: message/rfc822\r\n" + . "X-Body-Id:J\r\n" + . "\r\n" + . "From: foo\@local\r\n" + . "Date: Thu, 10 May 2018 15:15:38 +0200\r\n" + . "\r\n" + . "J" + . "\r\n--boundary_1_1--\r\n" + # body K + . "\r\n--boundary_1\r\n" + . "X-Body-Id:K\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Disposition: inline\r\n" + . "\r\n" + . "K" + . "\r\n--boundary_1--\r\n" + ) || die; + + my $bodyA = { + 'header:x-body-id' => 'A', + type => 'text/plain', + disposition => 'inline', + }; + my $bodyB = { + 'header:x-body-id' => 'B', + type => 'text/plain', + disposition => 'inline', + }; + my $bodyC = { + 'header:x-body-id' => 'C', + type => 'image/jpeg', + disposition => 'inline', + }; + my $bodyD = { + 'header:x-body-id' => 'D', + type => 'text/plain', + disposition => 'inline', + }; + my $bodyE = { + 'header:x-body-id' => 'E', + type => 'text/html', + disposition => undef, + }; + my $bodyF = { + 'header:x-body-id' => 'F', + type => 'image/jpeg', + disposition => undef, + }; + my $bodyG = { + 'header:x-body-id' => 'G', + type => 'image/jpeg', + disposition => 'attachment', + }; + my $bodyH = { + 'header:x-body-id' => 'H', + type => 'application/x-excel', + disposition => undef, + }; + my $bodyJ = { + 'header:x-body-id' => 'J', + type => 'message/rfc822', + disposition => undef, + }; + my $bodyK = { + 'header:x-body-id' => 'K', + type => 'text/plain', + disposition => 'inline', + }; + + my $wantBodyStructure = { + 'header:x-body-id' => undef, + type => 'multipart/mixed', + disposition => undef, + subParts => [ + $bodyA, + { + 'header:x-body-id' => undef, + type => 'multipart/mixed', + disposition => undef, + subParts => [ + { + 'header:x-body-id' => undef, + type => 'multipart/alternative', + disposition => undef, + subParts => [ + { + 'header:x-body-id' => undef, + type => 'multipart/mixed', + disposition => undef, + subParts => [ + $bodyB, + $bodyC, + $bodyD, + ], + }, + { + 'header:x-body-id' => undef, + type => 'multipart/related', + disposition => undef, + subParts => [ + $bodyE, + $bodyF, + ], + }, + ], + }, + $bodyG, + $bodyH, + $bodyJ, + ], + }, + $bodyK, + ], + }; + + my $wantTextBody = [ $bodyA, $bodyB, $bodyC, $bodyD, $bodyK ]; + my $wantHtmlBody = [ $bodyA, $bodyE, $bodyK ]; + my $wantAttachments = [ $bodyC, $bodyF, $bodyG, $bodyH, $bodyJ ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['bodyStructure', 'textBody', 'htmlBody', 'attachments' ], + bodyProperties => ['type', 'disposition', 'header:x-body-id'], + }, 'R2' ], + ]); + my $msg = $res->[1][1]{list}[0]; + $self->assert_deep_equals($wantBodyStructure, $msg->{bodyStructure}); + $self->assert_deep_equals($wantTextBody, $msg->{textBody}); + $self->assert_deep_equals($wantHtmlBody, $msg->{htmlBody}); + $self->assert_deep_equals($wantAttachments, $msg->{attachments}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_crlf b/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_crlf new file mode 100644 index 0000000000..9711b2e3c5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_crlf @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_bodyvalues_crlf + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $mimeMsg = <<'EOF'; +From: +To: to@local +Bcc: bcc@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain;charset=utf-8 + +one +two +three +EOF + $mimeMsg =~ s/\r?\n/\r\n/gs; + $mimeMsg =~ s/\r\n$//; + $imap->append('INBOX', $mimeMsg) || die $@; + + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['bodyValues'], + fetchAllBodyValues => JSON::true, + }, 'R2'], + ]); + + $self->assert_str_equals("one\ntwo\nthree", + $res->[1][1]{list}[0]{bodyValues}{1}{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_markdown b/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_markdown new file mode 100644 index 0000000000..d123c7bf78 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_markdown @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_bodyvalues_markdown + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog "Upload email blob"; + my $rawEmail = "" + . "From: foo\@local\r\n" + . "To: bar\@local\r\n" + . "Subject: test\r\n" + . "Date: Tue, 24 Mar 2020 11:21:50 -0500\r\n" + . "Content-Type: text/x-markdown\r\n" + . "MIME-Version: 1.0\r\n" + . "\r\n" + . "This is a test"; + my $data = $jmap->Upload($rawEmail, "application/octet-stream"); + my $blobId = $data->{blobId}; + + xlog "Import and get email"; + my $res = $jmap->CallMethods([ + ['Email/import', { + emails => { + 1 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + blobId => $blobId, + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#1'], + properties => ['bodyStructure', 'bodyValues'], + bodyProperties => [ + 'partId', + 'type', + ], + fetchAllBodyValues => JSON::true, + }, '$2'], + ]); + + $self->assert_str_equals('text/x-markdown', + $res->[1][1]{list}[0]{bodyStructure}{type}); + my $partId = $res->[1][1]{list}[0]{bodyStructure}{partId}; + $self->assert_not_null($partId); + $self->assert_str_equals('This is a test', + $res->[1][1]{list}[0]{bodyValues}{$partId}{value}); + $self->assert_equals(JSON::false, + $res->[1][1]{list}[0]{bodyValues}{$partId}{isEncodingProblem}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_nulbyte b/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_nulbyte new file mode 100644 index 0000000000..49e7387850 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_bodyvalues_nulbyte @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_bodyvalues_nulbyte + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $mimeMsg = <<'EOF'; +From: +To: to@local +Bcc: bcc@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html;charset=utf-8 + +hello=00 world +EOF + $mimeMsg =~ s/\r?\n/\r\n/gs; + $mimeMsg =~ s/\r\n$//; + $imap->append('INBOX', $mimeMsg) || die $@; + + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['bodyValues'], + fetchAllBodyValues => JSON::true, + }, 'R2'], + ]); + + my $bodyValue = $res->[1][1]{list}[0]{bodyValues}{1}; + $self->assert_str_equals("hello world", + $bodyValue->{value}); + $self->assert_equals(JSON::true, $bodyValue->{isEncodingProblem}); + $self->assert_equals(JSON::false, $bodyValue->{isTruncated}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_bogus_encoding b/cassandane/tiny-tests/JMAPEmail/email_get_bogus_encoding new file mode 100644 index 0000000000..aba540735b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_bogus_encoding @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_bogus_encoding + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: foobar + +This is a test email. +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "import and get email from blob $blobid"; + my $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$inboxid => JSON::true}, + }, + }, + }, "R1"], ["Email/get", { + ids => ["#1"], + properties => ['bodyStructure', 'bodyValues'], + fetchAllBodyValues => JSON::true, + }, "R2" ]]); + + $self->assert_str_equals("Email/import", $res->[0][0]); + $self->assert_str_equals("Email/get", $res->[1][0]); + + my $msg = $res->[1][1]{list}[0]; + my $partId = $msg->{bodyStructure}{partId}; + my $bodyValue = $msg->{bodyValues}{$partId}; + $self->assert_str_equals("", $bodyValue->{value}); + $self->assert_equals(JSON::true, $bodyValue->{isEncodingProblem}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_brokenheader_split_codepoint b/cassandane/tiny-tests/JMAPEmail/email_get_brokenheader_split_codepoint new file mode 100644 index 0000000000..2534028233 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_brokenheader_split_codepoint @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_brokenheader_split_codepoint + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: =?UTF-8?Q?=F0=9F=98=80=F0=9F=98=83=F0=9F=98=84=F0=9F=98=81=F0=9F=98=86=F0?= + =?UTF-8?Q?=9F=98=85=F0=9F=98=82=F0=9F=A4=A3=E2=98=BA=EF=B8=8F=F0=9F=98=8A?= + =?UTF-8?Q?=F0=9F=98=87?= +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: foobar + +This is a test email. +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + my $inboxid = $self->getinbox()->{id}; + + my $wantSubject = '😀😃😄😁😆😅😂🤣☺️😊😇'; + utf8::decode($wantSubject); + + xlog $self, "import and get email from blob $blobid"; + my $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$inboxid => JSON::true}, + }, + }, + }, "R1"], ["Email/get", { + ids => ["#1"], + properties => ['subject'], + }, "R2" ]]); + + $self->assert_str_equals($wantSubject, $res->[1][1]{list}[0]{subject}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents new file mode 100644 index 0000000000..c112086126 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents + :min_version_3_7 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # calendarEvents property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $store = $self->{store}; + my $talk = $store->get_client(); + + $self->make_message("foo", + mime_type => "multipart/related", + mime_boundary => "boundary_1", + body => "" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "txt body" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/calendar;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "BEGIN:VCALENDAR\r\n" + . "VERSION:2.0\r\n" + . "PRODID:-//CyrusIMAP.org/Cyrus 3.1.3-606//EN\r\n" + . "CALSCALE:GREGORIAN\r\n" + . "BEGIN:VTIMEZONE\r\n" + . "TZID:Europe/Vienna\r\n" + . "BEGIN:STANDARD\r\n" + . "DTSTART:19700101T000000\r\n" + . "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n" + . "TZOFFSETFROM:+0200\r\n" + . "TZOFFSETTO:+0100\r\n" + . "END:STANDARD\r\n" + . "BEGIN:DAYLIGHT\r\n" + . "DTSTART:19700101T000000\r\n" + . "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n" + . "TZOFFSETFROM:+0100\r\n" + . "TZOFFSETTO:+0200\r\n" + . "END:DAYLIGHT\r\n" + . "END:VTIMEZONE\r\n" + . "BEGIN:VEVENT\r\n" + . "CREATED:20180518T090306Z\r\n" + . "DTEND;TZID=Europe/Vienna:20180518T100000\r\n" + . "DTSTAMP:20180518T090306Z\r\n" + . "DTSTART;TZID=Europe/Vienna:20180518T090000\r\n" + . "LAST-MODIFIED:20180518T090306Z\r\n" + . "SEQUENCE:1\r\n" + . "SUMMARY:K=C3=A4se\r\n" + . "TRANSP:OPAQUE\r\n" + . "UID:d9e7f7d6-ce1a-4a71-94c0-b4edd41e5959\r\n" + . "END:VEVENT\r\n" + . "END:VCALENDAR\r\n" + . "\r\n--boundary_1--\r\n" + ) || die; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['textBody', 'attachments', 'calendarEvents'], + }, 'R2' ], + ]); + my $msg = $res->[1][1]{list}[0]; + + $self->assert_num_equals(1, scalar @{$msg->{attachments}}); + $self->assert_str_equals('text/calendar', $msg->{attachments}[0]{type}); + + $self->assert_num_equals(1, scalar keys %{$msg->{calendarEvents}}); + my $partId = $msg->{attachments}[0]{partId}; + + my @jsevents = @{$msg->{calendarEvents}{$partId}}; + $self->assert_num_equals(1, scalar @jsevents); + my $jsevent = $jsevents[0]; + + $self->assert_str_equals("K\N{LATIN SMALL LETTER A WITH DIAERESIS}se", $jsevent->{title}); + $self->assert_str_equals('2018-05-18T09:00:00', $jsevent->{start}); + $self->assert_str_equals('Europe/Vienna', $jsevent->{timeZone}); + $self->assert_str_equals('PT1H', $jsevent->{duration}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_allow_max_uids_only b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_allow_max_uids_only new file mode 100644 index 0000000000..dba5382ca9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_allow_max_uids_only @@ -0,0 +1,115 @@ +#!perl +use Cassandane::Tiny; + +use Data::UUID; + +sub do +{ + my ($self, $nevents, $exceedsThreshold) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $instance = $self->{instance}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + # Generate calendar attachment + + my $vevents; + my $now = DateTime->now(); + $now->set_time_zone('Etc/UTC'); + for (my $i = 1; $i <= $nevents; $i++) { + my $uuid = Data::UUID->new; + my $uid = $uuid->create_str; + my $start = $now->strftime('%Y-%m-%dT%H:%M:%SZ'); + $vevents .= <<"EOF"; +BEGIN:VEVENT +DTSTART:$start +DURATION:PT1H +UID:$uid +SUMMARY:test$i +END:VEVENT +EOF + $now->add(DateTime::Duration->new(seconds => 300)); + } + $vevents =~ s/\s+$//; + + # Generate MIME message + + my $subject = "nevents$nevents"; + my $mimeMessage = <<"EOF"; +From: from\@local +To: to\@local +Subject: $subject +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/related; + boundary=c4683f7a320d4d20902b000486fbdf9b + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/plain + +test + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/calendar;charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +$vevents +END:VCALENDAR + +--c4683f7a320d4d20902b000486fbdf9b-- +EOF + $mimeMessage =~ s/\r?\n/\r\n/gs; + + xlog $self, "Generate MIME message"; + $imap->append('INBOX', $mimeMessage) || die $@; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "Query email"; + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + subject => $subject, + }, + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + properties => ['calendarEvents'], + }, 'R2'], + ], $using); + + if ($exceedsThreshold) { + $self->assert_null($res->[1][1]{list}[0]{calendarEvents}); + } else { + $self->assert_num_equals($nevents, + scalar @{$res->[1][1]{list}[0]{calendarEvents}{2}}); + } +} + +sub test_email_get_calendarevents_allow_max_uids_only + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + xlog $self, "Assert that 8 unique UIDs are allowed"; + $self->do(8, 0); + + xlog $self, "Assert that 9 unique UIDs are rejected"; + $self->do(9, 1); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_deduplicate b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_deduplicate new file mode 100644 index 0000000000..ea41aac415 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_deduplicate @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents_deduplicate + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $ical = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150806T234327Z +UID:123456789 +SUMMARY:test +DTSTART:20160831T153000Z +DURATION:PT1H +DTSTAMP:20150806T234327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + $ical =~ s/\r?\n/\r\n/gs; + my $b64Ical = encode_base64($ical); + $b64Ical =~ s/\r?\n/\r\n/gs; + + $self->make_message('test', + mime_type => 'multipart/related', + mime_boundary => 'boundary', + body => "" + . "\r\n--boundary\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "test" + . "\r\n--boundary\r\n" + . "Content-Type: text/calendar;charset=\"utf-8\"; method=REPLY\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "\r\n" + . $ical + . "\r\n--boundary\r\n" + . "Content-Type: application/ics; name=\"test.ics\"\r\n" + . "Content-Disposition: attachment; filename=\"test.ics\"\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . $b64Ical + . "\r\n--boundary--\r\n" + ) || die; + + xlog "Fetch email via JMAP"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['calendarEvents'], + }, 'R2' ], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my @eventMimeParts = values %{$res->[1][1]{list}[0]{calendarEvents}}; + $self->assert_num_equals(1, scalar @eventMimeParts); + $self->assert_num_equals(1, scalar @{$eventMimeParts[0]}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_extended_filename b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_extended_filename new file mode 100644 index 0000000000..26243e5288 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_extended_filename @@ -0,0 +1,66 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents_extended_filename + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $body = <<'EOF'; +--boundary_1 +Content-Type: text/plain + +body +--boundary_1 +Content-Type: text/calendar;name*0=some;an*1=xpara;name*2=m.ics\r\n". + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20210101T120000 +DURATION:PT1H +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:test +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +--boundary_1-- +EOF + $body =~ s/\r?\n/\r\n/gs; + + $self->make_message('test', mime_type => 'multipart/related', + mime_boundary => 'boundary_1', body => $body) or die; + + xlog $self, 'get email'; + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ + 'calendarEvents', 'bodyStructure', + ], + }, 'R2'], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + $self->assert_num_equals(1, + scalar @{$res->[1][1]{list}[0]{calendarEvents}{2}}); + $self->assert_str_equals('test', + $res->[1][1]{list}[0]{calendarEvents}{2}[0]{title}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_icsfile b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_icsfile new file mode 100644 index 0000000000..f02c7f6b5c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_icsfile @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents_icsfile + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # calendarEvents property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $rawEvent = "" + . "BEGIN:VCALENDAR\r\n" + . "VERSION:2.0\r\n" + . "PRODID:-//CyrusIMAP.org/Cyrus 3.1.3-606//EN\r\n" + . "CALSCALE:GREGORIAN\r\n" + . "BEGIN:VTIMEZONE\r\n" + . "TZID:Europe/Vienna\r\n" + . "BEGIN:STANDARD\r\n" + . "DTSTART:19700101T000000\r\n" + . "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n" + . "TZOFFSETFROM:+0200\r\n" + . "TZOFFSETTO:+0100\r\n" + . "END:STANDARD\r\n" + . "BEGIN:DAYLIGHT\r\n" + . "DTSTART:19700101T000000\r\n" + . "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n" + . "TZOFFSETFROM:+0100\r\n" + . "TZOFFSETTO:+0200\r\n" + . "END:DAYLIGHT\r\n" + . "END:VTIMEZONE\r\n" + . "BEGIN:VEVENT\r\n" + . "CREATED:20180518T090306Z\r\n" + . "DTEND;TZID=Europe/Vienna:20180518T100000\r\n" + . "DTSTAMP:20180518T090306Z\r\n" + . "DTSTART;TZID=Europe/Vienna:20180518T090000\r\n" + . "LAST-MODIFIED:20180518T090306Z\r\n" + . "SEQUENCE:1\r\n" + . "SUMMARY:Hello\r\n" + . "TRANSP:OPAQUE\r\n" + . "UID:d9e7f7d6-ce1a-4a71-94c0-b4edd41e5959\r\n" + . "END:VEVENT\r\n" + . "END:VCALENDAR\r\n"; + + $self->make_message("foo", + mime_type => "multipart/related", + mime_boundary => "boundary_1", + body => "" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "txt body" + . "\r\n--boundary_1\r\n" + . "Content-Type: application/unknown\r\n" + . "Content-Transfer-Encoding: base64\r\n" + ."Content-Disposition: attachment; filename*0=Add_Appointment_;\r\n filename*1=To_Calendar.ics\r\n" + . "\r\n" + . encode_base64($rawEvent, "\r\n") + . "\r\n--boundary_1--\r\n" + ) || die; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['textBody', 'attachments', 'calendarEvents'], + }, 'R2' ], + ]); + my $msg = $res->[1][1]{list}[0]; + + my $partId = $msg->{attachments}[0]{partId}; + my $jsevent = $msg->{calendarEvents}{$partId}[0]; + $self->assert_str_equals("Hello", $jsevent->{title}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_itip_schedprops b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_itip_schedprops new file mode 100644 index 0000000000..bc8d148581 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_itip_schedprops @@ -0,0 +1,186 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents_itip_schedprops + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my ($maj, $min) = Cassandane::Instance->get_version(); + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # calendarEvents property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my @testCases = ({ + ical => <<'EOF', +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:REPLY +BEGIN:VEVENT +DTSTART:20210807T130000Z +DTEND:20210807T140000Z +DTSTAMP:20210802T032234Z +ORGANIZER;CN=Test User:mailto:organizer@local +UID:a4294f2a-cafb-407b-951b-67684ed0ba54 +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE; + X-NUM-GUESTS=0;X-RESPONSE-COMMENT="Hello\, World!":mailto:attendee@local +CREATED:20210802T032207Z +DESCRIPTION: +LAST-MODIFIED:20210802T032234Z +LOCATION: +SEQUENCE:3 +STATUS:CONFIRMED +SUMMARY:iTIP REPLY +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +EOF + wantMethod => 'reply', + wantScheduleUpdated => '2021-08-02T03:22:34Z', + wantScheduleSequence => 3, + wantParticipationComment => 'Hello, World!', + }, { + ical => <<'EOF', +BEGIN:VCALENDAR +METHOD:REPLY +PRODID:Microsoft Exchange Server 2010 +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Greenwich Standard Time +BEGIN:STANDARD +DTSTART:16010101T000000 +TZOFFSETFROM:+0000 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T000000 +TZOFFSETFROM:+0000 +TZOFFSETTO:+0000 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +ATTENDEE;PARTSTAT=ACCEPTED;CN=Opera Tester:mailto:attendee@local +COMMENT;LANGUAGE=en-GB:A comment.\n +UID:a4294f2a-cafb-407b-951b-67684ed0ab56 +SUMMARY;LANGUAGE=en-GB:Accepted: iTIP REPLY test +DTSTART;TZID=Greenwich Standard Time:20210807T090000 +DTEND;TZID=Greenwich Standard Time:20210807T100000 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20210802T032446Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:5 +X-MICROSOFT-CDO-APPT-SEQUENCE:1 +X-MICROSOFT-CDO-OWNERAPPTID: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:0 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +END:VEVENT +END:VCALENDAR +EOF + wantMethod => 'reply', + wantScheduleUpdated => '2021-08-02T03:24:46Z', + wantScheduleSequence => 5, + wantParticipationComment => "A comment.\n", + }, { + ical => <<'EOF', +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:COUNTER +BEGIN:VEVENT +DTSTART:20210807T130000Z +DTEND:20210807T140000Z +DTSTAMP:20210802T032234Z +ORGANIZER;CN=Test User:mailto:organizer@local +UID:a4294f2a-cafb-407b-951b-67684ed0ba54 +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE; + X-NUM-GUESTS=0:mailto:attendee@local +COMMENT:A counter comment +CREATED:20210802T032207Z +DESCRIPTION: +LAST-MODIFIED:20210802T032234Z +LOCATION: +SEQUENCE:3 +STATUS:CONFIRMED +SUMMARY:iTIP COUNTER +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +EOF + sinceVersion => ['3','7'], + wantMethod => 'counter', + wantScheduleUpdated => '2021-08-02T03:22:34Z', + wantScheduleSequence => 3, + wantParticipationComment => 'A counter comment', + }); + + foreach my $i (0 .. $#testCases) { + my $tc = $testCases[$i]; + + # skip tests for older Cyrus versions + next if $tc->{sinceVersion} && + ($maj le $tc->{sinceVersion}[0] || + ($maj eq $tc->{sinceVersion}[0] && + $min le $tc->{sinceVersion}[1])); + + $tc->{ical} =~ s/\r?\n/\r\n/gs; + $self->make_message("test$i", + mime_type => 'multipart/related', + mime_boundary => 'boundary', + body => "" + . "\r\n--boundary\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "test" + . "\r\n--boundary\r\n" + . "Content-Type: text/calendar;charset=utf-8\r\n" + . "\r\n" + . $tc->{ical} + . "\r\n--boundary--\r\n" + ) || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-i'); + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ + property => 'subject', + isAscending => JSON::false, + }], + limit => 1, + }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['calendarEvents'], + }, 'R2' ], + ]); + + my $event = (values %{$res->[1][1]{list}[0]{calendarEvents}})[0][0]; + $self->assert_not_null($event); + $self->assert_str_equals($tc->{wantMethod}, $event->{method}); + + my @attendees = grep { exists $_->{roles}{attendee} } values %{$event->{participants}}; + $self->assert_num_equals(1, scalar @attendees); + $self->assert_str_equals($tc->{wantScheduleUpdated}, + $attendees[0]->{scheduleUpdated}); + $self->assert_num_equals($tc->{wantScheduleSequence}, + $attendees[0]->{scheduleSequence}); + $self->assert_str_equals($tc->{wantParticipationComment}, + $attendees[0]->{participationComment}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_links_blobid b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_links_blobid new file mode 100644 index 0000000000..2604f4d30d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_links_blobid @@ -0,0 +1,109 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents_links_blobid + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150806T234327Z +ORGANIZER:cassandane@example.com +ATTENDEE:attendee@local +UID:123456789 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=Australia/Melbourne:20160831T153000 +DURATION:PT1H +DTSTAMP:20150806T234327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + $caldav->Request('PUT', 'Default/test.ics', $rawIcal, + 'Content-Type' => 'text/calendar'); + my $eventHref = '/dav/calendars/user/cassandane/Default/test.ics'; + + # clean notification cache + $self->{instance}->getnotify(); + + xlog "Add attachment via CalDAV"; + my $url = $caldav->request_url($eventHref) . '?action=attachment-add'; + my $caldavResponse = $caldav->ua->post($url, { + headers => { + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment;filename=test', + 'Prefer' => 'return=representation', + 'Authorization' => $caldav->auth_header(), + }, + content => 'someblob', + }); + $self->assert_str_equals('201', $caldavResponse->{status}); + + xlog "Get updated VEVENT via CalDAV"; + $caldavResponse = $caldav->Request('GET', $eventHref); + my $veventWithManagedAttachUrl = $caldavResponse->{content}; + $self->assert_not_null($veventWithManagedAttachUrl); + + xlog "Get updated VEVENT via iTIP"; + my $notif = $self->{instance}->getnotify(); + my ($imip) = grep { $_->{METHOD} eq 'imip' } @$notif; + my $payload = decode_json($imip->{MESSAGE}); + my $veventWithManagedAttachBinary = $payload->{ical}; + $self->assert_not_null($veventWithManagedAttachBinary); + + xlog "Embed VEVENT in email"; + $self->make_message('test', + mime_type => 'multipart/related', + mime_boundary => 'boundary', + body => "" + . "\r\n--boundary\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "test" + . "\r\n--boundary\r\n" + . "Content-Type: text/calendar;charset=utf-8\r\n" + . "\r\n" + . $veventWithManagedAttachUrl + . "\r\n--boundary\r\n" + . "Content-Type: text/calendar;charset=utf-8\r\n" + . "\r\n" + . $veventWithManagedAttachBinary + . "\r\n--boundary--\r\n" + ) || die; + + xlog "Fetch email via JMAP"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['calendarEvents'], + }, 'R2' ], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + xlog "Assert both events have the same blobId"; + my @linksFromUrl = values %{$res->[1][1]{list}[0]{calendarEvents}{2}[0]{links}}; + $self->assert_num_equals(1, scalar @linksFromUrl); + my @linksFromBinary = values %{$res->[1][1]{list}[0]{calendarEvents}{3}[0]{links}}; + $self->assert_num_equals(1, scalar @linksFromBinary); + $self->assert_str_equals($linksFromUrl[0]->{blobId}, $linksFromBinary[0]->{blobId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_links_blobid_attachbinary b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_links_blobid_attachbinary new file mode 100644 index 0000000000..75eb1b386c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_links_blobid_attachbinary @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents_links_blobid_attachbinary + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + xlog "Create event via CalDAV"; + my $rawIcal = <<'EOF'; +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20150806T234327Z +ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=text/plain:aGVsbG8= +ORGANIZER:cassandane@example.com +ATTENDEE:attendee@local +UID:123456789 +TRANSP:OPAQUE +SUMMARY:test +DTSTART;TZID=Australia/Melbourne:20160831T153000 +DURATION:PT1H +DTSTAMP:20150806T234327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + $rawIcal =~ s/\r?\n/\r\n/gs; + + xlog "Make email"; + $self->make_message('test', + mime_type => 'multipart/related', + mime_boundary => 'boundary', + body => "" + . "\r\n--boundary\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "test" + . "\r\n--boundary\r\n" + . "Content-Type: text/calendar;charset=utf-8\r\n" + . "\r\n" + . $rawIcal + . "\r\n--boundary--\r\n" + ) || die; + + xlog "Fetch email via JMAP"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['calendarEvents'], + }, 'R2' ], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + my $link = (values %{$res->[1][1]{list}[0]{calendarEvents}{2}[0]{links}})[0]; + $self->assert_not_null($link); + $self->assert_not_null($link->{blobId}); + $res = $jmap->Download('cassandane', uri_escape($link->{blobId})); + $self->assert_str_equals("hello", $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_standalone_instances b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_standalone_instances new file mode 100644 index 0000000000..e482bd76da --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_standalone_instances @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents_standalone_instances + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $body = <<'EOF'; +--boundary_1 +Content-Type: text/plain + +body +--boundary_1 +Content-Type: text/calendar + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +RECURRENCE-ID;TZID=America/New_York:20210101T060000 +DTSTART;TZID=Europe/Berlin:20210101T120000 +DURATION:PT1H +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:instance1 +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +BEGIN:VEVENT +RECURRENCE-ID;TZID=America/New_York:20210301T060000 +DTSTART;TZID=America/New_York:20210301T080000 +DURATION:PT1H +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:instance2 +SEQUENCE:0 +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +--boundary_1-- +EOF + $body =~ s/\r?\n/\r\n/gs; + + $self->make_message('test', mime_type => 'multipart/related', + mime_boundary => 'boundary_1', body => $body) or die; + + xlog $self, 'get email'; + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ + 'attachments', 'calendarEvents' + ], + }, 'R2'], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:calendars', + 'urn:ietf:params:jmap:principals', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/calendars', + ]); + + $self->assert_num_equals(2, + scalar @{$res->[1][1]{list}[0]{calendarEvents}{2}}); + $self->assert_str_equals('instance1', + $res->[1][1]{list}[0]{calendarEvents}{2}[0]{title}); + $self->assert_str_equals('instance2', + $res->[1][1]{list}[0]{calendarEvents}{2}[1]{title}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_utc b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_utc new file mode 100644 index 0000000000..c91c668afe --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_calendarevents_utc @@ -0,0 +1,90 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_calendarevents_utc + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # calendarEvents property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $uid1 = "d9e7f7d6-ce1a-4a71-94c0-b4edd41e5959"; + + $self->make_message("foo", + mime_type => "multipart/related", + mime_boundary => "boundary_1", + body => "" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "txt body" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/calendar;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "BEGIN:VCALENDAR\r\n" + . "VERSION:2.0\r\n" + . "PRODID:-//CyrusIMAP.org/Cyrus 3.1.3-606//EN\r\n" + . "CALSCALE:GREGORIAN\r\n" + . "BEGIN:VTIMEZONE\r\n" + . "TZID:UTC\r\n" + . "BEGIN:STANDARD\r\n" + . "DTSTART:16010101T000000\r\n" + . "TZOFFSETFROM:+0000\r\n" + . "TZOFFSETTO:+0000\r\n" + . "END:STANDARD\r\n" + . "BEGIN:DAYLIGHT\r\n" + . "DTSTART:16010101T000000\r\n" + . "TZOFFSETFROM:+0000\r\n" + . "TZOFFSETTO:+0000\r\n" + . "END:DAYLIGHT\r\n" + . "END:VTIMEZONE\r\n" + . "BEGIN:VEVENT\r\n" + . "CREATED:20180518T090306Z\r\n" + . "DTEND;TZID=UTC:20180518T100000\r\n" + . "DTSTAMP:20180518T090306Z\r\n" + . "DTSTART;TZID=UTC:20180518T090000\r\n" + . "LAST-MODIFIED:20180518T090306Z\r\n" + . "SEQUENCE:1\r\n" + . "SUMMARY:Foo\r\n" + . "TRANSP:OPAQUE\r\n" + . "UID:$uid1\r\n" + . "END:VEVENT\r\n" + . "END:VCALENDAR\r\n" + . "\r\n--boundary_1--\r\n" + ) || die; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['textBody', 'attachments', 'calendarEvents'], + }, 'R2' ], + ]); + my $msg = $res->[1][1]{list}[0]; + + $self->assert_num_equals(1, scalar @{$msg->{attachments}}); + $self->assert_str_equals('text/calendar', $msg->{attachments}[0]{type}); + + $self->assert_num_equals(1, scalar keys %{$msg->{calendarEvents}}); + my $partId = $msg->{attachments}[0]{partId}; + + my %jsevents_by_uid = map { $_->{uid} => $_ } @{$msg->{calendarEvents}{$partId}}; + $self->assert_num_equals(1, scalar keys %jsevents_by_uid); + my $jsevent1 = $jsevents_by_uid{$uid1}; + + $self->assert_not_null($jsevent1); + $self->assert_str_equals("Foo", $jsevent1->{title}); + $self->assert_str_equals('2018-05-18T09:00:00', $jsevent1->{start}); + $self->assert_str_equals('Etc/UTC', $jsevent1->{timeZone}); + $self->assert_str_equals('PT1H', $jsevent1->{duration}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_cid b/cassandane/tiny-tests/JMAPEmail/email_get_cid new file mode 100644 index 0000000000..e1350952c6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_cid @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_cid + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + $self->make_message("msg1", + mime_type => "multipart/mixed", + mime_boundary => "boundary", + body => "" + . "--boundary\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: image/png\r\n" + . "Content-Id: <1234567890\@local>\r\n" + . "\r\n" + . "data" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: image/png\r\n" + . "Content-Id: <1234567890>\r\n" + . "\r\n" + . "data" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: image/png\r\n" + . "Content-Id: 1234567890\r\n" + . "\r\n" + . "data" + . "\r\n" + . "--boundary--\r\n" + ) || die; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'bodyStructure' ], + bodyProperties => ['partId', 'cid'], + }, 'R2'], + ]); + my $bodyStructure = $res->[1][1]{list}[0]{bodyStructure}; + + $self->assert_null($bodyStructure->{subParts}[0]{cid}); + $self->assert_str_equals('1234567890@local', $bodyStructure->{subParts}[1]{cid}); + $self->assert_str_equals('1234567890', $bodyStructure->{subParts}[2]{cid}); + $self->assert_str_equals('1234567890', $bodyStructure->{subParts}[3]{cid}); + +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_createdmodseq b/cassandane/tiny-tests/JMAPEmail/email_get_createdmodseq new file mode 100644 index 0000000000..6747ce8e6d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_createdmodseq @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_createdmodseq + :min_version_3_9 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/mail'); + + xlog $self, "Append duplicate messages"; + $mimeMessage = <<'EOF'; +From: +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +test +EOF + $mimeMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', '17-Aug-2023 15:13:54 +0200', $mimeMessage) || die $@; + $imap->append('INBOX', '17-Aug-2023 15:13:54 +0200', $mimeMessage) || die $@; + + $imap->select('INBOX'); + $fetch = $imap->fetch('1:2', ['INTERNALDATE', 'CREATEDMODSEQ']); + $self->assert_str_equals( + $fetch->{1}{internaldate}, $fetch->{2}{internaldate} + ); + $self->assert_num_lt( + $fetch->{2}{createdmodseq}[0], $fetch->{1}{createdmodseq}[0] + ); + + # The createdModseq must be the lower modseq. + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['createdModseq'], + }, 'R2' ], + ]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_num_equals($fetch->{1}{createdmodseq}[0], + $res->[1][1]{list}[0]{createdModseq}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_deliveredto b/cassandane/tiny-tests/JMAPEmail/email_get_deliveredto new file mode 100644 index 0000000000..1822bfc694 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_deliveredto @@ -0,0 +1,96 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_deliveredto + :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $rawMessage = <<'EOF'; +From: +To: to@local +Bcc: bcc@local +X-Delivered-To: x-delivered-to@local +Subject: msg1 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +msg1 +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + $rawMessage = <<'EOF'; +From: +To: to@local +Bcc: bcc@local +X-Original-Delivered-To: x-original-delivered-to@local +X-Delivered-To: x-delivered-to@local +Subject: msg2 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +msg2 +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + $rawMessage = <<'EOF'; +From: +To: to@local +Subject: msg3 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +msg3 +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { }, + sort => [{ + property => 'subject', + }], + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['subject', 'deliveredTo'], + }, 'R2'], + ], $using); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + + # This test assumes that Email/get returns the emails in order + # of the ids property request argument. + + $self->assert_str_equals('msg1', $res->[1][1]{list}[0]{subject}); + $self->assert_str_equals('x-delivered-to@local', + $res->[1][1]{list}[0]{deliveredTo}); + + $self->assert_str_equals('msg2', $res->[1][1]{list}[1]{subject}); + $self->assert_str_equals('x-original-delivered-to@local', + $res->[1][1]{list}[1]{deliveredTo}); + + $self->assert_str_equals('msg3', $res->[1][1]{list}[2]{subject}); + $self->assert_null($res->[1][1]{list}[2]{deliveredTo}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_detect_iso_8859_1 b/cassandane/tiny-tests/JMAPEmail/email_get_detect_iso_8859_1 new file mode 100644 index 0000000000..35cb80e4e4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_detect_iso_8859_1 @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_detect_iso_8859_1 + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :needs_dependency_chardet +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: Here is some ISO-8859-1 text that claims to be ascii +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain +Content-Transfer-Encoding: base64 + +Ikvkc2Ugc2NobGllc3N0IGRlbiBNYWdlbiIsIGj2cnRlIGljaCBkZW4gU2NobG/faGVycm4gc2FnZW4uCg== + +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "import and get email from blob $blobid"; + my $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$inboxid => JSON::true}, + }, + }, + }, "R1"], ["Email/get", { + ids => ["#1"], + properties => ['textBody', 'bodyValues'], + fetchTextBodyValues => JSON::true, + }, "R2" ]]); + + $self->assert_num_equals(0, + index($res->[1][1]{list}[0]{bodyValues}{1}{value}, + "\"K\N{LATIN SMALL LETTER A WITH DIAERESIS}se") + ); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{bodyValues}{1}{isEncodingProblem}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_detect_utf32 b/cassandane/tiny-tests/JMAPEmail/email_get_detect_utf32 new file mode 100644 index 0000000000..d4d3c8ecc3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_detect_utf32 @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_detect_utf32 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: Here are some base64-encoded UTF-32LE bytes without BOM. +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-32" +Content-Transfer-Encoding: base64 + +QjAAAIEwAACKMAAASzAAAGlyAACeigAADQAAAAoAAAANAAAACgAAAAAwAAAOZgAAu2wAAAlOAABB +UwAAbVEAAHReAABuMAAAy3kAAEFTAAAIZwAAbjAAAAOYAACIMAAAijAAAHN8AAALVwAAazAAAEqQ +AABzMAAAZjAAAMpOAAAygwAADmYAALtsAADbVgAAQVMAAHReAAANAAAACgAAAAAwAABuMAAAD1kA +AANOAAAIZwAA1TAAAOkwAADzMAAAuTAAAGswAAARVAAAcjAAAGYwAADLMAAA5TAAAPwwAADoMAAA +/DAAAK8wAACSMAAAu1MAAIswAABrMAAA6IEAAH8wAAABMAAA5WUAAAOYAAANAAAACgAAAAAwAADF +ZQAAl3oAAGswAAD4ZgAATTAAALR9AACKMAAAXzAAAIswAACCMAAAbjAAAJIwAAChYwAAijAAAMaW +AACBMAAAZjAAAAEwAABCMAAAgTAAAIowAABLMAAAgjAAAG4wAABMMAAAXzAAAIowAAANAAAACgAA +AAAwAABoMAAATJgAAFcwAAABMAAAY/oAAJMwAABnMAAAjzAAAEwwAABpYAAAK14AAGswAABXMAAA +ZjAAAGlgAADLUwAAajAAAIswAAAPXAAA4mwAAHFcAAC6TgAA1l0AADeMAABIUQAAH3UAAG4wAAAN +AAAACgAAAAAwAAA6ZwAAC04AAGswAABIVAAAWTAAAAIwAAAOZgAAu2wAANtWAABBUwAAdF4AAEFT +AAAATgAACGcAAMyRAAA7ZgAAazAAAGYwAAA4bAAAlU4AAHeDAAComAAAAjAAAA0AAAAKAAAADQAA +AAoAAAAAMAAAOYIAAD9iAAAcWQAAcYoAAA0AAAAKAAAADQAAAAoAAAAAMAAAVU8AAFWGAAAKMAAA +RDAAAGUwAABTMAAACzAAAGswAABXMAAAZjAAAIIwAAB4lgAAkjAAAIuJAACLMAAAi04AAG4wAAD6 +UQAAhk8AAGowAABEMAAAKoIAAEX6AABvMAAAATAAAIZrAABpMAAAKlgAAHgwAABo+gAARDAAAAt6 +AAAhcQAASoAAAAowAAB2MAAAjDAAAEYwAAALMAAAazAAAOaCAABXMAAAgTAAAIkwAACMMAAAizAA +AIIwAABuMAAAZzAAAEIwAACLMAAATDAAAAEwAABragAA8W8AAEswAACJMAAAnk4AAHN8AAApUgAA +oFIAAAowAABCMAAAgTAAAIowAABLMAAACzAAAG4wAACwZQAAi5UAADBXAAC3MAAAojAAAMgwAADr +MAAAbjAAAC9uAAB4MAAAGpAAAHUwAAAqggAARfoAAAEwAABkawAAjDAAAIIwAABdMAAAbjAAAABO +AADEMAAAZzAAAEIwAACJMAAARjAAAAIwAAANAAAACgAAAAAwAAD6UQAABl4AAFcwAABfMAAA5WUA +AAEwAABFZQAAC1cAAG4wAABxXAAAcV8AAGswAAAlUgAAjDAAAF8wAABqMAAAiTAAAAEwAAA5ggAA +olsAAG8wAAB8XwAAuFwAAG4wAAAnWQAAeJYAAGswAAA5kAAAWTAAAIswAAB2UQAAbjAAAOVlAAB+ +MAAAZzAAAAEwAABKUwAACGcAAEIwAAB+MAAAijAAAG4wAACTlQAAATAAAABOAADEMAAAbjAAAPZc +AAABMAAAAE4AAMQwAABuMAAAcVwAAJIwAACCMAAAi4kAAIswAACLTgAAbzAAAPpRAACGTwAAajAA +AEQwAAACMAAAKGYAAOVlAACCMAAARfoAAAEwAADKTgAA5WUAAIIwAABF+gAAFSAAABUgAAAVIAAA +VU8AAEJmAACLiQAAZjAAAIIwAACKiwAAiTAAAGwwAAAqWQAAc14AAAttAABuMAAAOncAABtnAAAK +MAAAajAAAEwwAACBMAAACzAAAGgwAACRTgAAdTAAAG4wAABvMAAAL1UAAGAwAAArgwAAIG8AAGgw +AABXMAAAZjAAAAEwAAAnWQAATTAAAGowAADibAAAam0AAAowAABqMAAAfzAAAAswAABuMAAAd40A +AA9PAABZMAAAizAAAIqQAABrMAAA/H8AAG4wAAB3lQAARDAAADRWAAAKMAAATzAAAGEwAABwMAAA +VzAAAAswAABuMAAA8mYAAGQwAABfMAAAcHAAAHKCAABuMAAA4U8AAClZAADBfwAACjAAAEIwAABv +MAAARjAAAGkwAACKMAAACzAAAG4wAADbmAAAczAAAPteAABkMAAAZjAAAJAwAACLMAAAcDAAAEsw +AACKMAAAZzAAAEIwAACLMAAAAjAAAF0wAABuMAAACk4AAGswAACCMAAAKVkAACNsAABvMAAAIWsA +ACx7AABrMAAAF1MAAG4wAAC5ZQAAeDAAAGgwAAAykAAAgDAAAGswAAAjkAAAjDAAAGYwAADDXwAA +MFcAAIgwAABPMAAAEvoAAIwwAAAhbgAAizAAAItOAABvMAAAAHoAAGswAABqMAAAijAAAAEwAAB+ +MAAAZTAAAM9rAADlZQAAbjAAAIQwAABGMAAAazAAAHp6AABvMAAAl2YAALlvAABfMAAAizAAACCf +AAByggAAbjAAAPKWAABrMAAAPYUAAHIwAADhdgAAVTAAAIswAACdMAAAbjAAAH8wAABLMAAA1VIA +AAowAACEMAAAnTAAAAswAACCMAAAWTAAAIwwAABwMAAA6JYAAEswAADIUwAAbzAAACeXAABrMAAA +ajAAAGQwAABmMAAAhk4AAHUwAAACMAAADQAAAAoAAAAAMAAAwXkAAG8wAAAWVwAAiTAAAFowAACC +MAAAZGsAAMttAABXMAAARDAAAEX6AABuMAAACk4AAG4wAADFZQAAuk4AAGswAABqMAAAZDAAAF8w +AAACMAAAXTAAAFcwAABmMAAA6WUAAE8wAACCMAAAQVMAAOVlAABwMAAASzAAAIowAABuMAAA5WUA +AHhlAACSMAAAAZAAAIowAACXXwAAXzAAAFWGAABnMAAAQjAAAIswAAACMAAAXWYAAJOVAABqMAAA +iTAAAHAwAAAydQAAf2cAAGcwAACwdAAAlWIAAAowAACPMAAAajAAAFIwAAALMAAAbjAAAEqQAABz +MAAAATAAAOWCAABXMAAATzAAAG8wAACrVQAAWXEAAKRbAABnMAAAqJoAAExyAAAKMAAASzAAAIsw +AABfMAAACzAAAJIwAADWUwAAijAAAGowAABeMAAAVzAAAGYwAAABMAAAaTAAAEYwAABLMAAAr2UA +AEYwAABLMAAAQmYAAJOVAACSMAAAiG0AALuMAABZMAAAizAAAItOAABMMAAA+lEAAIZPAACLMAAA +UTAAAIwwAABpMAAAATAAAFUwAABmMAAAWmYAABCZAABuMAAA35gAAFNTAAAKMAAAxjAAAPwwAADW +MAAA6zAAAAswAACSMAAA4pYAAIwwAABmMAAASzAAAIkwAABuMAAAHFkAAGswAABqMAAAizAAAGgw +AAABMAAAhmsAAGkwAAAycgAAWTAAAItOAABMMAAAIXEAAE8wAABqMAAAZDAAAGYwAACGTgAAdTAA +AAIwAAAUTgAAZDAAAMpOAADlZQAAQjAAAF8wAACKMAAAbzAAABiZAAALegAAI2wAABlQAACCMAAA +0lsAAE8wAABqMAAAZDAAAGYwAACGTwAAXzAAAIQwAABGMAAAYDAAAAIwAAAWWQAAV1kAAGowAABX +MAAAZzAAAG8wAABoMAAAZjAAAIIwAAAydQAAf2cAAJIwAABlawAARDAAAGYwAACrVQAAWXEAAKRb +AAB4MAAAgjAAAEyIAABLMAAAjDAAAH4wAABEMAAAaDAAAB1gAAB1MAAAQGIAAEswAACJMAAAATAA +AMF5AABvMAAAdlEAAG4wAAAYUQAAXP8AADmCAAA/YgAACjAAAK0wAADkMAAA0zAAAPMwAAALMAAA +azAAAImVAABYMAAAYHwAAGQwAABmMAAAATAAAOVlAAAsZwAASzAAAIkwAAABYwAAZDAAAGYwAACG +TwAAXzAAANyWAACMigAAZzAAAIIwAACLlQAASzAAAEYwAABLMAAAaDAAAB1gAABkMAAAZjAAAEVc +AACLMAAAaDAAAAEwAAB2UQAAbjAAAEJmAACkWwAAbjAAADZiAACSMAAAB2MAAEhRAABnMAAAszAA +AMgwAAAzMAAANTAAAGgwAAAVjwAATzAAAOlTAABPMAAAgjAAAG4wAABMMAAAQjAAAIswAAACMAAA +DQAAAAoA +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "import and get email from blob $blobid"; + my $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$inboxid => JSON::true}, + }, + }, + }, "R1"], ["Email/get", { + ids => ["#1"], + properties => ['textBody', 'bodyValues', 'preview'], + fetchTextBodyValues => JSON::true, + }, "R2" ]]); + + $self->assert_num_equals(0, + index($res->[1][1]{list}[0]{bodyValues}{1}{value}, + "\N{HIRAGANA LETTER A}" . + "\N{HIRAGANA LETTER ME}" . + "\N{HIRAGANA LETTER RI}") + ); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{bodyValues}{1}{isEncodingProblem}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_encoded_message b/cassandane/tiny-tests/JMAPEmail/email_get_encoded_message new file mode 100644 index 0000000000..838d8c531e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_encoded_message @@ -0,0 +1,147 @@ +#!perl +use Cassandane::Tiny; +use MIME::Base64 qw(encode_base64); +use MIME::QuotedPrint qw(encode_qp); + +sub encode_qp_strip_crlf +{ + # encode_qp encodes CR in CRLF, so remove CR before encoding + my ($string, $eol) = @_; + (my $stripped_string = $string) =~ s/\r\n/\n/g; + return encode_qp($stripped_string, $eol); +} + +sub test_email_get_encoded_message + :needs_component_jmap :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + my $jmap = $self->{jmap}; + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/mail'); + + # Define some MIME message. Its content doesn't matter, + # but let's mix an 8bit character in the headers to + # make the content transfer encoding actually matter. + my $mime8Bit = <<'EOF'; +From: hello@example.com +To: world@example.com +Subject: töst +Date: Thu, 20 May 2004 14:28:51 +0200 +Mime-Version: 1.0 +Content-Type: text/plain; charset=utf-8 + +täst +EOF + $mime8Bit =~ s/\r?\n/\r\n/gs; + my $mime8BitSize = bytes::length($mime8Bit); + + # Define test cases. + my @tests = ({ + type => 'message/rfc822', + encoding => '8bit', + body => $mime8Bit, + }, { + type => 'message/global', + encoding => '8bit', + body => $mime8Bit, + }, { + type => 'message/rfc822', + encoding => 'base64', + body => encode_base64($mime8Bit, "\015\012"), + }, { + type => 'message/global', + encoding => 'base64', + body => encode_base64($mime8Bit, "\015\012"), + }, { + type => 'message/rfc822', + encoding => 'quoted-printable', + body => encode_qp_strip_crlf($mime8Bit, "\015\012"), + }, { + type => 'message/global', + encoding => 'quoted-printable', + body => encode_qp_strip_crlf($mime8Bit, "\015\012"), + }); + + my $res = $jmap->CallMethods([ + ['Email/get', { ids => [] }, 'R1'] + ]); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + + # Run the tests. + while (my ($i, $tc) = each @tests) { + my $imapUid = $i + 1; + my $testId = "test$imapUid"; + + xlog $self, "Testing content-type $tc->{type} and encoding $tc->{encoding}"; + + xlog $self, "Delivering test message"; + my $mime = <<"EOF"; +From: from\@local +To: to\@local +Subject: $testId +Date: Thu, 20 May 2004 14:28:51 +0200 +Content-Type: multipart/mixed; boundary=8c438cf1-a6ac-4d99-b388-b1dfd9550725=_ +Mime-Version: 1.0 + +--8c438cf1-a6ac-4d99-b388-b1dfd9550725=_ +Content-Type: text/plain; charset=utf-8 + +test + +--8c438cf1-a6ac-4d99-b388-b1dfd9550725=_ +Content-Type: $tc->{type} +Content-Transfer-Encoding: $tc->{encoding} + +$tc->{body} +--8c438cf1-a6ac-4d99-b388-b1dfd9550725=_-- +EOF + $mime =~ s/\r?\n/\r\n/gs; + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + + $res = $jmap->CallMethods([ + ['Email/changes', { + sinceState => $state + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/changes', + path => '/created', + }, + properties => ['subject', 'bodyStructure'], + bodyProperties => [ + 'blobId', + 'header:content-transfer-encoding:asText', + 'partId', + 'size', + 'type', + ], + }, 'R2'], + ]); + $self->assert_str_equals($testId, $res->[1][1]{list}[0]{subject}); + # Update Email state. + $state = $res->[0][1]{newState}; + + xlog $self, "Assert Email/get returns expected message"; + my $bodyPart = $res->[1][1]{list}[0]{bodyStructure}{subParts}[1]; + $self->assert_str_equals("2", $bodyPart->{partId}); + $self->assert_str_equals($tc->{type}, $bodyPart->{type}); + $self->assert_str_equals($tc->{encoding}, + $bodyPart->{'header:content-transfer-encoding:asText'}); + $self->assert_num_equals($mime8BitSize, $bodyPart->{size}); + + xlog $self, "Assert Blob download"; + $res = $self->download('cassandane', $bodyPart->{blobId}); + $self->assert_str_equals($mime8Bit, $res->{content}); + + # While we are at it let's check IMAP, too. + xlog $self, "Assert IMAP FETCH BINARY"; + $imap->select('INBOX'); + $res = $imap->fetch($imapUid, '(BINARY[2])'); + $self->assert_str_equals($mime8Bit, $res->{$imapUid}{binary}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_encoding_utf8 b/cassandane/tiny-tests/JMAPEmail/email_get_encoding_utf8 new file mode 100644 index 0000000000..e06e8e7b41 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_encoding_utf8 @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_encoding_utf8 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # Some clients erroneously declare encoding to be UTF-8. + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: UTF-8 + +This is a test. +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "import and get email from blob $blobid"; + my $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$inboxid => JSON::true}, + }, + }, + }, "R1"], ["Email/get", { + ids => ["#1"], + properties => ['bodyStructure', 'bodyValues'], + fetchAllBodyValues => JSON::true, + }, "R2" ]]); + + $self->assert_str_equals("Email/import", $res->[0][0]); + $self->assert_str_equals("Email/get", $res->[1][0]); + + my $msg = $res->[1][1]{list}[0]; + my $partId = $msg->{bodyStructure}{partId}; + my $bodyValue = $msg->{bodyValues}{$partId}; + $self->assert_str_equals("This is a test.\n", $bodyValue->{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_fixbrokenmessageids b/cassandane/tiny-tests/JMAPEmail/email_get_fixbrokenmessageids new file mode 100644 index 0000000000..6b662dbe25 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_fixbrokenmessageids @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_fixbrokenmessageids + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + + # See issue https://github.com/cyrusimap/cyrus-imapd/issues/2601 + + my ($self) = @_; + my $jmap = $self->{jmap}; + + # An email with a folded reference id. + my %params = ( + extra_headers => [ + ['references', "<123\r\n\t456\@lo cal>" ], + ], + ); + $self->make_message("Email A", %params) || die; + + xlog $self, "get email"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ + 'references' + ], + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $email = $res->[1][1]->{list}[0]; + + $self->assert_str_equals('123456@local', $email->{references}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_hasattachment b/cassandane/tiny-tests/JMAPEmail/email_get_hasattachment new file mode 100644 index 0000000000..7f8310beac --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_hasattachment @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_hasattachment + :min_version_3_5 :needs_component_sieve :needs_component_jmap :AltNamespace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $imap->create("matches") or die; + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "hasAttachment" : true + } +. + ) +{ + fileinto "matches"; +} +EOF + ); + + my $rawMessage = <<'EOF'; +From: from@local +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed;boundary=e523eb44-40ae-463e-9261-2f935700196d + +Content-Type: text/plain;charset=us-ascii +Content-Transfer-Encoding: 7bit + +Test + +--e523eb44-40ae-463e-9261-2f935700196d +Content-Type: image/jpeg; name=test.jpg; +Content-Disposition: inline; filename=test.jpg +Content-Transfer-Encoding: base64 + +ZGF0YQ== + +--e523eb44-40ae-463e-9261-2f935700196d +Content-Type: text/plain;charset=us-ascii +Content-Transfer-Encoding: 7bit + +Sent from my supercalifragilisticexpialidocious device + +--e523eb44-40ae-463e-9261-2f935700196d-- +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $rawMessage); + $self->{instance}->deliver($msg); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['hasAttachment'], + }, 'R2'], + ]); + + $self->assert_num_equals(1, $imap->message_count('matches')); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{hasAttachment}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_header_all b/cassandane/tiny-tests/JMAPEmail/email_get_header_all new file mode 100644 index 0000000000..8db1aa7fe6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_header_all @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_header_all + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + my %exp_inbox; + my %params = ( + extra_headers => [ + ['x-tra', "foo"], + ['x-tra', "bar"], + ], + body => "hello", + ); + $self->make_message("Email A", %params) || die; + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids, properties => ['header:x-tra:all', 'header:x-tra:asRaw:all'] }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + $self->assert_deep_equals([' foo', ' bar'], $msg->{'header:x-tra:all'}); + $self->assert_deep_equals([' foo', ' bar'], $msg->{'header:x-tra:asRaw:all'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_header_last_value b/cassandane/tiny-tests/JMAPEmail/email_get_header_last_value new file mode 100644 index 0000000000..23b47bf743 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_header_last_value @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_header_last_value + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + $self->make_message("msg", extra_headers => [ + ['x-tra', 'Fri, 21 Nov 1997 09:55:06 -0600'], + ['x-tra', 'Thu, 22 Aug 2019 23:12:06 -0600'], + ]) || die; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['header:x-tra:asDate'] + }, 'R2'], + ]); + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min >= 4)) { + $self->assert_str_equals('2019-08-22T23:12:06-06:00', + $res->[1][1]{list}[0]{'header:x-tra:asDate'}); + } else { + $self->assert_str_equals('2019-08-23T05:12:06Z', + $res->[1][1]{list}[0]{'header:x-tra:asDate'}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_header_nfc b/cassandane/tiny-tests/JMAPEmail/email_get_header_nfc new file mode 100644 index 0000000000..93786d4f98 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_header_nfc @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; +use MIME::Base64 qw(encode_base64); +use MIME::QuotedPrint qw(encode_qp); + +sub test_email_get_header_nfc + :needs_component_jmap :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + my $jmap = $self->{jmap}; + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/mail'); + + my $nonNfcEmailAddress = + "\N{U+1F71}\N{U+1F73}\N{U+1F75}" . '@' . + "\N{U+1F77}\N{U+1F79}\N{U+1F7B}.local"; + my $normalizedEmailAddress = + "\N{U+03AC}\N{U+03AD}\N{U+03AE}" . '@' . + "\N{U+03AF}\N{U+03CC}\N{U+03CD}.local"; + my $normalizedEmailAddressEncoded = + "\N{U+03AC}\N{U+03AD}\N{U+03AE}" . '@' . + "xn--kxa2dd.local"; + + my $nonNfcXHeaderValue = "0.5\N{U+212B}"; + my $normalizedXHeaderValue = "0.5\N{U+00C5}"; + + my $mime = <<"EOF"; +From: from\@local +To: $nonNfcEmailAddress +Subject: test +Date: Mon, 27 May 2024 02:31:37 -0400 +Content-Type: text/plain;charset=utf-8 +X-My-Header: $nonNfcXHeaderValue +Mime-Version: 1.0 + +test +EOF + $mime =~ s/\r?\n/\r\n/gs; + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + properties => [ + 'to', + 'header:to', + 'header:x-my-header:asText', + 'header:x-my-header', + ], + }, 'R2'], + ]); + $self->assert_str_equals($normalizedEmailAddressEncoded, + $res->[1][1]{list}[0]{to}[0]{email}); + $self->assert_str_equals(" $nonNfcEmailAddress", + $res->[1][1]{list}[0]{'header:to'}); + $self->assert_str_equals($normalizedXHeaderValue, + $res->[1][1]{list}[0]{'header:x-my-header:asText'}); + $self->assert_str_equals(" $nonNfcXHeaderValue", + $res->[1][1]{list}[0]{'header:x-my-header'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_headers_multipart b/cassandane/tiny-tests/JMAPEmail/email_get_headers_multipart new file mode 100644 index 0000000000..35057d1cc1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_headers_multipart @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_headers_multipart + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $htmlBody = "

This is the html part.

"; + my $textBody = "This is the plain text part."; + + my $body = "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $body .= "\r\n"; + $body .= $textBody; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/html;charset=\"UTF-8\"\r\n"; + $body .= "\r\n"; + $body .= $htmlBody; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348--\r\n"; + $exp_sub{A} = $self->make_message("foo", + mime_type => "multipart/alternative", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body, + extra_headers => [['X-Spam-Hits', 'SPAMA, SPAMB, SPAMC']], + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { + ids => $ids, + properties => [ "header:x-spam-hits:asRaw", "header:x-spam-hits:asText" ], + }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + $self->assert_str_equals(' SPAMA, SPAMB, SPAMC', $msg->{"header:x-spam-hits:asRaw"}); + $self->assert_str_equals('SPAMA, SPAMB, SPAMC', $msg->{"header:x-spam-hits:asText"}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_imagesize b/cassandane/tiny-tests/JMAPEmail/email_get_imagesize new file mode 100644 index 0000000000..2093cc74e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_imagesize @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_imagesize + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + # This is a FastMail-extension + + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + $store->set_folder('INBOX'); + + # Part 1 has no imagesize defined, part 2 defines no EXIF + # orientation, part 3 defines all image size properties. + my $imageSize = { + '2' => [1,2], + '3' => [1,2,3], + }; + + # Generate an email with image MIME parts. + xlog $self, "Generate an email via IMAP"; + my $msg = $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: image/png\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" + . "\r\n--sub\r\n" + . "Content-Type: image/png\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" + . "\r\n--sub\r\n" + . "Content-Type: image/png\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" + . "\r\n--sub--\r\n", + ); + xlog $self, "set imagesize annotation"; + my $annot = '/vendor/messagingengine.com/imagesize'; + my $ret = $talk->store('1', 'annotation', [ + $annot, ['value.shared', { Quote => encode_json($imageSize) }] + ]); + if (not $ret) { + xlog $self, "Could not set $annot annotation. Aborting."; + return; + } + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { + ids => $ids, + properties => ['bodyStructure'], + bodyProperties => ['partId', 'imageSize' ], + }, "R1"]]); + my $email = $res->[0][1]{list}[0]; + + my $part = $email->{bodyStructure}{subParts}[0]; + $self->assert_str_equals('1', $part->{partId}); + $self->assert_null($part->{imageSize}); + + $part = $email->{bodyStructure}{subParts}[1]; + $self->assert_str_equals('2', $part->{partId}); + $self->assert_deep_equals($imageSize->{2}, $part->{imageSize}); + + $part = $email->{bodyStructure}{subParts}[2]; + $self->assert_str_equals('3', $part->{partId}); + $self->assert_deep_equals($imageSize->{3}, $part->{imageSize}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_isdeleted b/cassandane/tiny-tests/JMAPEmail/email_get_isdeleted new file mode 100644 index 0000000000..04ec4e1151 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_isdeleted @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_isdeleted + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + # This is a FastMail-extension + + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + $store->set_folder('INBOX'); + + my $msg = $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: text/x-me-removed-file\r\n" + . "\r\n" + . "deleted" + . "\r\n--sub--\r\n", + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { + ids => $ids, + properties => ['bodyStructure'], + bodyProperties => ['partId', 'isDeleted' ], + }, "R1"]]); + my $email = $res->[0][1]{list}[0]; + + my $part = $email->{bodyStructure}{subParts}[0]; + $self->assert_str_equals('1', $part->{partId}); + $self->assert_equals(JSON::false, $part->{isDeleted}); + + $part = $email->{bodyStructure}{subParts}[1]; + $self->assert_str_equals('2', $part->{partId}); + $self->assert_equals(JSON::true, $part->{isDeleted}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_iso2022jp_body b/cassandane/tiny-tests/JMAPEmail/email_get_iso2022jp_body new file mode 100644 index 0000000000..599c59e9bf --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_iso2022jp_body @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_iso2022jp_body + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + open(my $F, 'data/mime/iso-2022-jp.eml') || die $!; + $imap->append('INBOX', $F) || die $@; + close($F); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['bodyValues', 'preview'], + fetchAllBodyValues => JSON::true, + }, 'R2'], + ]); + +use utf8; + $self->assert_str_equals("シニアソフトウェアエンジニア\n", + $res->[1][1]{list}[0]{bodyValues}{1}{value}); + $self->assert_str_equals("シニアソフトウェアエンジニア ", + $res->[1][1]{list}[0]{preview}); +no utf8; +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_keywords b/cassandane/tiny-tests/JMAPEmail/email_get_keywords new file mode 100644 index 0000000000..12577c43d8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_keywords @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_keywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Create IMAP mailbox and message A"; + $talk->create('INBOX.A') || die; + $store->set_folder('INBOX.A'); + $self->make_message('A') || die; + + xlog $self, "Create IMAP mailbox B and copy message A to B"; + $talk->create('INBOX.B') || die; + $talk->copy('1:*', 'INBOX.B'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids'} + }, 'R2' ] + ]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + my $jmapmsg = $res->[1][1]{list}[0]; + $self->assert_not_null($jmapmsg); + + # Keywords are empty by default + my $keywords = {}; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); + + xlog $self, "Set \\Seen on message A"; + $store->set_folder('INBOX.A'); + $talk->store('1', '+flags', '(\\Seen)'); + + # Seen must only be set if ALL messages are seen. + $res = $jmap->CallMethods([ + ['Email/get', { 'ids' => [ $jmapmsg->{id} ] }, 'R2' ] + ]); + $jmapmsg = $res->[0][1]{list}[0]; + $keywords = {}; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); + + xlog $self, "Set \\Seen on message B"; + $store->set_folder('INBOX.B'); + $store->_select(); + $talk->store('1', '+flags', '(\\Seen)'); + + # Seen must only be set if ALL messages are seen. + $res = $jmap->CallMethods([ + ['Email/get', { 'ids' => [ $jmapmsg->{id} ] }, 'R2' ] + ]); + $jmapmsg = $res->[0][1]{list}[0]; + $keywords = { + '$seen' => JSON::true, + }; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); + + xlog $self, "Set \\Flagged on message B"; + $store->set_folder('INBOX.B'); + $store->_select(); + $talk->store('1', '+flags', '(\\Flagged)'); + + # Any other keyword is set if set on any IMAP message of this email. + $res = $jmap->CallMethods([ + ['Email/get', { 'ids' => [ $jmapmsg->{id} ] }, 'R2' ] + ]); + $jmapmsg = $res->[0][1]{list}[0]; + $keywords = { + '$seen' => JSON::true, + '$flagged' => JSON::true, + }; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_keywords_case_insensitive b/cassandane/tiny-tests/JMAPEmail/email_get_keywords_case_insensitive new file mode 100644 index 0000000000..bbef9070cb --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_keywords_case_insensitive @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_keywords_case_insensitive + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Create IMAP mailbox and message A"; + $talk->create('INBOX.A') || die; + $store->set_folder('INBOX.A'); + $self->make_message('A') || die; + + xlog $self, "Set flag Foo and Flagged on message A"; + $store->set_folder('INBOX.A'); + $talk->store('1', '+flags', '(Foo \\Flagged)'); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids'}, + properties => ['keywords'], + }, 'R2' ] + ]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + my $jmapmsg = $res->[1][1]{list}[0]; + my $keywords = { + 'foo' => JSON::true, + '$flagged' => JSON::true, + }; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_maxbodyvaluebytes_utf8 b/cassandane/tiny-tests/JMAPEmail/email_get_maxbodyvaluebytes_utf8 new file mode 100644 index 0000000000..57367a971e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_maxbodyvaluebytes_utf8 @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_maxbodyvaluebytes_utf8 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + # A body containing a three-byte, two-byte and one-byte UTF-8 char + my $body = "\N{EURO SIGN}\N{CENT SIGN}\N{DOLLAR SIGN}"; + my @wantbodies = ( + [1, ""], + [2, ""], + [3, "\N{EURO SIGN}"], + [4, "\N{EURO SIGN}"], + [5, "\N{EURO SIGN}\N{CENT SIGN}"], + [6, "\N{EURO SIGN}\N{CENT SIGN}\N{DOLLAR SIGN}"], + ); + + utf8::encode($body); + my %params = ( + mime_charset => "utf-8", + body => $body + ); + $self->make_message("1", %params) || die; + + xlog $self, "get email id"; + my $res = $jmap->CallMethods([['Email/query', {}, 'R1']]); + my $id = $res->[0][1]->{ids}[0]; + + for my $tc ( @wantbodies ) { + my $nbytes = $tc->[0]; + my $wantbody = $tc->[1]; + + xlog $self, "get email"; + my $res = $jmap->CallMethods([ + ['Email/get', { + ids => [ $id ], + properties => [ 'bodyValues' ], + fetchAllBodyValues => JSON::true, + maxBodyValueBytes => $nbytes + 0, + }, "R1"], + ]); + my $msg = $res->[0][1]->{list}[0]; + $self->assert_str_equals($wantbody, $msg->{bodyValues}{'1'}{value}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_mimeencode b/cassandane/tiny-tests/JMAPEmail/email_get_mimeencode new file mode 100644 index 0000000000..43a398b151 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_mimeencode @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_mimeencode + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + my $body = "a body"; + + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => -10)); + + # Thanks to http://dogmamix.com/MimeHeadersDecoder/ for examples + + xlog $self, "Generate an email in INBOX via IMAP"; + my %exp_inbox; + my %params = ( + date => $maildate, + from => Cassandane::Address->new( + name => "=?ISO-8859-1?Q?Keld_J=F8rn_Simonsen?=", + localpart => "keld", + domain => "local" + ), + to => Cassandane::Address->new( + name => "=?US-ASCII?Q?Tom To?=", + localpart => 'tom', + domain => 'local' + ), + messageid => 'fake.123456789@local', + extra_headers => [ + ['x-tra', "foo bar\r\n baz"], + ['sender', "Bla "], + ['x-mood', '=?UTF-8?Q?I feel =E2=98=BA?='], + ], + body => $body + ); + + $self->make_message( + "=?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?= " . + "=?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=", + %params ) || die; + + xlog $self, "get email list"; + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'subject', 'header:x-mood:asText', 'from', 'to' ], + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $msg = $res->[1][1]->{list}[0]; + + $self->assert_str_equals("If you can read this you understand the example.", $msg->{subject}); + $self->assert_str_equals("I feel \N{WHITE SMILING FACE}", $msg->{'header:x-mood:asText'}); + $self->assert_str_equals("Keld J\N{LATIN SMALL LETTER O WITH STROKE}rn Simonsen", $msg->{from}[0]{name}); + $self->assert_str_equals("Tom To", $msg->{to}[0]{name}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_multimailboxes b/cassandane/tiny-tests/JMAPEmail/email_get_multimailboxes new file mode 100644 index 0000000000..3a508d5e91 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_multimailboxes @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_multimailboxes + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $now = DateTime->now(); + + xlog $self, "Generate an email in INBOX via IMAP"; + my $res = $self->make_message("foo") || die; + my $uid = $res->{attrs}->{uid}; + my $msg; + + xlog $self, "get email"; + $res = $jmap->CallMethods([ + ['Email/query', {}, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2'], + ]); + $msg = $res->[1][1]{list}[0]; + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + + xlog $self, "Create target mailbox"; + $talk->create("INBOX.target"); + + xlog $self, "Copy email into INBOX.target"; + $talk->copy($uid, "INBOX.target"); + + xlog $self, "get email"; + $res = $jmap->CallMethods([ + ['Email/query', {}, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2'], + ]); + $msg = $res->[1][1]{list}[0]; + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(2, scalar keys %{$msg->{mailboxIds}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_multimailboxes_expunged b/cassandane/tiny-tests/JMAPEmail/email_get_multimailboxes_expunged new file mode 100644 index 0000000000..66c5a668ad --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_multimailboxes_expunged @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_multimailboxes_expunged + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :DelayedExpunge +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $now = DateTime->now(); + + xlog $self, "Generate an email in INBOX via IMAP"; + my $res = $self->make_message("foo") || die; + my $uid = $res->{attrs}->{uid}; + my $msg; + + xlog $self, "get email"; + $res = $jmap->CallMethods([ + ['Email/query', {}, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2'], + ]); + $msg = $res->[1][1]{list}[0]; + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + + xlog $self, "Create target mailbox"; + $talk->create("INBOX.target"); + + xlog $self, "Copy email into INBOX.target"; + $talk->copy($uid, "INBOX.target"); + + xlog $self, "get email"; + $res = $jmap->CallMethods([ + ['Email/query', {}, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2'], + ]); + $msg = $res->[1][1]{list}[0]; + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(2, scalar keys %{$msg->{mailboxIds}}); + my $val = join(',', sort keys %{$msg->{mailboxIds}}); + + xlog $self, "Move the message to target2"; + $talk->create("INBOX.target2"); + $talk->copy($uid, "INBOX.target2"); + + xlog $self, "and move it back again!"; + $talk->select("INBOX.target2"); + $talk->move("1:*", "INBOX"); + + # and finally delete the SECOND copy by UID sorting + xlog $self, "and delete one of them"; + $talk->select("INBOX"); + $talk->store('2', "+flags", "\\Deleted"); + $talk->expunge(); + + xlog $self, "check that email is still in both mailboxes"; + $res = $jmap->CallMethods([ + ['Email/query', {}, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2'], + ]); + $msg = $res->[1][1]{list}[0]; + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(2, scalar keys %{$msg->{mailboxIds}}); + $self->assert_str_equals($val, join(',', sort keys %{$msg->{mailboxIds}})); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_preview b/cassandane/tiny-tests/JMAPEmail/email_get_preview new file mode 100644 index 0000000000..992075b162 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_preview @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_preview + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email in $inbox via IMAP"; + my %exp_sub; + $store->set_folder($inbox); + $store->_select(); + $self->{gen}->set_next_uid(1); + + my $body = "A plain\r\ntext email."; + $exp_sub{A} = $self->make_message("foo", + body => $body + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + + xlog $self, "get emails"; + $res = $jmap->CallMethods([['Email/get', { ids => $res->[0][1]->{ids} }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + $self->assert_str_equals('A plain text email.', $msg->{preview}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_preview_html b/cassandane/tiny-tests/JMAPEmail/email_get_preview_html new file mode 100644 index 0000000000..7c4e0b29c7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_preview_html @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_preview_html + :min_version_3_7 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + $self->make_message('test', mime_type => 'text/html', + body => 'hello

world

!'); + + my $res = $jmap->CallMethods([ + ['Email/query', {}, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['preview'], + }, 'R2'] + ]); + + $self->assert_str_equals("hello world !", + $res->[1][1]{list}[0]->{preview}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_previous_calendarevent b/cassandane/tiny-tests/JMAPEmail/email_get_previous_calendarevent new file mode 100644 index 0000000000..a6db270911 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_previous_calendarevent @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_previous_calendarevent + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $instance = $self->{instance}; + + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $rawMessage = <<'EOF'; +From: from@local +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/related; + boundary=c4683f7a320d4d20902b000486fbdf9b +X-ME-Cal-Method: request +X-ME-Cal-UID: 40d6fe3c-6a51-489e-823e-3ea22f427a3e +X-ME-Cal-Exists: DD2213E6-DEF3-11EB-934C-60C33F81E4B9 +X-ME-Cal-Previous: ewogICAgIkB0eXBlIjogImpzZXZlbnQiLAogICAgInN0YXJ0IjogIjIwMTYtMDktMjhUMTY6MDA6 + MDAiLAogICAgInRpbWVab25lIjogIkV1cm9wZS9WaWVubmEiLAogICAgImR1cmF0aW9uIjogIlBU + MUgiLAogICAgInNob3dXaXRob3V0VGltZSI6IGZhbHNlLAogICAgInVpZCI6ICI0MGQ2ZmUzYy02 + YTUxLTQ4OWUtODIzZS0zZWEyMmY0MjdhM2UiLAogICAgInJlbGF0ZWRUbyI6IG51bGwsCiAgICAi + cHJvZElkIjogIi0vL0FwcGxlIEluYy4vL01hYyBPUyBYIDEwLjkuNS8vRU4iLAogICAgImNyZWF0 + ZWQiOiAiMjAxNS0wOS0yOFQxMjo1MjoxMloiLAogICAgInVwZGF0ZWQiOiAiMjAxNS0wOS0yOFQx + MzoyNDozNFoiLAogICAgInNlcXVlbmNlIjogMCwKICAgICJwcmlvcml0eSI6IDAsCiAgICAidGl0 + bGUiOiAidGVzdCIsCiAgICAiZGVzY3JpcHRpb25Db250ZW50VHlwZSI6ICJ0ZXh0L3BsYWluIiwK + ICAgICJrZXl3b3JkcyI6IG51bGwsCiAgICAibGlua3MiOiBudWxsLAogICAgImxvY2FsZSI6IG51 + bGwsCiAgICAibG9jYXRpb25zIjogbnVsbCwKICAgICJ2aXJ0dWFsTG9jYXRpb25zIjogbnVsbCwK + ICAgICJyZWN1cnJlbmNlUnVsZSI6IG51bGwsCiAgICAic3RhdHVzIjogImNvbmZpcm1lZCIsCiAg + ICAiZnJlZUJ1c3lTdGF0dXMiOiAiYnVzeSIsCiAgICAicHJpdmFjeSI6ICJwdWJsaWMiLAogICAg + InBhcnRpY2lwYW50cyI6IG51bGwsCiAgICAidXNlRGVmYXVsdEFsZXJ0cyI6IGZhbHNlLAogICAg + ImFsZXJ0cyI6IG51bGwsCiAgICAicmVjdXJyZW5jZU92ZXJyaWRlcyI6IG51bGwKfQo= + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/plain + +test + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/calendar;charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;TZID=Europe/Vienna:20160928T160000 +DTEND;TZID=Europe/Vienna:20160928T170000 +UID:40d6fe3c-6a51-489e-823e-3ea22f427a3e +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +DESCRIPTION: +SUMMARY:updatedTest +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR + + +--c4683f7a320d4d20902b000486fbdf9b-- +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + properties => ['calendarEvents', 'previousCalendarEvent'], + }, 'R2'], + ], $using); + + $self->assert_str_equals('test', + $res->[1][1]{list}[0]{previousCalendarEvent}{title}); + $self->assert_str_equals('updatedTest', + $res->[1][1]{list}[0]{calendarEvents}{2}[0]{title}); + + $self->assert_str_equals('40d6fe3c-6a51-489e-823e-3ea22f427a3e', + $res->[1][1]{list}[0]{previousCalendarEvent}{uid}); + $self->assert_str_equals('40d6fe3c-6a51-489e-823e-3ea22f427a3e', + $res->[1][1]{list}[0]{calendarEvents}{2}[0]{uid}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_references b/cassandane/tiny-tests/JMAPEmail/email_get_references new file mode 100644 index 0000000000..2b2f7174dd --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_references @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_references + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $rawReferences = ', '; + my $parsedReferences = [ 'bar', 'baz' ]; + + $self->make_message("foo", + mime_type => 'text/plain', + extra_headers => [ + ['References', $rawReferences], + ], + body => 'foo', + ) || die; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['references', 'header:references', 'header:references:asMessageIds'], + }, 'R2' ], + ]); + my $msg = $res->[1][1]{list}[0]; + $self->assert_str_equals(' ' . $rawReferences, $msg->{'header:references'}); + $self->assert_deep_equals($parsedReferences, $msg->{'header:references:asMessageIds'}); + $self->assert_deep_equals($parsedReferences, $msg->{references}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_shared b/cassandane/tiny-tests/JMAPEmail/email_get_shared new file mode 100644 index 0000000000..7f36206a80 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_shared @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $admintalk = $self->{adminstore}->get_client(); + + # Share account + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lr") or die; + + # Create mailbox A + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lr") or die; + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + $self->make_message("Email", store => $self->{adminstore}) or die; + + # Copy message to unshared mailbox B + $admintalk->create("user.other.B") or die; + $admintalk->setacl("user.other.B", "cassandane", "") or die; + $admintalk->copy(1, "user.other.B"); + + my @fetchEmailMethods = [ + ['Email/query', { + accountId => 'other', + collapseThreads => JSON::true, + }, "R1"], + ['Email/get', { + accountId => 'other', + properties => ['mailboxIds'], + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]; + + # Fetch Email + my $res = $jmap->CallMethods(@fetchEmailMethods); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_num_equals(1, scalar keys %{$res->[1][1]{list}[0]{mailboxIds}}); + my $emailId = $res->[1][1]{list}[0]{id}; + + # Share mailbox B + $admintalk->setacl("user.other.B", "cassandane", "lr") or die; + $res = $jmap->CallMethods(@fetchEmailMethods); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_num_equals(2, scalar keys %{$res->[1][1]{list}[0]{mailboxIds}}); + + # Unshare mailboxes A and B + $admintalk->setacl("user.other.A", "cassandane", "") or die; + $admintalk->setacl("user.other.B", "cassandane", "") or die; + $res = $jmap->CallMethods([['Email/get', { + accountId => 'other', + ids => [$emailId], + }, 'R1']]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals($emailId, $res->[0][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_size b/cassandane/tiny-tests/JMAPEmail/email_get_size new file mode 100644 index 0000000000..7ae051a0bc --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_size @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_size + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + $self->make_message("foo", + mime_type => 'text/plain; charset="UTF-8"', + mime_encoding => 'quoted-printable', + body => '=C2=A1Hola, se=C3=B1or!', + ) || die; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['bodyStructure', 'size'], + }, 'R2' ], + ]); + + my $msg = $res->[1][1]{list}[0]; + $self->assert_num_equals(15, $msg->{bodyStructure}{size}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_trustedsender b/cassandane/tiny-tests/JMAPEmail/email_get_trustedsender new file mode 100644 index 0000000000..6a78c5f7ef --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_trustedsender @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_trustedsender + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + # This is a FastMail-extension + + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + $store->set_folder('INBOX'); + + my $msg = $self->make_message("foo"); + + xlog $self, "Assert trustedSender isn't set"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => [ 'id', 'trustedSender', 'keywords' ], + }, 'R2'], + ]); + my $emailId = $res->[0][1]{ids}[0]; + my $email = $res->[1][1]{list}[0]; + $self->assert_null($email->{trustedSender}); + + xlog $self, "Set IsTrusted flag"; + $talk->store('1', '+flags', '($IsTrusted)'); + + xlog $self, "Assert trustedSender isn't set"; + $res = $jmap->CallMethods([['Email/get', { + ids => [$emailId], properties => [ 'id', 'trustedSender', 'keywords' ], + }, 'R1']]); + $email = $res->[0][1]{list}[0]; + $self->assert_null($email->{trustedSender}); + + xlog $self, "Set zero-length trusted annotation"; + my $annot = '/vendor/messagingengine.com/trusted'; + my $ret = $talk->store('1', 'annotation', [ + $annot, ['value.shared', { Quote => '' }] + ]); + if (not $ret) { + xlog $self, "Could not set $annot annotation. Aborting."; + return; + } + + xlog $self, "Assert trustedSender isn't set"; + $res = $jmap->CallMethods([['Email/get', { + ids => [$emailId], properties => [ 'id', 'trustedSender', 'keywords' ], + }, 'R1']]); + $email = $res->[0][1]{list}[0]; + $self->assert_null($email->{trustedSender}); + + xlog $self, "Set trusted annotation"; + $ret = $talk->store('1', 'annotation', [ + $annot, ['value.shared', { Quote => 'bar' }] + ]); + if (not $ret) { + xlog $self, "Could not set $annot annotation. Aborting."; + return; + } + + xlog $self, "Assert trustedSender is set"; + $res = $jmap->CallMethods([['Email/get', { + ids => [$emailId], properties => [ 'id', 'trustedSender', 'keywords' ], + }, 'R1']]); + $email = $res->[0][1]{list}[0]; + $self->assert_str_equals('bar', $email->{trustedSender}); + + xlog $self, "Remove IsTrusted flag"; + $talk->store('1', '-flags', '($IsTrusted)'); + + xlog $self, "Assert trustedSender isn't set"; + $res = $jmap->CallMethods([['Email/get', { + ids => [$emailId], properties => [ 'id', 'trustedSender', 'keywords' ], + }, 'R1']]); + $email = $res->[0][1]{list}[0]; + $self->assert_null($email->{trustedSender}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_utf8_domain b/cassandane/tiny-tests/JMAPEmail/email_get_utf8_domain new file mode 100644 index 0000000000..ebc2a923e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_utf8_domain @@ -0,0 +1,38 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_utf8_domain + :min_version_3_9 :needs_component_jmap :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + open(my $F, "data/mime/utf8-domain.bin") || die $!; + $imap->append('INBOX', $F) || die $@; + close($F); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['from', 'subject'], + }, 'R2'], + ]); + +use utf8; + $self->assert_deep_equals([{ + name => 'J. Besteiro', + email => 'jb@xn--julin-0qa.example.com', + }], $res->[1][1]{list}[0]{from}); +no utf8; + + $imap->select('INBOX'); + $res = $imap->fetch('1:*', 'ENVELOPE'); + $self->assert_str_equals('"J. Besteiro" ', + $res->{1}{envelope}{From}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_get_utf8body_base64_with_replacement_char b/cassandane/tiny-tests/JMAPEmail/email_get_utf8body_base64_with_replacement_char new file mode 100644 index 0000000000..a60ba74ee1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_get_utf8body_base64_with_replacement_char @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_get_utf8body_base64_with_replacement_char + :min_version_3_5 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + # MIME message contains a correctly encoded emoji and one UTF-8 + # replacement character. The latter must not cause Cyrus to + # attempt guessing the source charset or report an encoding error. + open(my $F, '<', 'data/mime/utf8-base64-replacement.eml') || die $!; + $imap->append('INBOX', $F) || die $@; + close($F); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + properties => ['bodyValues'], + }, 'R2'], + ]); + $self->assert_equals(JSON::false, + $res->[1][1]{list}[0]{bodyValues}{1}{isEncodingProblem}); + $self->assert_str_equals("Hello \N{GRINNING FACE}, World \N{REPLACEMENT CHARACTER} !\n", + $res->[1][1]{list}[0]{bodyValues}{1}{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import b/cassandane/tiny-tests/JMAPEmail/email_import new file mode 100644 index 0000000000..01cbf2c364 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $inbox = $self->getinbox()->{id}; + $self->assert_not_null($inbox); + + # Generate an embedded email to get a blob id + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Disposition: inline\r\n" . "\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "An embedded email" + . "\r\n--sub--\r\n", + ) || die; + + xlog $self, "get blobId"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['attachments'], + }, 'R2' ], + ]); + my $blobid = $res->[1][1]->{list}[0]->{attachments}[0]{blobId}; + $self->assert_not_null($blobid); + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + my $drafts = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($drafts); + + xlog $self, "import and get email from blob $blobid"; + $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$drafts => JSON::true}, + keywords => { '$draft' => JSON::true }, + }, + }, + }, "R1"], ["Email/get", { ids => ["#1"] }, "R2" ]]); + + $self->assert_str_equals("Email/import", $res->[0][0]); + my $msg = $res->[0][1]->{created}{"1"}; + $self->assert_not_null($msg); + + $self->assert_str_equals("Email/get", $res->[1][0]); + $self->assert_str_equals($msg->{id}, $res->[1][1]{list}[0]->{id}); + + xlog $self, "load email"; + $res = $jmap->CallMethods([['Email/get', { ids => [$msg->{id}] }, "R1"]]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{list}[0]->{mailboxIds}}); + $self->assert_not_null($res->[0][1]{list}[0]->{mailboxIds}{$drafts}); + + xlog $self, "import existing email (expect email exists error)"; + $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$drafts => JSON::true, $inbox => JSON::true}, + keywords => { '$draft' => JSON::true }, + }, + }, + }, "R1"]]); + $self->assert_str_equals("Email/import", $res->[0][0]); + $self->assert_str_equals("alreadyExists", $res->[0][1]->{notCreated}{"1"}{type}); + $self->assert_not_null($res->[0][1]->{notCreated}{"1"}{existingId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_encoded_contenttype b/cassandane/tiny-tests/JMAPEmail/email_import_encoded_contenttype new file mode 100644 index 0000000000..fef50a9634 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_encoded_contenttype @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_encoded_contenttype + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + # Very old macOS Mail.app versions encode the complete + # Content-Type header value, when they really should + # just encode its file name parameter value. + # See: https://github.com/cyrusimap/cyrus-imapd/issues/2622 + + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $email = <<'EOF'; +From: example@example.com +To: example@example.biz +Subject: This is a test +Message-Id: <15288246899.CBDb71cE.3455@cyrus-dev> +Date: Tue, 12 Jun 2018 13:31:29 -0400 +MIME-Version: 1.0 +Content-Type: multipart/mixed;boundary=123456789 + +--123456789 +Content-Type: text/html + +This is a mixed message. + +--123456789 +Content-Type: =?utf-8?B?aW1hZ2UvcG5nOyBuYW1lPSJr?= + =?utf-8?B?w6RmZXIucG5nIg==?= + +data + +--123456789-- +EOF + $email =~ s/\r?\n/\r\n/gs; + my $blobId = $jmap->Upload($email, "message/rfc822")->{blobId}; + + my $inboxId = $self->getinbox()->{id}; + + my $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobId, + mailboxIds => {$inboxId => JSON::true}, + }, + }, + }, "R1"], ["Email/get", { ids => ["#1", "#2"], properties => ['bodyStructure'] }, "R2" ]]); + + my $msg = $res->[1][1]{list}[0]; + $self->assert_equals('image/png', $msg->{bodyStructure}{subParts}[1]{type}); + $self->assert_equals("k\N{LATIN SMALL LETTER A WITH DIAERESIS}fer.png", $msg->{bodyStructure}{subParts}[1]{name}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_error b/cassandane/tiny-tests/JMAPEmail/email_import_error new file mode 100644 index 0000000000..d4435cde64 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_error @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_error + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $inboxid = $self->getinbox()->{id}; + + my $res = $jmap->CallMethods([['Email/import', { emails => "nope" }, 'R1' ]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); + $self->assert_str_equals('emails', $res->[0][1]{arguments}[0]); + + $res = $jmap->CallMethods([['Email/import', { emails => { 1 => "nope" }}, 'R1' ]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); + $self->assert_str_equals('emails/1', $res->[0][1]{arguments}[0]); + + $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => "nope", + mailboxIds => {$inboxid => JSON::true}, + }, + }, + }, "R1"]]); + + $self->assert_str_equals('Email/import', $res->[0][0]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notCreated}{1}{type}); + $self->assert_str_equals('blobId', $res->[0][1]{notCreated}{1}{properties}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_has_attachment b/cassandane/tiny-tests/JMAPEmail/email_import_has_attachment new file mode 100644 index 0000000000..5792a81cb5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_has_attachment @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_has_attachment + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $emailSimple = <<'EOF'; +From: example@example.com +To: example@example.biz +Subject: This is a test +Message-Id: <15288246899.CBDb71cE.3455@cyrus-dev> +Date: Tue, 12 Jun 2018 13:31:29 -0400 +MIME-Version: 1.0 + +This is a very simple message. +EOF + $emailSimple =~ s/\r?\n/\r\n/gs; + my $blobIdSimple = $jmap->Upload($emailSimple, "message/rfc822")->{blobId}; + + my $emailMixed = <<'EOF'; +From: example@example.com +To: example@example.biz +Subject: This is a test +Message-Id: <15288246899.CBDb71cE.3455@cyrus-dev> +Date: Tue, 12 Jun 2018 13:31:29 -0400 +MIME-Version: 1.0 +Content-Type: multipart/mixed;boundary=123456789 + +--123456789 +Content-Type: text/plain + +This is a mixed message. + +--123456789 +Content-Type: application/data +Content-Disposition: attachment + +data + +--123456789-- +EOF + $emailMixed =~ s/\r?\n/\r\n/gs; + my $blobIdMixed = $jmap->Upload($emailMixed, "message/rfc822")->{blobId}; + + my $inboxId = $self->getinbox()->{id}; + + my $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobIdSimple, + mailboxIds => {$inboxId => JSON::true}, + }, + "2" => { + blobId => $blobIdMixed, + mailboxIds => {$inboxId => JSON::true}, + }, + }, + }, "R1"], ["Email/get", { ids => ["#1", "#2"] }, "R2" ]]); + + my $msgSimple = $res->[1][1]{list}[0]; + $self->assert_equals(JSON::false, $msgSimple->{hasAttachment}); + my $msgMixed = $res->[1][1]{list}[1]; + $self->assert_equals(JSON::true, $msgMixed->{hasAttachment}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_issue2918 b/cassandane/tiny-tests/JMAPEmail/email_import_issue2918 new file mode 100644 index 0000000000..fc8999a454 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_issue2918 @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_issue2918 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $binary = slurp_file(abs_path('data/mime/issue2918.eml')); + my $data = $jmap->Upload($binary, "message/rfc822"); + my $blobId = $data->{blobId}; + + # Not crashing here is enough. + + my $res = $jmap->CallMethods([ + ['Email/import', { + emails => { + "1" => { + blobId => $blobId, + mailboxIds => { + '$inbox' => JSON::true}, + }, + }, + }, "R1"] + ]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_issue3122 b/cassandane/tiny-tests/JMAPEmail/email_import_issue3122 new file mode 100644 index 0000000000..3e16f9326b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_issue3122 @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_issue3122 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $binary = slurp_file(abs_path('data/mime/msg1.eml')); + my $data = $jmap->Upload($binary, "message/rfc822"); + my $blobId = $data->{blobId}; + + # Not crashing here is enough. + + my $res = $jmap->CallMethods([ + ['Email/import', { + emails => { + "1" => { + blobId => $blobId, + mailboxIds => { + '$inbox' => JSON::true}, + }, + }, + }, "R1"] + ]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_mailboxid_by_role b/cassandane/tiny-tests/JMAPEmail/email_import_mailboxid_by_role new file mode 100644 index 0000000000..9ee7012099 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_mailboxid_by_role @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_mailboxid_by_role + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + my $draftsMboxId = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftsMboxId); + + xlog $self, "import email from blob $blobid"; + $res = eval { + $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => { + '$drafts'=> JSON::true + }, + keywords => { + '$draft' => JSON::true, + }, + }, + }, + }, "R1"], ['Email/get', {ids => ["#1"]}, "R2"]]); + }; + + $self->assert_str_equals("Email/import", $res->[0][0]); + $self->assert_not_null($res->[1][1]{list}[0]->{mailboxIds}{$draftsMboxId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_no_keywords b/cassandane/tiny-tests/JMAPEmail/email_import_no_keywords new file mode 100644 index 0000000000..c2a4bd485c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_no_keywords @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_no_keywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + + my $mboxid = $self->getinbox()->{id}; + + my $req = ['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$mboxid => JSON::true}, + }, + }, + }, "R1" + ]; + xlog $self, "import email from blob $blobid"; + my $res = eval { $jmap->CallMethods([$req]) }; + $self->assert(exists $res->[0][1]->{created}{"1"}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_received_at b/cassandane/tiny-tests/JMAPEmail/email_import_received_at new file mode 100644 index 0000000000..688c0350e6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_received_at @@ -0,0 +1,142 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_received_at + :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my ($maj, $min) = Cassandane::Instance->get_version(); + + my @testCases = ({ + desc => 'receivedAt set by client', + headers => "Date: Sat, 1 Jan 2022 01:00:00 +1100\r\n", + receivedAt => '2022-02-01T12:00:00Z', + wantSentAt => '2022-01-01T01:00:00+11:00', + wantReceivedAt => '2022-02-01T12:00:00Z', + }, { + desc => 'receivedAt set by client', + headers => "Date: Sat, 1 Jan 2022 01:00:00 +1100\r\n" . + "Received: from foo ([192.168.0.1]) by bar (Baz); Mon, 15 Aug 2022 07:49:01 -0400\r\n", + receivedAt => '2022-02-01T12:00:00Z', + wantSentAt => '2022-01-01T01:00:00+11:00', + wantReceivedAt => '2022-02-01T12:00:00Z', + }, { + desc => 'receivedAt from Received header', + creationId => 'receivedAtFromReceivedHeader', + headers => "Date: Sat, 1 Jan 2022 01:00:00 +1100\r\n" . + "Received: from foo ([192.168.0.1]) by bar (Baz); Mon, 15 Aug 2022 07:49:01 -0400\r\n", + wantSentAt => '2022-01-01T01:00:00+11:00', + wantReceivedAt => '2022-08-15T11:49:01Z', + skipVersionBefore => qw(3,7), + }, { + desc => 'receivedAt from first Received header', + headers => "Date: Sat, 1 Jan 2022 01:00:00 +1100\r\n" . + "Received: from rcv1 ([192.168.0.1]) by bar (Baz); Mon, 15 Aug 2022 07:49:01 -0400\r\n" . + "Received: from rcv2 ([192.168.0.2]) by tux (Qux); Sat, 13 Aug 2022 12:01:10 -0200\r\n" . + "Received: from rcv3 ([192.168.0.3]) by baz (Hkl); Tue, 16 Aug 2022 13:01:10 -0200\r\n", + wantSentAt => '2022-01-01T01:00:00+11:00', + wantReceivedAt => '2022-08-15T11:49:01Z', + skipVersionBefore => qw(3,7), + }, { + desc => 'receivedAt from Date header', + headers => "Date: Sat, 1 Jan 2022 01:00:00 +1100\r\n", + wantSentAt => '2022-01-01T01:00:00+11:00', + wantReceivedAt => '2021-12-31T14:00:00Z', + skipVersionBefore => qw(3,7), + }, { + desc => 'not set', + wantSentAt => undef, + wantReceivedAt => undef, + }, { + desc => 'receivedAt from first valid Received header', + headers => "Date: Sat, 1 Jan 2022 01:00:00 +1100\r\n" . + "Received: from rcv1 ([192.168.0.1]) by bar (Baz); invalid datetime\r\n" . + "Received: from rcv2 ([192.168.0.2]) by tux (Qux); Sat, 13 Aug 2022 12:01:10 -0200\r\n" . + "Received: from rcv3 ([192.168.0.3]) by baz (Hkl); Sat, 13 Aug 2022 12:00:45 -0200\r\n", + wantSentAt => '2022-01-01T01:00:00+11:00', + wantReceivedAt => '2022-08-13T14:01:10Z', + skipVersionBefore => qw(3,9), + }, { + desc => 'receivedAt from X-DeliveredInternalDate header', + headers => "Date: Sat, 1 Jan 2022 01:00:00 +1100\r\n" . + "Received: from rcv1 ([192.168.0.2]) by tux (Qux); Sat, 13 Aug 2022 12:01:10 -0200\r\n" . + "Received: from rcv2 ([192.168.0.3]) by baz (Hkl); Sat, 13 Aug 2022 12:00:45 -0200\r\n" . + "X-DeliveredInternalDate: Mon, 15 Aug 2022 13:00:45 -0200\r\n", + , + wantSentAt => '2022-01-01T01:00:00+11:00', + wantReceivedAt => '2022-08-15T15:00:45Z', + skipVersionBefore => qw(3,9), + }); + + while (my ($i, $tc) = each @testCases) { + + my ($needMaj, $needMin) = $tc->{skipVersionBefore} || qw(0,0); + if ($maj < $needMaj || ($maj == $needMaj && $min < $needMin)) { + xlog "maj=$maj needMaj=$needMaj min=$min needMin=$needMin"; + xlog $self, "Skipping test $tc->{creationId}"; + next; + } + + xlog $self, "Running test $tc->{creationId}"; + + my $mime = $tc->{headers} || ''; + $mime .= <<'EOF'; +From: sender@local +To: receiver@local +Subject: test +MIME-Version: 1.0 +Content-Type: text/plain; charset='UTF-8' +Content-Transfer-Encoding: quoted-printable +EOF + $mime =~ s/\r?\n/\r\n/gs; + $mime .= "\r\n"; + $mime .= $tc->{desc} || 'foo'; + + xlog $self, "Upload blob"; + my $blobId = ($jmap->Upload($mime, 'message/rfc822'))->{blobId}; + $self->assert_not_null($blobId); + + my $creationId = "email" . ($i + 1); + + xlog $self, "Import $creationId" . (": $tc->{desc}" || ''); + my $res = $jmap->CallMethods([ + ['Email/import', { + emails => { + $creationId => { + blobId => $blobId, + mailboxIds => { + '$inbox' => JSON::true + }, + receivedAt => $tc->{receivedAt}, + }, + }, + }, 'R1'], + ['Email/get', { + ids => [ + "#$creationId", + ], + properties => ['receivedAt', 'sentAt'], + }, 'R2'] + ]); + + $self->assert_not_null($res->[0][1]{created}{$creationId}); + + xlog $self, "Assert sentAt"; + if ($tc->{wantSentAt}) { + $self->assert_str_equals($tc->{wantSentAt}, + $res->[1][1]{list}[0]{sentAt}); + } else { + $self->assert_null($res->[1][1]{list}[0]{sentAt}); + } + + xlog $self, "Assert receivedAt"; + $self->assert_not_null($res->[1][1]{list}[0]{receivedAt}); + if ($tc->{wantReceivedAt}) { + $self->assert_str_equals($tc->{wantReceivedAt}, + $res->[1][1]{list}[0]{receivedAt}); + } else { + $self->assert_not_null($res->[1][1]{list}[0]{receivedAt}); + } + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_shared b/cassandane/tiny-tests/JMAPEmail/email_import_shared new file mode 100644 index 0000000000..f3256ee949 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_shared @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + + # Create user and share mailbox + xlog $self, "create shared mailbox"; + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lkrwpsintex") or die; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822", "foo"); + my $blobid = $data->{blobId}; + + my $mboxid = $self->getinbox({accountId => 'foo'})->{id}; + + my $req = ['Email/import', { + accountId => 'foo', + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$mboxid => JSON::true}, + keywords => { }, + }, + }, + }, "R1" + ]; + + xlog $self, "import email from blob $blobid"; + my $res = eval { $jmap->CallMethods([$req]) }; + $self->assert(exists $res->[0][1]->{created}{"1"}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_singlecopy b/cassandane/tiny-tests/JMAPEmail/email_import_singlecopy new file mode 100644 index 0000000000..484b7e1733 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_singlecopy @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_singlecopy + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftsmbox); + + xlog $self, "import email from blob $blobid"; + $res = eval { + $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$draftsmbox => JSON::true}, + keywords => { + '$draft' => JSON::true, + }, + }, + }, + }, "R1"]]); + }; + + $self->assert_str_equals("Email/import", $res->[0][0]); + my $msg = $res->[0][1]->{created}{"1"}; + $self->assert_not_null($msg); + + my $basedir = $self->{instance}->{basedir}; + my $jpath = $self->{instance}->folder_to_directory('user.cassandane.#jmap'); + my $dpath = $self->{instance}->folder_to_directory('user.cassandane.drafts'); + + my @jstat = stat("$jpath/1."); + my @dstat = stat("$dpath/1."); + + xlog $self, "sizes match"; + $self->assert_num_equals($jstat[7], $dstat[7]); + xlog $self, "same device"; + $self->assert_num_equals($jstat[0], $dstat[0]); + xlog $self, "same inode"; # single instance store + $self->assert_num_equals($jstat[1], $dstat[1]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_snooze b/cassandane/tiny-tests/JMAPEmail/email_import_snooze new file mode 100644 index 0000000000..6b94ab0e76 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_snooze @@ -0,0 +1,94 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_snooze + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd + :needs_component_sieve :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # snoozed property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $inbox = $self->getinbox()->{id}; + $self->assert_not_null($inbox); + + # Generate an embedded email to get a blob id + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Disposition: inline\r\n" . "\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "An embedded email" + . "\r\n--sub--\r\n", + ) || die; + + xlog $self, "get blobId"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['attachments'], + }, 'R2' ], + ]); + my $blobid = $res->[1][1]->{list}[0]->{attachments}[0]{blobId}; + $self->assert_not_null($blobid); + + xlog $self, "create snooze mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "snoozed", + parentId => undef, + role => "snoozed" + }}}, "R1"] + ]); + my $snoozed = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($snoozed); + + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => 30)); + my $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + xlog $self, "import and get email from blob $blobid"; + $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$snoozed => JSON::true}, + snoozed => { "until" => "$datestr" }, + }, + }, + }, "R1"], ["Email/get", { ids => ["#1"] }, "R2" ]]); + + $self->assert_str_equals("Email/import", $res->[0][0]); + my $msg = $res->[0][1]->{created}{"1"}; + $self->assert_not_null($msg); + + $self->assert_str_equals("Email/get", $res->[1][0]); + $self->assert_str_equals($msg->{id}, $res->[1][1]{list}[0]->{id}); + $self->assert_str_equals($datestr, $res->[1][1]{list}[0]->{snoozed}{'until'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_state b/cassandane/tiny-tests/JMAPEmail/email_import_state new file mode 100644 index 0000000000..b196ce03ce --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_state @@ -0,0 +1,114 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_state + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $inbox = $self->getinbox()->{id}; + $self->assert_not_null($inbox); + + # Generate an embedded email to get a blob id + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Disposition: inline\r\n" . "\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "An embedded email" + . "\r\n--sub--\r\n", + ) || die; + + xlog $self, "get blobId"; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['attachments'], + }, 'R2' ], + ]); + my $blobid = $res->[1][1]->{list}[0]->{attachments}[0]{blobId}; + $self->assert_not_null($blobid); + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + my $drafts = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($drafts); + + my $state = $res->[0][1]{newState}; + $self->assert_not_null($state); + + xlog $self, "attempt to import from blob $blobid with bogus state token"; + $res = $jmap->CallMethods([['Email/import', { + ifInState => 'bogus', + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$drafts => JSON::true}, + keywords => { '$draft' => JSON::true }, + }, + }, + }, "R1"]]); + + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); + + xlog $self, "import email from blob $blobid with valid state token"; + $res = $jmap->CallMethods([['Email/import', { + ifInState => $state, + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$drafts => JSON::true}, + keywords => { '$draft' => JSON::true }, + }, + }, + }, "R1"]]); + + $self->assert_str_equals("Email/import", $res->[0][0]); + $self->assert_not_null($res->[0][1]->{created}{"1"}); + $self->assert_str_equals($state, $res->[0][1]{oldState}); + $self->assert_not_null($res->[0][1]{newState}); + + xlog $self, "attempt to import from blob $blobid with stale state token"; + $res = $jmap->CallMethods([['Email/import', { + ifInState => $state, + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$drafts => JSON::true}, + keywords => { '$draft' => JSON::true }, + }, + }, + }, "R1"]]); + + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('stateMismatch', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_import_zerobyte b/cassandane/tiny-tests/JMAPEmail/email_import_zerobyte new file mode 100644 index 0000000000..a32787a14c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_import_zerobyte @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_import_zerobyte + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # A bogus email with an unencoded zero byte + my $email = <<"EOF"; +From: \"Some Example Sender\" \r\n +To: baseball\@local\r\n +Subject: test email\r\n +Date: Wed, 7 Dec 2016 22:11:11 +1100\r\n +MIME-Version: 1.0\r\n +Content-Type: text/plain; charset="UTF-8"\r\n +\r\n +This is a test email with a \x{0}-byte.\r\n +EOF + + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftsmbox); + + xlog $self, "import email from blob $blobid"; + $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$draftsmbox => JSON::true}, + keywords => { + '$draft' => JSON::true, + }, + }, + }, + }, "R1"]]); + $self->assert_str_equals("invalidEmail", $res->[0][1]{notCreated}{1}{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_matchmime b/cassandane/tiny-tests/JMAPEmail/email_matchmime new file mode 100644 index 0000000000..4fa360c7a4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_matchmime @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_matchmime + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd + :needs_component_sieve :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # Email/matchMime method + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $email = <<'EOF'; +From: sender@local +To: recipient@local +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +X-tra: baz +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +Some body. +EOF + $email =~ s/\r?\n/\r\n/gs; + + my $res = $jmap->CallMethods([ + ['Email/matchMime', { + mime => $email, + filter => { + subject => "test", + header => [ "X-tra", 'baz' ], + }, + }, "R1"], + ]); + + $self->assert_equals(JSON::true, $res->[0][1]{matches}); + + $res = $jmap->CallMethods([ + ['Email/matchMime', { + mime => $email, + filter => { + operator => 'AND', + conditions => [{ + text => "body", + }, { + header => [ "X-tra" ], + }], + }, + }, "R1"], + ]); + + $self->assert_equals(JSON::true, $res->[0][1]{matches}); + + $res = $jmap->CallMethods([ + ['Email/matchMime', { + mime => $email, + filter => { + hasAttachment => JSON::true, + }, + }, "R1"], + ]); + + $self->assert_equals(JSON::false, $res->[0][1]{matches}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_move_shared b/cassandane/tiny-tests/JMAPEmail/email_move_shared new file mode 100644 index 0000000000..5a02a85e2c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_move_shared @@ -0,0 +1,67 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_move_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $admintalk = $self->{adminstore}->get_client(); + + # Share account + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lr") or die; + + # Create mailbox A + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lrswipkxtecdan") or die; + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + $self->make_message("Email", store => $self->{adminstore}) or die; + + # Create mailbox B + $admintalk->create("user.other.B") or die; + $admintalk->setacl("user.other.B", "cassandane", "lrswipkxtecdan") or die; + + my @fetchEmailMethods = ( + ['Email/query', { + accountId => 'other', + collapseThreads => JSON::true, + }, "R1"], + ['Email/get', { + accountId => 'other', + properties => ['mailboxIds'], + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ); + + # Fetch Email + my $res = $jmap->CallMethods([@fetchEmailMethods, ['Mailbox/get', { accountId => 'other' }, 'R3']]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_num_equals(1, scalar keys %{$res->[1][1]{list}[0]{mailboxIds}}); + my $emailId = $res->[1][1]{list}[0]{id}; + my %mbids = map { $_->{name} => $_->{id} } @{$res->[2][1]{list}}; + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "mailboxIds/$mbids{A}" => undef, + "mailboxIds/$mbids{B}" => $JSON::true, + }}, + accountId => 'other', + }, 'R1'], + ]); + + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_move_shared_fromsharedseen b/cassandane/tiny-tests/JMAPEmail/email_move_shared_fromsharedseen new file mode 100644 index 0000000000..a6d908eeb0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_move_shared_fromsharedseen @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; + +# a case where ANOTHER user moved an email from a folder with sharedseen +# enabled to a folder with different seen options enabled caused an IOERROR +# and DBERROR because the seen db was in a transaction, and hence led to +# this in the logs: +# +# IOERROR: append_addseen failed to open DB for foo@example.com + +sub test_email_move_shared_fromsharedseen + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $admintalk = $self->{adminstore}->get_client(); + + # Share account + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lr") or die; + + # Create mailbox A + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lrswipkxtecdan") or die; + $admintalk->setmetadata("user.other.A", "/shared/vendor/cmu/cyrus-imapd/sharedseen", "true"); + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + $self->make_message("Email", store => $self->{adminstore}) or die; + + # Create mailbox B + $admintalk->create("user.other.B") or die; + $admintalk->setacl("user.other.B", "cassandane", "lrswipkxtecdan") or die; + + my @fetchEmailMethods = ( + ['Email/query', { + accountId => 'other', + collapseThreads => JSON::true, + }, "R1"], + ['Email/get', { + accountId => 'other', + properties => ['mailboxIds'], + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ); + + # Fetch Email + my $res = $jmap->CallMethods([@fetchEmailMethods, ['Mailbox/get', { accountId => 'other' }, 'R3']]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_num_equals(1, scalar keys %{$res->[1][1]{list}[0]{mailboxIds}}); + my $emailId = $res->[1][1]{list}[0]{id}; + my %mbids = map { $_->{name} => $_->{id} } @{$res->[2][1]{list}}; + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "keywords/\$seen" => $JSON::true, + }}, + accountId => 'other', + }, 'R1'], + ['Email/set', { + update => { $emailId => { + "mailboxIds/$mbids{A}" => undef, + "mailboxIds/$mbids{B}" => $JSON::true, + }}, + accountId => 'other', + }, 'R2'], + ]); + + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + $self->assert_not_null($res->[1][1]{updated}); + $self->assert_null($res->[1][1]{notUpdated}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse b/cassandane/tiny-tests/JMAPEmail/email_parse new file mode 100644 index 0000000000..bc227c9d21 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "An embedded email" + . "\r\n--sub--\r\n", + ) || die; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['attachments'], + }, 'R2' ], + ]); + my $blobId = $res->[1][1]{list}[0]{attachments}[0]{blobId}; + + my @props = $self->defaultprops_for_email_get(); + push @props, "bodyStructure"; + push @props, "bodyValues"; + + $res = $jmap->CallMethods([['Email/parse', { + blobIds => [ $blobId ], properties => \@props, fetchAllBodyValues => JSON::true, + }, 'R1']]); + my $email = $res->[0][1]{parsed}{$blobId}; + $self->assert_not_null($email); + + $self->assert_null($email->{id}); + $self->assert_null($email->{threadId}); + $self->assert_null($email->{mailboxIds}); + $self->assert_deep_equals({}, $email->{keywords}); + $self->assert_deep_equals(['fake.1475639947.6507@local'], $email->{messageId}); + $self->assert_deep_equals([{name=>'Ava T. Nguyen', email=>'Ava.Nguyen@local'}], $email->{from}); + $self->assert_deep_equals([{name=>'Test User', email=>'test@local'}], $email->{to}); + $self->assert_null($email->{cc}); + $self->assert_null($email->{bcc}); + $self->assert_null($email->{references}); + $self->assert_null($email->{sender}); + $self->assert_null($email->{replyTo}); + $self->assert_str_equals('bar', $email->{subject}); + $self->assert_str_equals('2016-10-05T14:59:07+11:00', $email->{sentAt}); + $self->assert_not_null($email->{blobId}); + $self->assert_str_equals('text/plain', $email->{bodyStructure}{type}); + $self->assert_null($email->{bodyStructure}{subParts}); + $self->assert_num_equals(1, scalar @{$email->{textBody}}); + $self->assert_num_equals(1, scalar @{$email->{htmlBody}}); + $self->assert_num_equals(0, scalar @{$email->{attachments}}); + + my $bodyValue = $email->{bodyValues}{$email->{bodyStructure}{partId}}; + $self->assert_str_equals('An embedded email', $bodyValue->{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_base64 b/cassandane/tiny-tests/JMAPEmail/email_parse_base64 new file mode 100644 index 0000000000..f822d7fcf4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_base64 @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_base64 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $rawEmail = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $rawEmail =~ s/\r?\n/\r\n/gs; + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: message/rfc822\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . MIME::Base64::encode_base64($rawEmail, "\r\n") + . "\r\n--sub--\r\n", + ) || die; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['attachments'], + }, 'R2' ], + ]); + my $blobId = $res->[1][1]{list}[0]{attachments}[0]{blobId}; + + my @props = $self->defaultprops_for_email_get(); + push @props, "bodyStructure"; + push @props, "bodyValues"; + + $res = $jmap->CallMethods([['Email/parse', { + blobIds => [ $blobId ], + properties => \@props, + fetchAllBodyValues => JSON::true, + }, 'R1']]); + + my $email = $res->[0][1]{parsed}{$blobId}; + $self->assert_not_null($email); + $self->assert_deep_equals( + [{ + name => 'Some Example Sender', + email => 'example@example.com' + }], + $email->{from} + ); + my $bodyValue = $email->{bodyValues}{$email->{bodyStructure}{partId}}; + $self->assert_str_equals("This is a test email.\n", $bodyValue->{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_blob822 b/cassandane/tiny-tests/JMAPEmail/email_parse_blob822 new file mode 100644 index 0000000000..e1627cae7c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_blob822 @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_blob822 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $rawEmail = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $rawEmail =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($rawEmail, "application/data"); + my $blobId = $data->{blobId}; + + my @props = $self->defaultprops_for_email_get(); + push @props, "bodyStructure"; + push @props, "bodyValues"; + + my $res = $jmap->CallMethods([['Email/parse', { + blobIds => [ $blobId ], + properties => \@props, + fetchAllBodyValues => JSON::true, + }, 'R1']]); + my $email = $res->[0][1]{parsed}{$blobId}; + + $self->assert_not_null($email); + $self->assert_deep_equals([{name=>'Some Example Sender', email=>'example@example.com'}], $email->{from}); + + my $bodyValue = $email->{bodyValues}{$email->{bodyStructure}{partId}}; + $self->assert_str_equals("This is a test email.\n", $bodyValue->{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_blob822_lenient b/cassandane/tiny-tests/JMAPEmail/email_parse_blob822_lenient new file mode 100644 index 0000000000..8e2f9c9a79 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_blob822_lenient @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_blob822_lenient + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + # This isn't a valid RFC822 message, as it neither contains + # a Date nor a From header. But there's wild stuff out there, + # so let's be lenient. + my $rawEmail = <<'EOF'; +To: foo@bar.local +MIME-Version: 1.0 + +Some illegit mail. +EOF + $rawEmail =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($rawEmail, "application/data"); + my $blobId = $data->{blobId}; + + my $res = $jmap->CallMethods([['Email/parse', { + blobIds => [ $blobId ], + fetchAllBodyValues => JSON::true, + }, 'R1']]); + my $email = $res->[0][1]{parsed}{$blobId}; + + $self->assert_not_null($email); + $self->assert_null($email->{from}); + $self->assert_null($email->{sentAt}); + $self->assert_deep_equals([{name=>undef, email=>'foo@bar.local'}], $email->{to}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_charset b/cassandane/tiny-tests/JMAPEmail/email_parse_charset new file mode 100644 index 0000000000..2c75b13ad2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_charset @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_charset + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + # LF in raw headers will be replaced to CRLF later. + + my @testCases = ({ + desc => "Canonical charset parameter", + rawHeader => "text/plain; charset=utf-8", + wantContentType => 'text/plain', + wantCharset => 'utf-8', + }, { + desc => "Folded charset parameter", + rawHeader => "text/plain;\n charset=\n utf-8", + wantContentType => 'text/plain', + wantCharset => 'utf-8', + }, { + desc => "Aliased charset parameter", + rawHeader => "text/plain; charset=latin1", + wantContentType => 'text/plain', + wantCharset => 'latin1', + }); + + foreach (@testCases) { + xlog $self, "Running test: $_->{desc}"; + my $rawEmail = "" + . "From: foo\@local\n" + . "To: bar\@local\n" + . "Subject: test email\n" + . "Date: Wed, 7 Dec 2016 00:21:50 -0500\n" + . "Content-Type: " . $_->{rawHeader} . "\n" + . "MIME-Version: 1.0\n" + . "\n" + . "This is a test email.\n"; + + $rawEmail =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($rawEmail, "application/octet-stream"); + my $blobId = $data->{blobId}; + + my $res = $jmap->CallMethods([ + ['Email/import', { + emails => { + 1 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + blobId => $blobId, + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#1'], + properties => ['bodyStructure'], + bodyProperties => ['charset'], + }, '$2'], + ]); + my $email = $res->[1][1]{list}[0]; + if (defined $_->{wantCharset}) { + $self->assert_str_equals($_->{wantCharset}, $email->{bodyStructure}{charset}); + } else { + $self->assert_null($email->{bodyStructure}{charset}); + } + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_contenttype_default b/cassandane/tiny-tests/JMAPEmail/email_parse_contenttype_default new file mode 100644 index 0000000000..1771d5fd58 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_contenttype_default @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_contenttype_default + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $emailWithoutContentType = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 + +This is a test email. +EOF + + my $emailWithoutCharset = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +Content-Type: text/plain +MIME-Version: 1.0 + +This is a test email. +EOF + + my $emailWithNonTextContentType = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +Content-Type: application/data +MIME-Version: 1.0 + +This is a test email. +EOF + + my $emailWithBogusContentTypeParams = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +Content-Type: text/html; charset=text/plain; charset=utf-8 +MIME-Version: 1.0 + +This is a test email. +EOF + + + my @testCases = ({ + desc => "Email without Content-Type header", + rawEmail => $emailWithoutContentType, + wantContentType => 'text/plain', + wantCharset => 'us-ascii', + }, { + desc => "Email without charset parameter", + rawEmail => $emailWithoutCharset, + wantContentType => 'text/plain', + wantCharset => 'us-ascii', + }, { + desc => "Email with non-text Content-Type", + rawEmail => $emailWithNonTextContentType, + wantContentType => 'application/data', + wantCharset => undef, + }, { + desc => "Email with bogus Content-Type params", + rawEmail => $emailWithBogusContentTypeParams, + wantContentType => 'text/html', + wantCharset => 'utf-8', + }); + + foreach (@testCases) { + xlog $self, "Running test: $_->{desc}"; + my $rawEmail = $_->{rawEmail}; + $rawEmail =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($rawEmail, "application/data"); + my $blobId = $data->{blobId}; + + my $res = $jmap->CallMethods([['Email/parse', { + blobIds => [ $blobId ], + properties => ['bodyStructure'], + }, 'R1']]); + my $email = $res->[0][1]{parsed}{$blobId}; + $self->assert_str_equals($_->{wantContentType}, $email->{bodyStructure}{type}); + if (defined $_->{wantCharset}) { + $self->assert_str_equals($_->{wantCharset}, $email->{bodyStructure}{charset}); + } else { + $self->assert_null($email->{bodyStructure}{charset}); + } + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_digest b/cassandane/tiny-tests/JMAPEmail/email_parse_digest new file mode 100644 index 0000000000..b406f9730d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_digest @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_digest + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + $self->make_message("foo", + mime_type => "multipart/digest", + mime_boundary => "sub", + body => "" + . "\r\n--sub\r\n" + . "\r\n" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "An embedded email" + . "\r\n--sub--\r\n", + ) || die; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['bodyStructure'] + }, 'R2' ], + ]); + my $blobId = $res->[1][1]{list}[0]{bodyStructure}{subParts}[0]{blobId}; + $self->assert_not_null($blobId); + + $res = $jmap->CallMethods([['Email/parse', { blobIds => [ $blobId ] }, 'R1']]); + $self->assert_not_null($res->[0][1]{parsed}{$blobId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_embedded_toplevel b/cassandane/tiny-tests/JMAPEmail/email_parse_embedded_toplevel new file mode 100644 index 0000000000..e57d15369f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_embedded_toplevel @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_embedded_toplevel + :min_version_3_3 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 1 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + subject => 'test1', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'A text body', + }, + }, + }, + }, + }, 'R1'], + ]); + my $blobId = $res->[0][1]{created}{1}{blobId}; + $self->assert_not_null($blobId); + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 2 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + subject => 'test2', + bodyStructure => { + subParts => [{ + type => 'text/plain', + partId => 'part1', + }, { + type => 'message/rfc822', + blobId => $blobId, + }], + }, + bodyValues => { + part1 => { + value => 'A text body', + }, + }, + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#2'], + properties => ['bodyStructure'], + bodyProperties => ['blobId'], + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}{2}); + $self->assert_str_equals($blobId, + $res->[1][1]{list}[0]{bodyStructure}{subParts}[1]{blobId}); + + $res = $jmap->CallMethods([ + ['Email/parse', { + blobIds => [ $blobId ], + properties => ['blobId'], + }, 'R1'], + ]); + $self->assert_str_equals($blobId, $res->[0][1]{parsed}{$blobId}{blobId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_encoding b/cassandane/tiny-tests/JMAPEmail/email_parse_encoding new file mode 100644 index 0000000000..dfee093975 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_encoding @@ -0,0 +1,93 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_encoding + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $decodedBody = "\N{LATIN SMALL LETTER A WITH GRAVE} la carte"; + my $encodedBody = '=C3=A0 la carte'; + $encodedBody =~ s/\r?\n/\r\n/gs; + + my $Header = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable +EOF + $Header =~ s/\r?\n/\r\n/gs; + my $emailBlob = $Header . "\r\n" . $encodedBody; + + my $email; + my $res; + my $partId; + + $self->make_message("foo", + mime_type => "multipart/mixed;boundary=1234567", + body => "" + . "--1234567\r\n" + . "Content-Type: text/plain; charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . $encodedBody + . "\r\n--1234567\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "X-Header: ignore\r\n" # make this blob id unique + . $emailBlob + . "\r\n--1234567--\r\n" + ); + + # Assert content decoding for top-level message. + xlog $self, "get email"; + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['bodyValues', 'bodyStructure', 'textBody'], + bodyProperties => ['partId', 'blobId'], + fetchAllBodyValues => JSON::true, + }, 'R2'], + ]); + $self->assert_num_equals(scalar @{$res->[0][1]->{ids}}, 1); + $email = $res->[1][1]->{list}[0]; + $partId = $email->{textBody}[0]{partId}; + $self->assert_str_equals($decodedBody, $email->{bodyValues}{$partId}{value}); + + # Assert content decoding for embedded message. + xlog $self, "parse embedded email"; + my $embeddedBlobId = $email->{bodyStructure}{subParts}[1]{blobId}; + $res = $jmap->CallMethods([['Email/parse', { + blobIds => [ $email->{bodyStructure}{subParts}[1]{blobId} ], + properties => ['bodyValues', 'textBody'], + fetchAllBodyValues => JSON::true, + }, 'R1']]); + $email = $res->[0][1]{parsed}{$embeddedBlobId}; + $partId = $email->{textBody}[0]{partId}; + $self->assert_str_equals($decodedBody, $email->{bodyValues}{$partId}{value}); + + # Assert content decoding for message blob. + my $data = $jmap->Upload($emailBlob, "application/data"); + my $blobId = $data->{blobId}; + + $res = $jmap->CallMethods([['Email/parse', { + blobIds => [ $blobId ], + properties => ['bodyValues', 'textBody'], + fetchAllBodyValues => JSON::true, + }, 'R1']]); + $email = $res->[0][1]{parsed}{$blobId}; + $partId = $email->{textBody}[0]{partId}; + $self->assert_str_equals($decodedBody, $email->{bodyValues}{$partId}{value}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_inmemory_blob b/cassandane/tiny-tests/JMAPEmail/email_parse_inmemory_blob new file mode 100644 index 0000000000..792e95a395 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_inmemory_blob @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_inmemory_blob + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # XXX: replace with the upstream one once RFC 9404 is finished + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/blob'); + + my $mimeMsg = <<'EOF'; +From: +To: to@local +Bcc: bcc@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +hello +EOF + $mimeMsg =~ s/\r?\n/\r\n/gs; + + # XXX: can't use a result reference in array + my $blobId = 'G67501cd2e1eaaf65d25e6f3b49554d2193f06ee8'; + + $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + b1 => { + data => [{'data:asText' => $mimeMsg}] + }, + }, + }, 'R1'], + ['Email/parse', { + blobIds => [$blobId], + properties => ['subject', 'bodyStructure'], + }, 'R2'], + ]); + $self->assert_str_equals('Blob/upload', $res->[0][0]); + $self->assert_not_null($res->[0][1]{created}{b1}{id}); + $self->assert_str_equals('test', $res->[1][1]{parsed}{$blobId}{subject}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_notparsable b/cassandane/tiny-tests/JMAPEmail/email_parse_notparsable new file mode 100644 index 0000000000..a93873299f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_notparsable @@ -0,0 +1,24 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_notparsable + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $rawEmail = "" + ."To:foo\@bar.local\r\n" + ."Date: Date: Wed, 7 Dec 2016 00:21:50 -0500\r\n" + ."\r\n" + ."Some\nbogus\nbody"; + + my $data = $jmap->Upload($rawEmail, "application/data"); + my $blobId = $data->{blobId}; + + my $res = $jmap->CallMethods([['Email/parse', { blobIds => [ $blobId ] }, 'R1']]); + $self->assert_str_equals($blobId, $res->[0][1]{notParsable}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_parse_replyto b/cassandane/tiny-tests/JMAPEmail/email_parse_replyto new file mode 100644 index 0000000000..9dc85c8987 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_parse_replyto @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_parse_replyto + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $rawMessage = <<'EOF'; +From: +To: to@local +Reply-To: replyto@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary=6c3338934661485f87537c19b5f9d933 + +--6c3338934661485f87537c19b5f9d933 +Content-Type: text/plain + +body + +--6c3338934661485f87537c19b5f9d933 +Content-Type: message/rfc822 + +From: +To: attachedto@local +Reply-To: attachedreplyto@local +Subject: attachedtest +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +attachedbody + +--6c3338934661485f87537c19b5f9d933-- +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + my $res = $jmap->CallMethods([ + ['Email/query', { + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['bodyStructure'], + }, 'R2'], + ]); + my $emailId = $res->[0][1]{ids}[0]; + $self->assert_not_null($emailId); + + my $blobId = $res->[1][1]{list}[0]{bodyStructure}{subParts}[1]{blobId}; + $self->assert_not_null($blobId); + + $res = $jmap->CallMethods([ + ['Email/parse', { + blobIds => [$blobId], + properties => ['from', 'replyTo'], + }, 'R1'], + ]); + $self->assert_str_equals('attachedfrom@local', + $res->[0][1]{parsed}{$blobId}{from}[0]{email}); + $self->assert_str_equals('attachedreplyto@local', + $res->[0][1]{parsed}{$blobId}{replyTo}[0]{email}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query b/cassandane/tiny-tests/JMAPEmail/email_query new file mode 100644 index 0000000000..4ffadbecc2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query @@ -0,0 +1,348 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $account = undef; + my $store = $self->{store}; + my $mboxprefix = "INBOX"; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => $account }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create mailboxes"; + $talk->create("$mboxprefix.A") || die; + $talk->create("$mboxprefix.B") || die; + $talk->create("$mboxprefix.C") || die; + + $res = $jmap->CallMethods([['Mailbox/get', { accountId => $account }, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxa = $m{"A"}->{id}; + my $mboxb = $m{"B"}->{id}; + my $mboxc = $m{"C"}->{id}; + $self->assert_not_null($mboxa); + $self->assert_not_null($mboxb); + $self->assert_not_null($mboxc); + + xlog $self, "create emails"; + my %params; + $store->set_folder("$mboxprefix.A"); + my $dtfoo = DateTime->new( + year => 2016, + month => 11, + day => 1, + hour => 7, + time_zone => 'Etc/UTC', + ); + my $bodyfoo = "A rather short email"; + %params = ( + date => $dtfoo, + body => $bodyfoo, + store => $store, + ); + $res = $self->make_message("foo", %params) || die; + $talk->copy(1, "$mboxprefix.C") || die; + + $store->set_folder("$mboxprefix.B"); + my $dtbar = DateTime->new( + year => 2016, + month => 3, + day => 1, + hour => 19, + time_zone => 'Etc/UTC', + ); + my $bodybar = "" + . "In the context of electronic mail, emails are viewed as having an\r\n" + . "envelope and contents. The envelope contains whatever information is\r\n" + . "needed to accomplish transmission and delivery. (See [RFC5321] for a\r\n" + . "discussion of the envelope.) The contents comprise the object to be\r\n" + . "delivered to the recipient. This specification applies only to the\r\n" + . "format and some of the semantics of email contents. It contains no\r\n" + . "specification of the information in the envelope.i\r\n" + . "\r\n" + . "However, some email systems may use information from the contents\r\n" + . "to create the envelope. It is intended that this specification\r\n" + . "facilitate the acquisition of such information by programs.\r\n" + . "\r\n" + . "This specification is intended as a definition of what email\r\n" + . "content format is to be passed between systems. Though some email\r\n" + . "systems locally store emails in this format (which eliminates the\r\n" + . "need for translation between formats) and others use formats that\r\n" + . "differ from the one specified in this specification, local storage is\r\n" + . "outside of the scope of this specification.\r\n"; + + %params = ( + date => $dtbar, + body => $bodybar, + extra_headers => [ + ['x-tra', "baz"], + ], + store => $store, + ); + $self->make_message("bar", %params) || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch emails without filter"; + $res = $jmap->CallMethods([ + ['Email/query', { accountId => $account }, 'R1'], + ['Email/get', { + accountId => $account, + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } + }, 'R2'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_num_equals(2, scalar @{$res->[1][1]->{list}}); + + %m = map { $_->{subject} => $_ } @{$res->[1][1]{list}}; + my $foo = $m{"foo"}->{id}; + my $bar = $m{"bar"}->{id}; + $self->assert_not_null($foo); + $self->assert_not_null($bar); + + xlog $self, "filter text"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + text => "foo", + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter NOT text"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + operator => "NOT", + conditions => [ {text => "foo"} ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailbox A"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + inMailbox => $mboxa, + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailboxes"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + operator => 'OR', + conditions => [ + { + inMailbox => $mboxa, + }, + { + inMailbox => $mboxc, + }, + ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailboxes with not in"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + inMailboxOtherThan => [$mboxb], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailboxes"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + operator => 'AND', + conditions => [ + { + inMailbox => $mboxa, + }, + { + inMailbox => $mboxb, + }, + { + inMailbox => $mboxc, + }, + ], + }, + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "filter not in mailbox A"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + operator => 'NOT', + conditions => [ + { + inMailbox => $mboxa, + }, + ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by before"; + my $dtbefore = $dtfoo->clone()->subtract(seconds => 1); + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + before => $dtbefore->strftime('%Y-%m-%dT%TZ'), + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by after", + my $dtafter = $dtbar->clone()->add(seconds => 1); + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + after => $dtafter->strftime('%Y-%m-%dT%TZ'), + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by after and before", + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + after => $dtafter->strftime('%Y-%m-%dT%TZ'), + before => $dtbefore->strftime('%Y-%m-%dT%TZ'), + }, + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "filter by minSize"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + minSize => length($bodybar), + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by maxSize"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + maxSize => length($bodybar), + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by header"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + header => [ "x-tra" ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by header and value"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + header => [ "x-tra", "bam" ], + }, + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "sort by ascending receivedAt"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "receivedAt" }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by descending receivedAt"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "receivedAt", isAscending => JSON::false }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by ascending sentAt"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "sentAt" }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by descending sentAt"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "sentAt", isAscending => JSON::false }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by ascending size"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "size" }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by descending size"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "size", isAscending => JSON::false }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by ascending id"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "id" }], + }, "R1"]]); + my @ids = sort ($foo, $bar); + $self->assert_deep_equals(\@ids, $res->[0][1]->{ids}); + + xlog $self, "sort by descending id"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "id", isAscending => JSON::false }], + }, "R1"]]); + @ids = reverse sort ($foo, $bar); + $self->assert_deep_equals(\@ids, $res->[0][1]->{ids}); + + xlog $self, "delete mailboxes"; + $talk->delete("$mboxprefix.A") or die; + $talk->delete("$mboxprefix.B") or die; + $talk->delete("$mboxprefix.C") or die; +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_acl b/cassandane/tiny-tests/JMAPEmail/email_query_acl new file mode 100644 index 0000000000..68df898027 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_acl @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_acl + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $admintalk = $self->{adminstore}->get_client(); + + # Create user and share mailbox + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lr") or die; + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', { accountId => 'foo' }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "Create email in shared account"; + $self->{adminstore}->set_folder('user.foo'); + $self->make_message("Email foo", store => $self->{adminstore}) or die; + + xlog $self, "get email list in main account"; + $res = $jmap->CallMethods([['Email/query', { }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "get email list in shared account"; + $res = $jmap->CallMethods([['Email/query', { accountId => 'foo' }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $id = $res->[0][1]->{ids}[0]; + + xlog $self, "Create email in main account"; + $self->make_message("Email cassandane") or die; + + xlog $self, "get email list in main account"; + $res = $jmap->CallMethods([['Email/query', { }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_not_equals($id, $res->[0][1]->{ids}[0]); + + xlog $self, "get email list in shared account"; + $res = $jmap->CallMethods([['Email/query', { accountId => 'foo' }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($id, $res->[0][1]->{ids}[0]); + + xlog $self, "create but do not share mailbox"; + $admintalk->create("user.foo.box1") or die; + $admintalk->setacl("user.foo.box1", "cassandane", "") or die; + + xlog $self, "create email in private mailbox"; + $self->{adminstore}->set_folder('user.foo.box1'); + $self->make_message("Email private foo", store => $self->{adminstore}) or die; + + xlog $self, "get email list in shared account"; + $res = $jmap->CallMethods([['Email/query', { accountId => 'foo' }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($id, $res->[0][1]->{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_addedDates b/cassandane/tiny-tests/JMAPEmail/email_query_addedDates new file mode 100644 index 0000000000..eb7b8e4b84 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_addedDates @@ -0,0 +1,148 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_addedDates + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # addedDates property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "Create Trash folder"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "trash" => { + name => "Trash", + parentId => undef, + role => "trash" + } + } + }, "R1"], + ]); + my $trashId = $res->[0][1]{created}{trash}{id}; + $self->assert_not_null($trashId); + + xlog $self, "create messages"; + $self->make_message('uid1') || die; + $self->make_message('uid2') || die; + sleep 1; + $self->make_message('uid3') || die; + $self->make_message('uid4') || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ + property => 'subject', + isAscending => JSON::true + }], + }, 'R1'], + ]); + my $emailId1 = $res->[0][1]{ids}[0]; + my $emailId2 = $res->[0][1]{ids}[1]; + my $emailId3 = $res->[0][1]{ids}[2]; + my $emailId4 = $res->[0][1]{ids}[3]; + $self->assert_not_null($emailId1); + $self->assert_not_null($emailId2); + $self->assert_not_null($emailId3); + $self->assert_not_null($emailId4); + + # Move email2 to mailbox using role as id + sleep 1; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId2 => { + "mailboxIds/$inboxid" => undef, + "mailboxIds/$trashId" => JSON::true + } + }, + }, 'R1'], + ]); + + # Move email1 to mailbox using role as id + sleep 1; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId1 => { + "mailboxIds/$inboxid" => undef, + "mailboxIds/$trashId" => JSON::true + } + }, + }, 'R1'], + ]); + + # Copy email4 to mailbox using role as id + sleep 1; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId4 => { + "mailboxIds/$trashId" => JSON::true, + keywords => { '$flagged' => JSON::true } + } + }, + }, 'R1'], + ]); + + # Copy email3 to mailbox using role as id + sleep 1; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId3 => { + "mailboxIds/$trashId" => JSON::true + } + }, + }, 'R1'], + ]); + + xlog $self, "query emails sorted by addedDates"; + $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ + property => 'addedDates', + mailboxId => "$trashId", + isAscending => JSON::true + }], + }, 'R1'], + ]); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[1]); + $self->assert_str_equals($emailId2, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($emailId3, $res->[0][1]->{ids}[3]); + $self->assert_str_equals($emailId4, $res->[0][1]->{ids}[2]); + + $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ + property => 'someInThreadHaveKeyword', + keyword => '$flagged', + isAscending => JSON::false, + }, + { + property => 'addedDates', + mailboxId => "$trashId", + isAscending => JSON::false, + }], + }, 'R1'], + ]); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[2]); + $self->assert_str_equals($emailId2, $res->[0][1]->{ids}[3]); + $self->assert_str_equals($emailId3, $res->[0][1]->{ids}[1]); + $self->assert_str_equals($emailId4, $res->[0][1]->{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_angleuri b/cassandane/tiny-tests/JMAPEmail/email_query_angleuri new file mode 100644 index 0000000000..9dea8278bf --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_angleuri @@ -0,0 +1,94 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_angleuri + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + # Search indexing attempts to strip HTML tags also from plain + # text bodies if they are part of a multipart/alternative. + # This is because ill-behaving email implementations tend to + # put HTML in these, too. + + # Unfortunately, legit email clients might enclose URLs + # in angle-brackets when they convert anchor hrefs in + # HTML bodies to plain text alternatives. This caused + # Cyrus to also strip such URLs from plain text, instead + # of indexing them for text search. + + xlog $self, "Append message with angle-bracket URI in plain text"; + my $mimeMessage = <<'EOF'; +From: from@local +To: to@local +Subject: needle_in_plain +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary=c4683f7a320d4d20902b000486fbdf9b + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/plain;charset=utf-8 + +Click here for a surprise + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/html;charset=utf-8 + +Nothing to see here + +--c4683f7a320d4d20902b000486fbdf9b-- +EOF + $mimeMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $mimeMessage) || die $@; + + xlog $self, "Append message with angle-bracket URI in HTML text"; + $mimeMessage = <<'EOF'; +From: from@local +To: to@local +Subject: needle_in_html +Date: Mon, 14 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary=c4683f7a320d4d20902b000486fbdf9b + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/plain;charset=utf-8 + +Nothing to see here + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/html;charset=utf-8 + +Click here for a surprise + +--c4683f7a320d4d20902b000486fbdf9b-- +EOF + $mimeMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $mimeMessage) || die $@; + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "Assert angle-bracket URI is indexed for plain text, but not HTML"; + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => 'needle', + }, + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + properties => ['subject'], + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals('needle_in_plain', + $res->[1][1]{list}[0]{subject}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_attachmentname b/cassandane/tiny-tests/JMAPEmail/email_query_attachmentname new file mode 100644 index 0000000000..cb1523f120 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_attachmentname @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_attachmentname + :needs_component_jmap :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/mail'); + + xlog "Append emails"; + my @filenameParams = ( + 'filename="logo.png"', + 'filename=somethingelse.png', + "filename*=utf-8''R%C3%BCbezahl.png", + 'filename=blåbærsyltetøy.png', + ); + while (my ($i, $filenameParam) = each @filenameParams) { + my $mime = <new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + } + + xlog "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-Z'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ + property => 'subject', + }], + }, 'R1'], + + ]); + my @ids = @{$res->[0][1]{ids}}; + $self->assert_num_equals(scalar @filenameParams, scalar @ids); + + my @tests = ({ + filter => { + attachmentName => "logo", + }, + wantIds => [$ids[0]], + }, { + filter => { + attachmentName => "png", + }, + wantIds => [$ids[0], $ids[1], $ids[2], $ids[3]], + }, { + filter => { + attachmentName => decode('utf-8', "Rübezahl.png"), + }, + wantIds => [$ids[2]], + }, { + filter => { + text => decode('utf-8', "rübezahl"), + }, + wantIds => [$ids[2]], + }, { + filter => { + attachmentName => decode('utf-8', 'blåbærsyltetøy'), + }, + wantIds => [$ids[3]], + }); + + foreach (@tests) { + $res = $jmap->CallMethods([ + ['Email/query', { + filter => $_->{filter}, + sort => [{ + property => 'subject', + }], + }, 'R1'], + ]); + $self->assert_deep_equals($_->{wantIds}, $res->[0][1]{ids}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype b/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype new file mode 100644 index 0000000000..5a66e5f2c5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype @@ -0,0 +1,177 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_attachmenttype + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $blobId = $jmap->Upload('some_data', "application/octet")->{blobId}; + + my $rfc822Msg = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $rfc822Msg =~ s/\r?\n/\r\n/gs; + my $rfc822MsgBlobId = $jmap->Upload($rfc822Msg, "message/rfc822")->{blobId}; + $self->assert_not_null($rfc822MsgBlobId); + + my $inboxid = $self->getinbox()->{id}; + + my $res = $jmap->CallMethods([ + ['Email/set', { create => { + "1" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "sam\@acme.local" } ] , + to => [ { name => "", email => "bugs\@acme.local" } ], + subject => "foo", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "foo" } }, + attachments => [{ + blobId => $blobId, + type => 'image/gif', + }], + }, + "2" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "tweety\@acme.local" } ] , + to => [ { name => "", email => "duffy\@acme.local" } ], + subject => "bar", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "bar" } }, + }, + "3" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "elmer\@acme.local" } ] , + to => [ { name => "", email => "porky\@acme.local" } ], + subject => "baz", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "baz" } }, + attachments => [{ + blobId => $blobId, + type => 'application/msword', + }], + }, + "4" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "elmer\@acme.local" } ] , + to => [ { name => "", email => "porky\@acme.local" } ], + subject => "baz", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "baz" } }, + attachments => [{ + blobId => $blobId, + type => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }], + }, + "5" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "elmer\@acme.local" } ] , + to => [ { name => "", email => "porky\@acme.local" } ], + subject => "embeddedmsg", + bodyStructure => { + subParts => [{ + partId => "text", + type => "text/plain" + },{ + blobId => $rfc822MsgBlobId, + disposition => "attachment", + type => "message/rfc822" + }], + type => "multipart/mixed", + }, + bodyValues => { + text => { + value => "Hello World", + }, + }, + } + }}, 'R1'] + ]); + my $idGif = $res->[0][1]{created}{"1"}{id}; + my $idTxt = $res->[0][1]{created}{"2"}{id}; + my $idDoc = $res->[0][1]{created}{"3"}{id}; + my $idWord = $res->[0][1]{created}{"4"}{id}; + my $idRfc822Msg = $res->[0][1]{created}{"5"}{id}; + $self->assert_not_null($idGif); + $self->assert_not_null($idTxt); + $self->assert_not_null($idDoc); + $self->assert_not_null($idWord); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @testCases = ({ + filter => { + attachmentType => 'image/gif', + }, + wantIds => [$idGif], + }, { + filter => { + attachmentType => 'image', + }, + wantIds => [$idGif], + }, { + filter => { + attachmentType => 'application/msword', + }, + wantIds => [$idDoc], + }, { + filter => { + # this should be application/vnd... but Xapian has a 64 character limit on terms + # indexed, so application_vndopenxmlformatsofficedocumentwordprocessingmldocument + # never got indexed + attachmentType => 'vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + wantIds => [$idWord], + }, { + filter => { + attachmentType => 'document', + }, + wantIds => [$idDoc, $idWord], + }, { + filter => { + operator => 'NOT', + conditions => [{ + attachmentType => 'image', + }, { + attachmentType => 'document', + }], + }, + wantIds => [$idTxt, $idRfc822Msg], + }, { + filter => { + attachmentType => 'email', + }, + wantIds => [$idRfc822Msg], + }); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + ]; + + foreach (@testCases) { + my $filter = $_->{filter}; + my $wantIds = $_->{wantIds}; + $res = $jmap->CallMethods([['Email/query', { + filter => $filter, + }, "R1"]], $using); + my @wantIds = sort @{$wantIds}; + my @gotIds = sort @{$res->[0][1]->{ids}}; + $self->assert_deep_equals(\@wantIds, \@gotIds); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype_legacy b/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype_legacy new file mode 100644 index 0000000000..683dbd3bbb --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype_legacy @@ -0,0 +1,124 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_attachmenttype_legacy + :min_version_3_1 :max_version_3_4 + :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $blobId = $jmap->Upload('some_data', "application/octet")->{blobId}; + + my $inboxid = $self->getinbox()->{id}; + + my $res = $jmap->CallMethods([ + ['Email/set', { create => { + "1" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "sam\@acme.local" } ] , + to => [ { name => "", email => "bugs\@acme.local" } ], + subject => "foo", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "foo" } }, + attachments => [{ + blobId => $blobId, + type => 'image/gif', + }], + }, + "2" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "tweety\@acme.local" } ] , + to => [ { name => "", email => "duffy\@acme.local" } ], + subject => "bar", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "bar" } }, + }, + "3" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "elmer\@acme.local" } ] , + to => [ { name => "", email => "porky\@acme.local" } ], + subject => "baz", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "baz" } }, + attachments => [{ + blobId => $blobId, + type => 'application/msword', + }], + }, + "4" => { + mailboxIds => {$inboxid => JSON::true}, + from => [ { name => "", email => "elmer\@acme.local" } ] , + to => [ { name => "", email => "porky\@acme.local" } ], + subject => "baz", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "baz" } }, + attachments => [{ + blobId => $blobId, + type => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }], + }, + }}, 'R1'] + ]); + my $idGif = $res->[0][1]{created}{"1"}{id}; + my $idTxt = $res->[0][1]{created}{"2"}{id}; + my $idDoc = $res->[0][1]{created}{"3"}{id}; + my $idWord = $res->[0][1]{created}{"4"}{id}; + $self->assert_not_null($idGif); + $self->assert_not_null($idTxt); + $self->assert_not_null($idDoc); + $self->assert_not_null($idWord); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @testCases = ({ + filter => { + attachmentType => 'image/gif', + }, + wantIds => [$idGif], + }, { + filter => { + attachmentType => 'image', + }, + wantIds => [$idGif], + }, { + filter => { + attachmentType => 'application/msword', + }, + wantIds => [$idDoc], + }, { + filter => { + # this should be application/vnd... but Xapian has a 64 character limit on terms + # indexed, so application_vndopenxmlformatsofficedocumentwordprocessingmldocument + # never got indexed + attachmentType => 'vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + wantIds => [$idWord], + }, { + filter => { + attachmentType => 'document', + }, + wantIds => [$idDoc, $idWord], + }); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + ]; + + foreach (@testCases) { + my $filter = $_->{filter}; + my $wantIds = $_->{wantIds}; + $res = $jmap->CallMethods([['Email/query', { + filter => $filter, + }, "R1"]], $using); + my @wantIds = sort @{$wantIds}; + my @gotIds = sort @{$res->[0][1]->{ids}}; + $self->assert_deep_equals(\@wantIds, \@gotIds); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype_wildcards b/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype_wildcards new file mode 100644 index 0000000000..eb4fc3f2fd --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_attachmenttype_wildcards @@ -0,0 +1,123 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_attachmenttype_wildcards + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $self->make_message("msg1", + mime_type => "multipart/mixed", + mime_boundary => "123456789", + body => "" + . "--123456789\r\n" + . "Content-Type: text/plain\r\n" + . "msg1" + . "\r\n--123456789\r\n" + . "Content-Type: application/rtf\r\n" + . "\r\n" + . "data" + . "\r\n--123456789--\r\n", + ); + + $self->make_message("msg2", + mime_type => "multipart/mixed", + mime_boundary => "123456789", + body => "" + . "--123456789\r\n" + . "Content-Type: text/plain\r\n" + . "msg1" + . "\r\n--123456789\r\n" + . "Content-Type: text/rtf\r\n" + . "\r\n" + . "data" + . "\r\n--123456789--\r\n", + ); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'subject' ], + }, 'R2'], + ]); + $self->assert_num_equals(2, scalar @{$res->[1][1]{list}}); + my %emails = map { $_->{subject} => $_->{id} } @{$res->[1][1]{list}}; + + my @tests = ({ + filter => { + attachmentType => 'text/plain', + }, + wantIds => [ $emails{'msg1'}, $emails{'msg2'} ], + }, { + filter => { + attachmentType => 'application/rtf', + }, + wantIds => [ $emails{'msg1'} ], + }, { + filter => { + attachmentType => 'text/rtf', + }, + wantIds => [ $emails{'msg2'} ], + }, { + filter => { + attachmentType => 'text', + }, + wantIds => [ $emails{'msg1'}, $emails{'msg2'} ], + }, { + filter => { + attachmentType => 'application', + }, + wantIds => [ $emails{'msg1'} ], + }, { + filter => { + attachmentType => 'plain', + }, + wantIds => [ $emails{'msg1'}, $emails{'msg2'} ], + }, { + filter => { + attachmentType => 'rtf', + }, + wantIds => [ $emails{'msg1'}, $emails{'msg2'} ], + }, { + filter => { + attachmentType => 'application/*', + }, + wantIds => [ $emails{'msg1'} ], + }, { + filter => { + attachmentType => '*/rtf', + }, + wantIds => [ $emails{'msg1'}, $emails{'msg2'} ], + }); + + foreach (@tests) { + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => $_->{filter}, + }, 'R1'], + ], $using); + my @gotIds = sort @{$res->[0][1]->{ids}}; + my @wantIds = sort @{$_->{wantIds}}; + $self->assert_deep_equals(\@wantIds, \@gotIds); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_bcc b/cassandane/tiny-tests/JMAPEmail/email_query_bcc new file mode 100644 index 0000000000..6667ee277d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_bcc @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_bcc + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $account = undef; + my $store = $self->{store}; + my $mboxprefix = "INBOX"; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => $account }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create email1"; + my $bcc1 = Cassandane::Address->new(localpart => 'needle', domain => 'local'); + my $msg1 = $self->make_message('msg1', bcc => $bcc1); + + my $bcc2 = Cassandane::Address->new(localpart => 'beetle', domain => 'local'); + my $msg2 = $self->make_message('msg2', bcc => $bcc2); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch emails without filter"; + $res = $jmap->CallMethods([ + ['Email/query', { accountId => $account }, 'R1'], + ['Email/get', { + accountId => $account, + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } + }, 'R2'], + ]); + + my %m = map { $_->{subject} => $_ } @{$res->[1][1]{list}}; + my $emailId1 = $m{"msg1"}->{id}; + my $emailId2 = $m{"msg2"}->{id}; + $self->assert_not_null($emailId1); + $self->assert_not_null($emailId2); + + xlog $self, "filter text"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + text => "needle", + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[0]); + + xlog $self, "filter NOT text"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + operator => "NOT", + conditions => [ {text => "needle"} ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId2, $res->[0][1]->{ids}[0]); + + xlog $self, "filter bcc"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + bcc => "needle", + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[0]); + + xlog $self, "filter NOT bcc"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + operator => "NOT", + conditions => [ {bcc => "needle"} ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId2, $res->[0][1]->{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_body_sieve b/cassandane/tiny-tests/JMAPEmail/email_query_body_sieve new file mode 100644 index 0000000000..c344cb4d49 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_body_sieve @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_body_sieve + :min_version_3_7 :needs_component_sieve :needs_component_jmap + :JMAPExtensions :AltNamespace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $imap->create("matches") or die; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "text" : "wizzbang" + } +. + ) +{ + fileinto "matches"; +} +EOF + ); + + xlog "Deliver matching message"; + my $msg1 = $self->{gen}->generate( + subject => 'xxxyyyzzz', + body => "a msg with a wizzbang in it" + ); + $self->{instance}->deliver($msg1); + + xlog "Assert that message got moved into INBOX.matches"; + $self->{store}->set_folder('matches'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Deliver a non-matching message"; + my $msg2 = $self->{gen}->generate( + subject => 'zzzyyyyxxx', + body => "a more boring msg" + ); + $self->{instance}->deliver($msg2); + $msg2->set_attribute(uid => 1); + + xlog "Assert that message got moved into INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_cached b/cassandane/tiny-tests/JMAPEmail/email_query_cached new file mode 100644 index 0000000000..1fb359f1fa --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_cached @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_cached + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPQueryCacheMaxAge1s :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create emails"; + $res = $self->make_message("foo 1") || die; + $res = $self->make_message("foo 2") || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $query1 = { + filter => { + subject => 'foo', + }, + sort => [{ property => 'subject' }], + }; + + my $query2 = { + filter => { + subject => 'foo', + }, + sort => [{ property => 'subject', isAscending => JSON::false }], + }; + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + xlog $self, "run query #1"; + $res = $jmap->CallMethods([['Email/query', $query1, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "re-run query #1"; + $res = $jmap->CallMethods([['Email/query', $query1, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::true, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "run query #2"; + $res = $jmap->CallMethods([['Email/query', $query2, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "re-run query #2"; + $res = $jmap->CallMethods([['Email/query', $query2, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::true, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "change Email state"; + $res = $self->make_message("foo 3") || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "re-run query #2"; + $res = $jmap->CallMethods([['Email/query', $query2, 'R1']], $using); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_cached_collapsed_uncollapsed b/cassandane/tiny-tests/JMAPEmail/email_query_cached_collapsed_uncollapsed new file mode 100644 index 0000000000..c287069715 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_cached_collapsed_uncollapsed @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_cached_collapsed_uncollapsed + :min_version_3_7 :needs_component_sieve :needs_component_jmap + :JMAPQueryCacheMaxAge1s :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create emails"; + $res = $self->make_message("foo 1") || die; + $res = $self->make_message("foo 2") || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + ]; + + + xlog $self, "Query uncollapsed threads"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + subject => 'foo', + }, + sort => [{ property => 'subject' }], + collapseThreads => JSON::false, + limit => 1, + }, 'R1']], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + #$self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "Query collapsed threads"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + subject => 'foo', + }, + sort => [{ property => 'subject' }], + collapseThreads => JSON::true, + limit => 1, + }, 'R1']], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_cached_evict b/cassandane/tiny-tests/JMAPEmail/email_query_cached_evict new file mode 100644 index 0000000000..613c0f00c0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_cached_evict @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_cached_evict + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPQueryCacheMaxAge1s :JMAPExtensions :want_smtpdaemon +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + $self->make_message("foo") || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + text => 'foo', + }, + }, 'R1'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + text => 'foo', + }, + }, 'R1'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::true, $res->[0][1]->{performance}{details}{isCached}); + + sleep(2); + + $res = $jmap->CallMethods([ + ['Identity/get', { + # evict cache + }, 'R1'], + ['Email/query', { + filter => { + text => 'foo', + }, + }, 'R2'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[1][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[1][1]->{performance}{details}{isCached}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_cached_legacy b/cassandane/tiny-tests/JMAPEmail/email_query_cached_legacy new file mode 100644 index 0000000000..02492c7db2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_cached_legacy @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_cached_legacy + :min_version_3_1 :max_version_3_4 :needs_component_jmap + :needs_component_sieve :JMAPSearchDBLegacy :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create emails"; + $res = $self->make_message("foo 1") || die; + $res = $self->make_message("foo 2") || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $query1 = { + filter => { + subject => 'foo', + }, + sort => [{ property => 'subject' }], + }; + + my $query2 = { + filter => { + subject => 'foo', + }, + sort => [{ property => 'subject', isAscending => JSON::false }], + }; + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + xlog $self, "run query #1"; + $res = $jmap->CallMethods([['Email/query', $query1, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "re-run query #1"; + $res = $jmap->CallMethods([['Email/query', $query1, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::true, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "run query #2"; + $res = $jmap->CallMethods([['Email/query', $query2, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "re-run query #1 (still cached)"; + $res = $jmap->CallMethods([['Email/query', $query1, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::true, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "re-run query #2 (still cached)"; + $res = $jmap->CallMethods([['Email/query', $query2, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::true, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "change Email state"; + $res = $self->make_message("foo 3") || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "re-run query #1 (cache invalidated)"; + $res = $jmap->CallMethods([['Email/query', $query1, 'R1']], $using); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); + + xlog $self, "re-run query #2 (cache invalidated)"; + $res = $jmap->CallMethods([['Email/query', $query2, 'R1']], $using); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals(JSON::false, $res->[0][1]->{performance}{details}{isCached}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_cjk_fullwidth b/cassandane/tiny-tests/JMAPEmail/email_query_cjk_fullwidth new file mode 100644 index 0000000000..b1529f637b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_cjk_fullwidth @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_cjk_fullwidth + :min_version_3_9 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + +use utf8; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => 'foo@local' }], + to => [{ email => 'bar@local' }], + subject => $_->{id}, + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => <<'EOF' +三菱UFJファクター株式会社 +EOF + }, + }, + }, + }, + }, 'R1'], + ]); + my $email1Id = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($email1Id); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => "UFJ", + }, + }, 'R1'], + ['Email/query', { + filter => { + body => "三菱UFJファクター株式会社", + }, + }, 'R2'], + ['Email/query', { + filter => { + body => "三菱UFJ", + }, + }, 'R3'], + ['Email/query', { + filter => { + body => "三菱", + }, + }, 'R4'], + ]); + $self->assert_deep_equals([$email1Id], $res->[0][1]{ids}); + $self->assert_deep_equals([$email1Id], $res->[1][1]{ids}); + $self->assert_deep_equals([$email1Id], $res->[2][1]{ids}); + $self->assert_deep_equals([$email1Id], $res->[3][1]{ids}); + +no utf8; +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_collapse b/cassandane/tiny-tests/JMAPEmail/email_query_collapse new file mode 100644 index 0000000000..bee7392877 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_collapse @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_collapse + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + + my $imaptalk = $self->{store}->get_client(); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + my $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->create_user("test"); + $admintalk->setacl("user.test", "cassandane", "lrwkx") or die; + + # run tests for both the main and "test" account + foreach (undef, "test") { + my $account = $_; + my $store = defined $account ? $self->{adminstore} : $self->{store}; + my $mboxprefix = defined $account ? "user.$account" : "INBOX"; + my $talk = $store->get_client(); + + my %params = (store => $store); + $store->set_folder($mboxprefix); + + xlog $self, "generating email A"; + $exp{A} = $self->make_message("Email A", %params); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B", %params); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + %params = ( + references => [ $exp{A} ], + store => $store, + ); + $exp{C} = $self->make_message("Re: Email A", %params); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "list uncollapsed threads"; + $res = $jmap->CallMethods([['Email/query', { accountId => $account }, "R1"]]); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + + $res = $jmap->CallMethods([['Email/query', { accountId => $account, collapseThreads => JSON::true }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_contactgroup_filter_no_dnf b/cassandane/tiny-tests/JMAPEmail/email_query_contactgroup_filter_no_dnf new file mode 100644 index 0000000000..43e6ad58e6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_contactgroup_filter_no_dnf @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_contactgroup_filter_no_dnf + :min_version_3_4 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/contacts', + ]; + + my $ncontacts = 100; + my $createContacts = {}; + for (my $i = 1; $i <= $ncontacts; $i++) { + $createContacts->{"contact$i"} = { + emails => [{ + type => 'personal', + value => "contact$i\@local", + }], + }; + } + + my @contactCreationIds = map { "#$_" } keys %{$createContacts}; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => $createContacts, + }, 'R1'], + ['ContactGroup/set', { + create => { + contactGroup => { + name => 'contactGroup', + contactIds => \@contactCreationIds, + }, + } + }, 'R2'], + ], $using); + $self->assert_num_equals($ncontacts, scalar keys %{$res->[0][1]{created}}); + my $contactGroupId = $res->[1][1]{created}{contactGroup}{id}; + $self->assert_not_null($contactGroupId); + + $self->make_message("msg-contact", from => Cassandane::Address->new( + localpart => 'contact1', domain => 'local' + )) or die; + $self->make_message("msg-nocontact", from => Cassandane::Address->new( + localpart => 'nocontact', domain => 'local' + )) or die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ + fromContactGroupId => $contactGroupId, + }], + }, + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + properties => ['subject'], + }, 'R2'] + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals('msg-nocontact', $res->[1][1]{list}[0]{subject}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_convflags_seen_in_trash b/cassandane/tiny-tests/JMAPEmail/email_query_convflags_seen_in_trash new file mode 100644 index 0000000000..58fc964f8d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_convflags_seen_in_trash @@ -0,0 +1,147 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_convflags_seen_in_trash + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxTrash => { + name => 'Trash', + }, + } + }, 'R2'], + ]); + my $mboxTrash = $res->[0][1]{created}{mboxTrash}{id}; + $self->assert_not_null($mboxTrash); + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + emailInInbox => { + mailboxIds => { + '$inbox' => JSON::true, + }, + messageId => ['emailInInbox@local'], + subject => 'test', + keywords => { + '$seen' => JSON::true, + }, + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test inbox', + } + }, + }, + }, + }, 'R1'], + ['Email/set', { + create => { + emailInTrash => { + mailboxIds => { + $mboxTrash => JSON::true, + }, + messageId => ['emailInThrash@local'], + subject => 'Re: test', + references => ['emailInInbox@local'], + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test trash', + } + }, + }, + }, + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}{emailInInbox}{id}); + $self->assert_not_null($res->[1][1]{created}{emailInTrash}{id}); + $self->assert_str_equals($res->[0][1]{created}{emailInInbox}{threadId}, + $res->[1][1]{created}{emailInTrash}{threadId}); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ + allInThreadHaveKeyword => '$seen', + }], + }, + }, 'R1'], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + operator => 'NOT', + conditions => [{ + allInThreadHaveKeyword => '$seen', + }], + }, { + inMailboxOtherThan => [ $mboxTrash ], + }], + }, + }, 'R2'], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + body => 'test', + }, { + operator => 'NOT', + conditions => [{ + allInThreadHaveKeyword => '$seen', + }], + }], + }, + }, 'R3'], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + body => 'test', + }, { + operator => 'NOT', + conditions => [{ + allInThreadHaveKeyword => '$seen', + }], + }, { + inMailboxOtherThan => [ $mboxTrash ], + }], + }, + }, 'R4'], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]); + + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(2, scalar @{$res->[2][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[3][1]{ids}}); + + $self->assert_equals(JSON::false, + $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, + $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::true, + $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::true, + $res->[3][1]{performance}{details}{isGuidSearch}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_dash b/cassandane/tiny-tests/JMAPEmail/email_query_dash new file mode 100644 index 0000000000..f8d92186cd --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_dash @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_dash + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $self->make_message("something - otherthing", body => 'test') || die; + $self->make_message("something", body => 'test') || die; + $self->make_message("otherthing", body => 'test') || die; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Running query with guidsearch"; + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + "operator" => "AND", + "conditions" => [ + { + "subject" => "something" + }, + { + "subject" => "-" + }, + { + "subject" => "otherthing" + } + ], + }, + }, 'R1'] + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_dash_sieve b/cassandane/tiny-tests/JMAPEmail/email_query_dash_sieve new file mode 100644 index 0000000000..f16b613d39 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_dash_sieve @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_dash_sieve + :min_version_3_3 :needs_component_jmap :JMAPExtensions + :needs_component_sieve +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog "Running query in sieve"; + $imap->create("INBOX.matches") or die; + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "operator" : "AND", + "conditions" : [ + { + "subject" : "something" + }, + { + "subject" : "-" + }, + { + "subject" : "otherthing" + } + ] + } +. + ) +{ + fileinto "INBOX.matches"; +} +EOF + ); + + my $msg1 = $self->{gen}->generate( + subject => 'something - otherthing', body => '' + ); + $self->{instance}->deliver($msg1); + my $msg2 = $self->{gen}->generate( + subject => 'something', body => '' + ); + my $msg3 = $self->{gen}->generate( + subject => 'otherthing', body => '' + ); + $self->{instance}->deliver($msg1); + $self->{store}->set_fetch_attributes('uid'); + $self->{store}->set_folder('INBOX.matches'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_deliveredto b/cassandane/tiny-tests/JMAPEmail/email_query_deliveredto new file mode 100644 index 0000000000..4b1b1f48ea --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_deliveredto @@ -0,0 +1,107 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_deliveredto + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $rawMessage = <<'EOF'; +From: +To: to@local +Bcc: bcc@local +X-Delivered-To: deliveredto@local +Subject: match1 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +match1 +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + $rawMessage = <<'EOF'; +From: +To: to@local +Bcc: bcc@local +X-Original-Delivered-To: deliveredto@local +Subject: match2 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +match2 +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + $rawMessage = <<'EOF'; +From: +To: to@local +Subject: nomatch +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +nomatch +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { }, + sort => [{ + property => 'subject', + }], + }, 'R1'], + ], $using); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + my $match1Id = $res->[0][1]{ids}[0]; + $self->assert_not_null($match1Id); + my $match2Id = $res->[0][1]{ids}[1]; + $self->assert_not_null($match2Id); + my $noMatchId = $res->[0][1]{ids}[2]; + $self->assert_not_null($noMatchId); + + xlog "Query with JMAP search"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + deliveredTo => 'deliveredto@local', + }, + sort => [{ + property => 'subject', + }], + }, 'R1'], + ], $using); + $self->assert_deep_equals([$match1Id,$match2Id], $res->[0][1]{ids}); + + xlog "Query with IMAP search"; + $imap->select('INBOX'); + my $uids = $imap->search( + 'deliveredto', { Quote => 'deliveredto@local' }, + ) || die; + $self->assert_deep_equals([1,2], $uids); + + xlog "Query with fuzzy IMAP search"; + $imap->select('INBOX'); + $uids = $imap->search( + 'fuzzy', 'deliveredto', { Quote => 'deliveredto@local' }, + ) || die; + $self->assert_deep_equals([1,2], $uids); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_dnfcomplexity b/cassandane/tiny-tests/JMAPEmail/email_query_dnfcomplexity new file mode 100644 index 0000000000..8bf0566bb9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_dnfcomplexity @@ -0,0 +1,110 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_dnfcomplexity + :min_version_3_4 :needs_component_sieve :needs_component_jmap + :JMAPExtensions :SearchNormalizationMax20000 :SearchMaxTime1Sec +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $rawMessage = <<'EOF'; +From: +To: to@local +Reply-To: replyto@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary=6c3338934661485f87537c19b5f9d933 + +--6c3338934661485f87537c19b5f9d933 +Content-Type: text/plain + +text body + +--6c3338934661485f87537c19b5f9d933 +Content-Type: image/jpg +Content-Disposition: attachment; filename="November.jpg" +Content-Transfer-Encoding: base64 + +ZGF0YQ== + +--6c3338934661485f87537c19b5f9d933 +Content-Type: application/pdf +Content-Disposition: attachment; filename="December.pdf" +Content-Transfer-Encoding: base64 + +ZGF0YQ== + +--6c3338934661485f87537c19b5f9d933-- +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + xlog $self, 'run squatter'; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + position => 0, + calculateTotal => JSON::false, + limit => 30, + findAllInThread => JSON::true, + collapseThreads => JSON::true, + sort => [{ + property => 'receivedAt', + isAscending => JSON::false + }], + filter => { + operator => 'AND', + conditions => [{ + hasAttachment => JSON::true + }, { + operator => 'NOT', + conditions => [{ + hasAttachment => JSON::true, + attachmentType => 'pdf' + }, { + hasAttachment => JSON::true, + attachmentType => 'presentation' + }, { + hasAttachment => JSON::true, + attachmentType => 'email' + }, { + hasAttachment => JSON::true, + attachmentType => 'spreadsheet' + }, { + attachmentType => 'document', + hasAttachment => JSON::true + }, { + attachmentType => 'image', + hasAttachment => JSON::true + }, { + attachmentType => 'presentation', + hasAttachment => JSON::true + }, { + attachmentType => 'document', + hasAttachment => JSON::true + }, { + hasAttachment => JSON::true, + attachmentType => 'pdf' + }], + }], + }, + }, 'R0'], + ], $using); + + $self->assert_str_equals('unsupportedFilter', $res->[0][1]{type}); + $self->assert_str_equals('search too complex', $res->[0][1]{description}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_emailaddress b/cassandane/tiny-tests/JMAPEmail/email_query_emailaddress new file mode 100644 index 0000000000..235d620b3c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_emailaddress @@ -0,0 +1,198 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_emailaddress + :needs_component_jmap :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog "Append emails"; + $self->make_message("msg1", + from => Cassandane::Address->new( + localpart => 'from', + domain => 'local', + ), + to => Cassandane::Address->new( + name => "Jon Doe", + localpart => "foo.bar", + domain => "xxx.example.com" + ), + body => "msg1" + ) || die; + $self->make_message("msg2", + from => Cassandane::Address->new( + localpart => 'from', + domain => 'local', + ), + to => Cassandane::Address->new( + name => "Jane Doe", + localpart => "foo.baz+bla", + domain => "yyy.example.com" + ), + body => "msg2" + ) || die; + $self->make_message("msg3", + from => Cassandane::Address->new( + localpart => 'from', + domain => 'local', + ), + to => Cassandane::Address->new( + localpart => '"tu x"', + domain => "example.com" + ), + body => "msg3" + ) || die; + my $mime = <<'EOF'; +From: +To: toa@example.com, RecipientB +Subject: msg4 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +msg4 +EOF + $mime =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $mime) || die $@; + + $mime = <<'EOF'; +From: +To: Jøran Øygårdvær , Dømi , xn--ls8ha@outlook.com, foo@ümlaut.example.com, δοκιμή@παράδειγμα.δοκιμή +Date: Mon, 13 Apr 2020 15:34:03 +0200 +Subject: msg5 +MIME-Version: 1.0 +Content-Type: text/plain + +msg5 +EOF + $mime =~ s/\r?\n/\r\n/gs; + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + + xlog "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-Z'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ + property => 'subject', + }], + }, 'R1'], + + ], $using); + my @ids = @{$res->[0][1]{ids}}; + $self->assert_num_equals(5, scalar @ids); + + my @tests = ({ + to => '@xxx.example.com', + wantIds => [$ids[0]], + }, { + to => '@*.example.com', + wantIds => [$ids[0], $ids[1], $ids[4]], + }, { + to => '@*example.com', + wantIds => [$ids[0], $ids[1], $ids[2], $ids[3], $ids[4]], + }, { + to => 'foo*@*example.com', + wantIds => [$ids[0], $ids[1], $ids[4]], + }, { + to => 'foo.bar@*.com', + wantIds => [$ids[0]], + }, { + to => 'foo.baz+*@yyy.example.com', + wantIds => [$ids[1]], + }, { + to => 'foo.baz+bla@yyy.example.com', + wantIds => [$ids[1]], + }, { + to => 'foo.bar@', + wantIds => [$ids[0]], + }, { + to => 'foo.ba*@', + wantIds => [$ids[0], $ids[1]], + }, { + to => 'doe', + wantIds => [$ids[0], $ids[1]], + }, { + to => 'jane doe', + wantIds => [$ids[1]], + }, { + to => 'foo* example', + wantIds => [$ids[0], $ids[1], $ids[4]], + }, { + to => 'foo* yyy', + wantIds => [$ids[1]], + }, { + to => 'example.com', + wantIds => [$ids[0], $ids[1], $ids[2], $ids[3], $ids[4]], + }, { + to => '"tu x"@example.com', + wantIds => [$ids[2]], + }, { + to => 'tux@example.com', + wantIds => [], + }, { + to => 'Jane Doe ', + wantIds => [$ids[1]], + }, { + to => 'Doe ', + wantIds => [$ids[0], $ids[1]], + }, { + to => 'tob@example.com', + wantIds => [$ids[3]], + }, { + to => decode('utf-8', "Øygårdvær"), + wantIds => [$ids[4]], + }, { + to => decode('utf-8', 'sjöhäst712@example.com'), + wantIds => [$ids[4]], + }, { + to => decode('utf-8', 'Dømi'), + wantIds => [$ids[4]], + }, { + to => decode('utf-8', 'xn--ls8ha@outlook.com'), + wantIds => [$ids[4]], + }, { + to => decode('utf-8', 'δοκιμή'), + wantIds => [$ids[4]], + }, { + to => decode('utf-8', '@παράδειγμα.δοκιμή'), + wantIds => [$ids[4]], + }, { + to => decode('utf-8', 'info@dømi.fo'), + wantIds => [$ids[4]], + }, { + to => decode('utf-8', 'sjöhäst712'), + wantIds => [$ids[4]], + }, { + to => decode('utf-8', 'Dømi <*@*dømi.fo>'), + wantIds => [$ids[4]], + }); + + foreach (@tests) { + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + to => $_->{to}, + }, + sort => [{ + property => 'subject', + }], + }, 'R1'], + ]); + $self->assert_deep_equals($_->{wantIds}, $res->[0][1]{ids}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_empty b/cassandane/tiny-tests/JMAPEmail/email_query_empty new file mode 100644 index 0000000000..08506c7ec2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_empty @@ -0,0 +1,22 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_empty + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # See + # https://github.com/cyrusimap/cyrus-imapd/issues/2266 + # and + # https://github.com/cyrusimap/cyrus-imapd/issues/2287 + + my $res = $jmap->CallMethods([['Email/query', { }, "R1"]]); + $self->assert(ref($res->[0][1]->{ids}) eq 'ARRAY'); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + $res = $jmap->CallMethods([['Email/query', { limit => 0 }, "R1"]]); + $self->assert(ref($res->[0][1]->{ids}) eq 'ARRAY'); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_empty_filter_conds b/cassandane/tiny-tests/JMAPEmail/email_query_empty_filter_conds new file mode 100644 index 0000000000..45cfa72eb8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_empty_filter_conds @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_empty_filter_conds + :min_version_3_7 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $self->make_message('test'); + + xlog $self, 'run squatter'; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ }], + }, + }, 'R0'], + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [], + }, + }, 'R1'], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ }], + }, + }, 'R2'], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [], + }, + }, 'R3'], + ], $using); + + $self->assert_num_equals(0, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[3][1]{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_findallinthread b/cassandane/tiny-tests/JMAPEmail/email_query_findallinthread new file mode 100644 index 0000000000..6bd0c1fc1d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_findallinthread @@ -0,0 +1,187 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_findallinthread + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog "Create three top-level thread emails"; + my %createEmails; + for (my $i = 1; $i <= 3; $i++) { + $createEmails{$i} = { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => "$i\@local" }], + to => [{ email => "$i\@local" }], + messageId => ["email$i\@local"], + subject => "email$i", + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email$i body", + }, + }, + } + } + my $res = $jmap->CallMethods([ + ['Email/set', { + create => \%createEmails, + }, 'R1'], + ]); + $self->assert_num_equals(3, scalar keys %{$res->[0][1]{created}}); + my $emailId1 = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($emailId1); + my $threadId1 = $res->[0][1]{created}{1}{threadId}; + $self->assert_not_null($threadId1); + my $emailId2 = $res->[0][1]{created}{2}{id}; + $self->assert_not_null($emailId2); + my $threadId2 = $res->[0][1]{created}{2}{threadId}; + $self->assert_not_null($threadId2); + my $emailId3 = $res->[0][1]{created}{3}{id}; + $self->assert_not_null($emailId3); + my $threadId3 = $res->[0][1]{created}{3}{threadId}; + $self->assert_not_null($threadId3); + + xlog "Create reference emails to top-level emails"; + %createEmails = (); + foreach (qw/21 22 31/) { + my $ref = substr($_, 0, 1); + $createEmails{$_} = { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => "$_\@local" }], + to => [{ email => "$_\@local" }], + messageId => ["email$_\@local"], + subject => "Re: email$ref", + references => ["email$ref\@local"], + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email$_ body", + }, + }, + } + } + $res = $jmap->CallMethods([ + ['Email/set', { + create => \%createEmails, + }, 'R1'], + ]); + $self->assert_num_equals(3, scalar keys %{$res->[0][1]{created}}); + my $emailId21 = $res->[0][1]{created}{21}{id}; + $self->assert_not_null($emailId21); + my $emailId22 = $res->[0][1]{created}{22}{id}; + $self->assert_not_null($emailId22); + my $emailId31 = $res->[0][1]{created}{31}{id}; + $self->assert_not_null($emailId31); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query emails"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => 'body', + }, + sort => [{ + property => 'id', + }], + collapseThreads => JSON::true, + findAllInThread => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { + body => 'body', + }, + sort => [{ + property => 'id', + }], + collapseThreads => JSON::true, + findAllInThread => JSON::true, + disableGuidSearch => JSON::true, + }, 'R2'], + ], $using); + + my @emailIdsThread1 = sort ($emailId1); + my @emailIdsThread2 = sort ($emailId2, $emailId21, $emailId22); + my @emailIdsThread3 = sort ($emailId3, $emailId31); + + my $wantThreadIdToEmailIds = { + $threadId1 => \@emailIdsThread1, + $threadId2 => \@emailIdsThread2, + $threadId3 => \@emailIdsThread3, + }; + + my %gotThreadIdToEmailIds; + while (my ($threadId, $emailIds) = each %{$res->[0][1]{threadIdToEmailIds}}) { + my @emailIds = sort @{$emailIds}; + $gotThreadIdToEmailIds{$threadId} = \@emailIds; + } + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals($wantThreadIdToEmailIds, \%gotThreadIdToEmailIds); + + %gotThreadIdToEmailIds = (); + while (my ($threadId, $emailIds) = each %{$res->[1][1]{threadIdToEmailIds}}) { + my @emailIds = sort @{$emailIds}; + $gotThreadIdToEmailIds{$threadId} = \@emailIds; + } + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals($wantThreadIdToEmailIds, \%gotThreadIdToEmailIds); + + xlog "Assert empty result"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => 'nope', + }, + findAllInThread => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { + body => 'nope', + }, + findAllInThread => JSON::true, + disableGuidSearch => JSON::true, + }, 'R2'], + ], $using); + $self->assert_deep_equals({}, $res->[0][1]{threadIdToEmailIds}); + $self->assert_deep_equals({}, $res->[1][1]{threadIdToEmailIds}); + + xlog "Assert threadIdToEmailIds isn't set if not requested"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => 'body', + }, + }, 'R1'], + ['Email/query', { + filter => { + body => 'body', + }, + disableGuidSearch => JSON::true, + }, 'R2'], + ], $using); + $self->assert_null($res->[0][1]{threadIdToEmailIds}); + $self->assert_null($res->[1][1]{threadIdToEmailIds}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_fix_multiple_recipients b/cassandane/tiny-tests/JMAPEmail/email_query_fix_multiple_recipients new file mode 100644 index 0000000000..e14f94b2e5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_fix_multiple_recipients @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_fix_multiple_recipients + :min_version_3_4 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $rawMessage = <<'EOF'; +From: from@local +To: unquoted@local, "quot@ed" +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +test +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + to => 'unquoted@local', + }, + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_from b/cassandane/tiny-tests/JMAPEmail/email_query_from new file mode 100644 index 0000000000..34ad5efc83 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_from @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_from + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + # Create test messages. + $self->make_message('uid1', from => Cassandane::Address->new( + name => 'B', + localpart => 'local', + domain => 'hostA' + )); + $self->make_message('uid2', from => Cassandane::Address->new( + name => 'A', + localpart => 'local', + domain => 'hostA' + )); + $self->make_message('uid3', from => Cassandane::Address->new( + localpart => 'local', + domain => 'hostY' + )); + $self->make_message('uid4', from => Cassandane::Address->new( + localpart => 'local', + domain => 'hostX' + )); + + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => 'subject' }], + }, 'R1'], + ]); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{ids}}); + my $emailId1 = $res->[0][1]{ids}[0]; + my $emailId2 = $res->[0][1]{ids}[1]; + my $emailId3 = $res->[0][1]{ids}[2]; + my $emailId4 = $res->[0][1]{ids}[3]; + + $res = $jmap->CallMethods([ + ['Email/query', { + sort => [ + { property => 'from' }, + { property => 'subject'} + ], + }, 'R1'], + ]); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId2, $res->[0][1]{ids}[0]); + $self->assert_str_equals($emailId1, $res->[0][1]{ids}[1]); + $self->assert_str_equals($emailId4, $res->[0][1]{ids}[2]); + $self->assert_str_equals($emailId3, $res->[0][1]{ids}[3]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_fromanycontact_ignore_localpartonly b/cassandane/tiny-tests/JMAPEmail/email_query_fromanycontact_ignore_localpartonly new file mode 100644 index 0000000000..1aee402edd --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_fromanycontact_ignore_localpartonly @@ -0,0 +1,81 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_fromanycontact_ignore_localpartonly + :min_version_3_3 :needs_component_jmap :JMAPExtensions + :needs_component_sieve +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog "Create contact with localpart-only mail address"; + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/contacts', + ]; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + emails => [{ + type => 'personal', + value => 'email', + }], + }, + } + }, 'R1'], + ], $using); + my $contactId1 = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($contactId1); + + xlog "Assert JMAP sieve ignores localpart-only contacts"; + $imap->create("INBOX.matches") or die; + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "operator" : "NOT", + "conditions" : [ + { + "fromAnyContact" : true + } + ] + } +. + ) +{ + fileinto "INBOX.matches"; +} +EOF + ); + + my $msg1 = $self->{gen}->generate(from => Cassandane::Address->new( + localpart => 'email', domain => 'local' + )); + $self->{instance}->deliver($msg1); + $self->{store}->set_fetch_attributes('uid'); + $self->{store}->set_folder('INBOX.matches'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog "Assert Email/query ignores localpart-only contacts"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ + fromAnyContact => JSON::true + }] + }, + sort => [ + { property => "subject" } + ], + }, 'R1'] + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_fromanycontact_shared b/cassandane/tiny-tests/JMAPEmail/email_query_fromanycontact_shared new file mode 100644 index 0000000000..f28262c58f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_fromanycontact_shared @@ -0,0 +1,139 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_fromanycontact_shared + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $admin = $self->{adminstore}->get_client(); + + xlog "Create shared addressbook"; + $admin->create("user.other"); + my $http = $self->{instance}->get_service("http"); + my $otherCarddav = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $http->host(), + port => $http->port(), + scheme => 'http', + url => '/jmap/', + ); + $admin->create("user.other.#addressbooks.Shared", ['TYPE', 'ADDRESSBOOK']); + $admin->setacl("user.other.#addressbooks.Shared", "cassandane", "lr") or die; + $imap->subscribe("user.other.#addressbooks.Shared"); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/contacts', + ]; + + xlog "Create contact in shared addressbook"; + my $res = $otherJmap->CallMethods([ + ['Contact/set', { + create => { + sharedContact => { + emails => [{ + type => 'personal', + value => 'sharedcontact@local', + }], + addressbookId => 'Shared', + }, + }, + }, 'R1'], + ], $using); + $self->assert_not_null($res->[0][1]{created}{sharedContact}{id}); + + xlog "Create contact in own addressbook"; + $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + ownContact => { + emails => [{ + type => 'personal', + value => 'ownContact@local', + }], + }, + }, + }, 'R1'], + ], $using); + $self->assert_not_null($res->[0][1]{created}{ownContact}{id}); + + xlog "Create emails"; + $self->make_message("msg1", from => Cassandane::Address->new( + localpart => 'sharedContact', domain => 'local' + )) or die; + $self->make_message("msg2", from => Cassandane::Address->new( + localpart => 'ownContact', domain => 'local' + )) or die; + $self->make_message("msg3", from => Cassandane::Address->new( + localpart => 'noContact', domain => 'local' + )) or die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => "subject" }], + }, 'R1'] + ], $using); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + my $emailIds = $res->[0][1]{ids}; + + xlog "Assert Email/query"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + fromAnyContact => JSON::true, + }, + sort => [{ property => "subject" }], + }, 'R1'] + ], $using); + $self->assert_deep_equals([$emailIds->[0], $emailIds->[1]], $res->[0][1]{ids}); + + xlog "Assert Sieve"; + $imap->create("INBOX.matches") or die; + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "fromAnyContact" : true + } +. + ) +{ + fileinto "INBOX.matches"; +} +EOF + ); + + my $rawMessage = <<'EOF'; +From: sharedContact@local +To: to@local +Subject: sieve1 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +hello +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $rawMessage); + $self->{instance}->deliver($msg); + $self->assert_num_equals(1, $imap->message_count('INBOX.matches')); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_fromcontactgroupid b/cassandane/tiny-tests/JMAPEmail/email_query_fromcontactgroupid new file mode 100644 index 0000000000..77e7fdd590 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_fromcontactgroupid @@ -0,0 +1,252 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_fromcontactgroupid + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user.cassandane.#addressbooks.Addrbook1", ['TYPE', 'ADDRESSBOOK']) or die; + $admintalk->create("user.cassandane.#addressbooks.Addrbook2", ['TYPE', 'ADDRESSBOOK']) or die; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/contacts', + ]; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + emails => [{ + type => 'personal', + value => 'contact1@local', + }], + }, + contact2 => { + emails => [{ + type => 'personal', + value => 'contact2@local', + }] + }, + } + }, 'R1'], + ['ContactGroup/set', { + create => { + contactGroup1 => { + name => 'contactGroup1', + contactIds => ['#contact1', '#contact2'], + addressbookId => 'Addrbook1', + }, + contactGroup2 => { + name => 'contactGroup2', + contactIds => ['#contact1'], + addressbookId => 'Addrbook2', + } + } + }, 'R2'], + ], $using); + my $contactId1 = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($contactId1); + my $contactId2 = $res->[0][1]{created}{contact2}{id}; + $self->assert_not_null($contactId2); + my $contactGroupId1 = $res->[1][1]{created}{contactGroup1}{id}; + $self->assert_not_null($contactGroupId1); + my $contactGroupId2 = $res->[1][1]{created}{contactGroup2}{id}; + $self->assert_not_null($contactGroupId2); + + $self->make_message("msg1", from => Cassandane::Address->new( + localpart => 'contact1', domain => 'local' + )) or die; + $self->make_message("msg2", from => Cassandane::Address->new( + localpart => 'contact2', domain => 'local' + )) or die; + $self->make_message("msg3", from => Cassandane::Address->new( + localpart => 'neither', domain => 'local' + )) or die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => "subject" }], + }, 'R1'] + ], $using); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + my $emailId1 = $res->[0][1]{ids}[0]; + my $emailId2 = $res->[0][1]{ids}[1]; + my $emailId3 = $res->[0][1]{ids}[2]; + + # Filter by contact group. + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + fromContactGroupId => $contactGroupId1 + }, + sort => [ + { property => "subject" } + ], + }, 'R1'] + ], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]{ids}[0]); + $self->assert_str_equals($emailId2, $res->[0][1]{ids}[1]); + + # Filter by fromAnyContact + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + fromAnyContact => $JSON::true + }, + sort => [ + { property => "subject" } + ], + }, 'R1'] + ], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]{ids}[0]); + $self->assert_str_equals($emailId2, $res->[0][1]{ids}[1]); + + # Filter by contact group and addressbook. + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + fromContactGroupId => $contactGroupId2 + }, + sort => [ + { property => "subject" } + ], + addressbookId => 'Addrbook2' + }, 'R1'] + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]{ids}[0]); + + + # Negate filter by contact group. + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ + fromContactGroupId => $contactGroupId1 + }] + }, + sort => [ + { property => "subject" } + ], + }, 'R1'] + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($emailId3, $res->[0][1]{ids}[0]); + + # Reject unknown contact groups. + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + fromContactGroupId => 'doesnotexist', + }, + sort => [ + { property => "subject" } + ], + }, 'R1'] + ], $using); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); + + # Reject contact groups in wrong addressbook. + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + fromContactGroupId => $contactGroupId1 + }, + sort => [ + { property => "subject" } + ], + addressbookId => 'Addrbook2', + }, 'R1'] + ], $using); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); + + # Reject unknown addressbooks. + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + fromContactGroupId => $contactGroupId1, + }, + sort => [ + { property => "subject" } + ], + addressbookId => 'doesnotexist', + }, 'R1'] + ], $using); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); + + # Support also to, cc, bcc + $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact3 => { + emails => [{ + type => 'personal', + value => 'contact3@local', + }] + }, + } + }, 'R1'], + ['ContactGroup/set', { + update => { + $contactGroupId1 => { + contactIds => ['#contact3'], + } + } + }, 'R1'], + ], $using); + $self->assert_not_null($res->[0][1]{created}{contact3}); + $self->make_message("msg4", to => Cassandane::Address->new( + localpart => 'contact3', domain => 'local' + )) or die; + $self->make_message("msg5", cc => Cassandane::Address->new( + localpart => 'contact3', domain => 'local' + )) or die; + $self->make_message("msg6", bcc => Cassandane::Address->new( + localpart => 'contact3', domain => 'local' + )) or die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => "subject" }], + }, 'R1'] + ], $using); + $self->assert_num_equals(6, scalar @{$res->[0][1]{ids}}); + my $emailId4 = $res->[0][1]{ids}[3]; + my $emailId5 = $res->[0][1]{ids}[4]; + my $emailId6 = $res->[0][1]{ids}[5]; + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + toContactGroupId => $contactGroupId1 + }, + }, 'R1'], + ['Email/query', { + filter => { + ccContactGroupId => $contactGroupId1 + }, + }, 'R2'], + ['Email/query', { + filter => { + bccContactGroupId => $contactGroupId1 + }, + }, 'R3'] + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($emailId4, $res->[0][1]{ids}[0]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{ids}}); + $self->assert_str_equals($emailId5, $res->[1][1]{ids}[0]); + $self->assert_num_equals(1, scalar @{$res->[2][1]{ids}}); + $self->assert_str_equals($emailId6, $res->[2][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch new file mode 100644 index 0000000000..6f36f141dd --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + for (my $i = 0; $i < 10; $i++) { + $self->make_message("msg$i", to => Cassandane::Address->new( + localpart => "recipient$i", + domain => 'example.com' + )) || die; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog "Running query with guidsearch"; + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + to => '@example.com', + }, + }, 'R1'] + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + my $guidSearchIds = $res->[0][1]{ids}; + $self->assert_num_equals(10, scalar @{$guidSearchIds}); + + xlog "Running query without guidsearch"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + to => '@example.com', + }, + disableGuidSearch => JSON::true, + }, 'R1'] + ], $using); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + my $uidSearchIds = $res->[0][1]{ids}; + $self->assert_num_equals(10, scalar @{$uidSearchIds}); + + xlog "Comparing results"; + $self->assert_deep_equals($guidSearchIds, $uidSearchIds); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_collapsethreads b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_collapsethreads new file mode 100644 index 0000000000..a543eb7e9d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_collapsethreads @@ -0,0 +1,129 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_collapsethreads + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + + my $emailCount = 3; + my %createEmails; + for (my $i = 1; $i <= $emailCount; $i++) { + my $extraBody = ' diy reseller' unless ($i % 2); + $createEmails{$i} = { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => "foo$i\@bar" }], + to => [{ email => "bar$i\@example.com" }], + messageId => ["email$i\@local"], + subject => "email$i", + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email$i body" . $extraBody + }, + }, + } + } + my $res = $jmap->CallMethods([ + ['Email/set', { + create => \%createEmails, + }, 'R1'], + ]); + $self->assert_num_equals($emailCount, scalar keys %{$res->[0][1]{created}}); + + for (my $i = 1; $i <= $emailCount; $i++) { + my %createEmails = (); + my $threadCount = ($i % 7) + 3; # clamp to max 10 thread emails + for (my $j = 1; $j <= $threadCount; $j++) { + my $extraBody = ' nyi reseller' unless ($j % 2); + $createEmails{$j} = { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => "foo$i" . "ref$j\@bar" }], + to => [{ email => "bar$i" . "ref$j\@example.com" }], + messageId => ["email$i" . "ref$j\@local"], + references => ["email$i\@local"], + subject => "Re: email$i", + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email$i" ."ref$j body" . $extraBody + }, + }, + } + } + $res = $jmap->CallMethods([ + ['Email/set', { + create => \%createEmails, + }, 'R1'], + ]); + $self->assert_num_equals($threadCount, scalar keys %{$res->[0][1]{created}}); + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query collapsed threads"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + text => 'nyi', + }, { + text => 'reseller', + }], + }, + sort => [{ + property => 'receivedAt', + isAscending => JSON::false, + }], + collapseThreads => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + text => 'nyi', + }, { + text => 'reseller', + }], + }, + sort => [{ + property => 'receivedAt', + isAscending => JSON::false, + }], + collapseThreads => JSON::true, + disableGuidSearch => JSON::true, + }, 'R2'], + ], $using); + + my $guidSearchIds; + my @wantIds; + + # Check GUID search results + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals($res->[1][1]{ids}, $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inbox b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inbox new file mode 100644 index 0000000000..6b3418943d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inbox @@ -0,0 +1,161 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_inbox + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog "Create message in mailbox A"; + my $email = <<'EOF'; +From: from@local +To: to@local +Subject: email1 +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +email1 +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobId = $data->{blobId}; + $self->assert_not_null($blobId); + + my $res = $jmap->CallMethods([ + ['Mailbox/query', { + }, "R1"], + ['Mailbox/set', { + create => { + mboxA => { + name => "A", + } + } + }, "R2"] + ], $using); + my $inboxId = $res->[0][1]{ids}[0]; + $self->assert_not_null($inboxId); + my $mboxId = $res->[1][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxId); + + $res = $jmap->CallMethods([ + ['Email/import', { + emails => { + email1 => { + blobId => $blobId, + mailboxIds => { + $mboxId => JSON::true + }, + }, + }, + }, "R1"], + ], $using); + $self->assert_str_equals("Email/import", $res->[0][0]); + my $email1Id = $res->[0][1]->{created}{email1}{id}; + $self->assert_not_null($email1Id); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query inMailbox=inbox"; + $res = $jmap->CallMethods([ + ['Email/get', { + ids => [$email1Id], + properties => ['mailboxIds'], + }, "R1"], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + inMailbox => $inboxId, + }], + }, + }, "R2"], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + inMailbox => $inboxId, + subject => 'email1', + }], + }, + }, "R3"], + ], $using); + $self->assert_deep_equals({ + $mboxId => JSON::true, + }, $res->[0][1]{list}[0]{mailboxIds}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals([], $res->[1][1]{ids}); + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj < 3 || ($maj ==3 && $min < 5)) { + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + } + else { + # Due to improved JMAP Email query optimizer + $self->assert_equals(JSON::false, $res->[2][1]{performance}{details}{isGuidSearch}); + } + + $self->assert_deep_equals([], $res->[2][1]{ids}); + + xlog "Create message in inbox"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email2 => { + mailboxIds => { + $inboxId => JSON::true, + }, + subject => 'email2', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'email2', + } + }, + }, + }, + }, 'R1'], + ]); + my $email2Id = $res->[0][1]->{created}{email2}{id}; + $self->assert_not_null($email2Id); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Rerun query inMailbox=inbox"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + inMailbox => $inboxId, + }], + }, + }, "R1"], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + inMailbox => $inboxId, + subject => 'email2', + }], + }, + }, "R1"], + ], $using); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals([$email2Id], $res->[0][1]{ids}); + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals([$email2Id], $res->[1][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inmailbox b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inmailbox new file mode 100644 index 0000000000..65592edd4a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inmailbox @@ -0,0 +1,273 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_inmailbox + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + $imap->create("INBOX.B") or die; + $imap->create("INBOX.C") or die; + $imap->create("INBOX.D") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ], $using); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $mboxByName{'A'}->{id}; + my $mboxIdB = $mboxByName{'B'}->{id}; + my $mboxIdC = $mboxByName{'C'}->{id}; + my $mboxIdD = $mboxByName{'D'}->{id}; + + xlog $self, "create emails"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'mA' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'A', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mB' => { + mailboxIds => { + $mboxIdB => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'B', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mC' => { + mailboxIds => { + $mboxIdC => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'C', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mD' => { + mailboxIds => { + $mboxIdD => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'D', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mAB' => { + mailboxIds => { + $mboxIdA => JSON::true, + $mboxIdB => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'AB', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mCD' => { + mailboxIds => { + $mboxIdC => JSON::true, + $mboxIdD => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'CD', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mABCD' => { + mailboxIds => { + $mboxIdA => JSON::true, + $mboxIdB => JSON::true, + $mboxIdC => JSON::true, + $mboxIdD => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'ABCD', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ], $using); + my $emailIdA = $res->[0][1]->{created}{mA}{id}; + $self->assert_not_null($emailIdA); + my $emailIdB = $res->[0][1]->{created}{mB}{id}; + $self->assert_not_null($emailIdB); + my $emailIdC = $res->[0][1]->{created}{mC}{id}; + $self->assert_not_null($emailIdC); + my $emailIdD = $res->[0][1]->{created}{mD}{id}; + $self->assert_not_null($emailIdD); + my $emailIdAB = $res->[0][1]->{created}{mAB}{id}; + $self->assert_not_null($emailIdAB); + my $emailIdCD = $res->[0][1]->{created}{mCD}{id}; + $self->assert_not_null($emailIdCD); + my $emailIdABCD = $res->[0][1]->{created}{mABCD}{id}; + $self->assert_not_null($emailIdABCD); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @wantIds; + + xlog $self, "query emails in mailbox A"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + from => 'foo@local', + inMailbox => $mboxIdA, + }, + sort => [{ + property => 'id', + isAscending => JSON::true, + }], + }, 'R1'], + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + @wantIds = sort ($emailIdA, $emailIdAB, $emailIdABCD); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + + xlog $self, "query emails in mailbox A and B"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + from => 'foo@local', + inMailbox => $mboxIdA, + }, { + from => 'foo@local', + inMailbox => $mboxIdB, + }], + }, + sort => [{ + property => 'id', + isAscending => JSON::true, + }], + }, 'R1'], + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + @wantIds = sort ($emailIdAB, $emailIdABCD); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + + xlog $self, "query emails in mailboxes other than A,B"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + from => 'foo@local', + inMailboxOtherThan => [$mboxIdA, $mboxIdB], + }, + sort => [{ + property => 'id', + isAscending => JSON::true, + }], + }, 'R1'], + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + @wantIds = sort ($emailIdC, $emailIdD, $emailIdCD, $emailIdABCD); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inmailboxotherthan b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inmailboxotherthan new file mode 100644 index 0000000000..6cb58d5a5a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_inmailboxotherthan @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_inmailboxotherthan + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ], $using); + + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxA = $mboxByName{'A'}->{id}; + $self->assert_not_null($mboxA); + my $inbox = $mboxByName{'Inbox'}->{id}; + $self->assert_not_null($inbox); + + xlog $self, "create emails"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'msgInbox' => { + mailboxIds => { + $inbox => JSON::true, + }, + from => [{ + name => '', email => 'from@local' + }], + to => [{ + name => '', email => 'to@local' + }], + subject => 'msgInbox', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'msgA' => { + mailboxIds => { + $mboxA => JSON::true, + }, + from => [{ + name => '', email => 'from@local' + }], + to => [{ + name => '', email => 'to@local' + }], + subject => 'msgA', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ], $using); + my $emailInbox = $res->[0][1]->{created}{msgInbox}{id}; + $self->assert_not_null($emailInbox); + my $emailA = $res->[0][1]->{created}{msgA}{id}; + $self->assert_not_null($emailA); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Running query with guidsearch"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + body => 'test', + inMailboxOtherThan => [ + $inbox, + ], + }], + }, + collapseThreads => JSON::true, + findAllInThread => JSON::true, + }, 'R1'] + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + my @wantIds = sort ($emailA); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_keywords b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_keywords new file mode 100644 index 0000000000..ae6f42f48e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_keywords @@ -0,0 +1,147 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_keywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "create emails"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'mA' => { + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + mailboxIds => { + '$inbox' => JSON::true, + }, + subject => 'Answered', + keywords => { + '$Answered' => JSON::true, + }, + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mD' => { + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + mailboxIds => { + '$inbox' => JSON::true, + }, + subject => 'Draft', + keywords => { + '$Draft' => JSON::true, + }, + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mF' => { + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + mailboxIds => { + '$inbox' => JSON::true, + }, + subject => 'Flagged', + keywords => { + '$Flagged' => JSON::true, + }, + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ], $using); + my $emailIdA = $res->[0][1]->{created}{mA}{id}; + $self->assert_not_null($emailIdA); + my $emailIdD = $res->[0][1]->{created}{mD}{id}; + $self->assert_not_null($emailIdD); + my $emailIdF = $res->[0][1]->{created}{mF}{id}; + $self->assert_not_null($emailIdF); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @wantIds; + + xlog $self, "query draft emails"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + from => 'foo@local', + hasKeyword => '$draft', + }, + sort => [{ + property => 'id', + isAscending => JSON::true, + }], + }, 'R1'], + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + @wantIds = sort ($emailIdD); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + + xlog $self, "query anything but draft emails"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + from => 'foo@local', + notKeyword => '$draft', + }, + sort => [{ + property => 'id', + isAscending => JSON::true, + }], + }, 'R1'], + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + @wantIds = sort ($emailIdA, $emailIdF); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_mixedfilter b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_mixedfilter new file mode 100644 index 0000000000..586a83824c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_mixedfilter @@ -0,0 +1,162 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_mixedfilter + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + $imap->create("INBOX.B") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ], $using); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $mboxByName{'A'}->{id}; + my $mboxIdB = $mboxByName{'B'}->{id}; + + xlog $self, "create emails"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'mAfoo' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'from@local' + }], + to => [{ + name => '', email => 'to@local' + }], + subject => 'foo', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mAbar' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'from@local' + }], + to => [{ + name => '', email => 'to@local' + }], + subject => 'bar', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mBfoo' => { + mailboxIds => { + $mboxIdB => JSON::true, + }, + from => [{ + name => '', email => 'from@local' + }], + to => [{ + name => '', email => 'to@local' + }], + subject => 'foo', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mBbar' => { + mailboxIds => { + $mboxIdB => JSON::true, + }, + from => [{ + name => '', email => 'from@local' + }], + to => [{ + name => '', email => 'to@local' + }], + subject => 'bar', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ], $using); + my $emailIdAfoo = $res->[0][1]->{created}{mAfoo}{id}; + $self->assert_not_null($emailIdAfoo); + my $emailIdAbar = $res->[0][1]->{created}{mAbar}{id}; + $self->assert_not_null($emailIdAbar); + my $emailIdBfoo = $res->[0][1]->{created}{mBfoo}{id}; + $self->assert_not_null($emailIdBfoo); + my $emailIdBbar = $res->[0][1]->{created}{mBbar}{id}; + $self->assert_not_null($emailIdBbar); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @wantIds; + + xlog $self, "query emails with disjunction of mixed criteria"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'OR', + conditions => [{ + subject => 'foo', + }, { + inMailbox => $mboxIdB, + }], + }, + sort => [{ + property => 'id', + isAscending => JSON::true, + }], + }, 'R1'], + ], $using); + + # Current Cyrus implementation of GUID search does not support + # disjunctions of Xapian and non-Xapian filters. This might change. + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + @wantIds = sort ($emailIdAfoo, $emailIdBfoo, $emailIdBbar); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_mixedfilter2 b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_mixedfilter2 new file mode 100644 index 0000000000..aea21b5201 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_mixedfilter2 @@ -0,0 +1,178 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_mixedfilter2 + :min_version_3_4 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['id'], + }, 'R1'], + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + } + }, 'R2'], + ], $using); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + my $mboxA = $res->[1][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[1][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + emailA => { + mailboxIds => { + $mboxA => JSON::true, + }, + subject => 'emailA', + from => [{ + email => 'fromA@local' + }] , + to => [{ + email => 'toA@local' + }] , + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'emailA', + } + }, + }, + emailB => { + mailboxIds => { + $mboxB => JSON::true, + }, + subject => 'emailB', + from => [{ + email => 'fromB@local' + }] , + to => [{ + email => 'toB@local' + }] , + cc => [{ + email => 'ccB@local' + }] , + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'emailB', + } + }, + }, + emailX => { + mailboxIds => { + $inbox => JSON::true, + }, + subject => 'emailX', + from => [{ + email => 'fromA@local' + }] , + to => [{ + email => 'toB@local' + }] , + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'emailX', + } + }, + }, + }, + }, 'R1'], + ], $using); + my $emailA = $res->[0][1]{created}{emailA}{id}; + $self->assert_not_null($emailA); + my $emailB = $res->[0][1]{created}{emailB}{id}; + $self->assert_not_null($emailB); + my $emailX = $res->[0][1]{created}{emailX}{id}; + $self->assert_not_null($emailX); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + 'operator' => 'AND', + 'conditions' => [ + { + 'operator' => 'OR', + 'conditions' => [ + { + 'from' => 'fromA@local', + }, + { + 'operator' => 'AND', + 'conditions' => [ + { + 'inMailbox' => $mboxB, + }, + { + 'operator' => 'OR', + 'conditions' => [ + { + 'to' => 'toB@local' + }, + { + 'cc' => 'ccB@local' + }, + { + 'bcc' => 'bccB@local' + }, + { + 'deliveredTo' => 'deliveredToB@local' + } + ] + } + ] + } + ] + }, + { + 'inMailboxOtherThan' => [ + $inbox + ] + } + ] + }, + sort => [{ property => 'id' }], + }, 'R1'], + ], $using); + + # All DNF-clauses of a guidsearch query with Xapian and non-Xapian criteria + # must contain the same non-Xapian criteria. + # This might change in the future. + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + my @wantIds = sort ( $emailA, $emailB ); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_only_email_mailboxes b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_only_email_mailboxes new file mode 100644 index 0000000000..5fc1486fe8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_only_email_mailboxes @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_only_email_mailboxes + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + 'https://cyrusimap.org/ns/jmap/calendars', + 'https://cyrusimap.org/ns/jmap/contacts', + ]; + + xlog $self, "create email, calendar event and contact"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + '1' => { + mailboxIds => { + '$inbox' => JSON::true, + }, + from => [{ + name => '', email => 'from@local' + }], + to => [{ + name => '', email => 'to@local' + }], + subject => 'test', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ['CalendarEvent/set', { + create => { + '2' => { + calendarIds => { + Default => JSON::true + }, + start => '2020-02-25T11:00:00', + timeZone => 'Australia/Melbourne', + title => 'test', + } + } + }, 'R2'], + ['Contact/set', { + create => { + "3" => { + lastName => "test", + } + } + }, 'R3'], + ], $using); + my $emailId = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($emailId); + my $eventId = $res->[1][1]->{created}{2}{id}; + $self->assert_not_null($eventId); + my $contactId = $res->[2][1]->{created}{3}{id}; + $self->assert_not_null($contactId); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query emails"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + text => 'test', + }, + }, 'R1'], + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals([$emailId], $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_scanmode b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_scanmode new file mode 100644 index 0000000000..54d2c11395 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_scanmode @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_scanmode + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions :SearchSetForceScanMode +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + for (my $i = 0; $i < 10; $i++) { + $self->make_message("msg$i", to => Cassandane::Address->new( + localpart => "recipient$i", + domain => 'example.com' + )) || die; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog "Running query with guidsearch"; + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + to => '@example.com', + }, + }, 'R1'] + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + my $guidSearchIds = $res->[0][1]{ids}; + $self->assert_num_equals(10, scalar @{$guidSearchIds}); + + xlog "Running query without guidsearch"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + to => '@example.com', + }, + disableGuidSearch => JSON::true, + }, 'R1'] + ], $using); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + my $uidSearchIds = $res->[0][1]{ids}; + $self->assert_num_equals(10, scalar @{$uidSearchIds}); + + xlog "Comparing results"; + $self->assert_deep_equals($guidSearchIds, $uidSearchIds); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_sort b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_sort new file mode 100644 index 0000000000..9858d3b1af --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_sort @@ -0,0 +1,194 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_sort + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $emailCount = 10; + + xlog "Creating $emailCount emails (every 5th has same internaldate)"; + my %createEmails; + for (my $i = 0; $i < $emailCount; $i++) { + my $receivedAt = '2019-01-0' . (($i % 5) + 1) . 'T00:00:00Z'; + $createEmails{$i} = { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => "foo$i\@bar" }], + to => [{ email => "bar$i\@example.com" }], + receivedAt => $receivedAt, + subject => "email$i", + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email$i body", + }, + }, + } + } + my $res = $jmap->CallMethods([ + ['Email/set', { + create => \%createEmails, + }, 'R1'], + ]); + $self->assert_num_equals($emailCount, scalar keys %{$res->[0][1]{created}}); + + my @emails; + for (my $i = 0; $i < $emailCount; $i++) { + $emails[$i] = { + id => $res->[0][1]{created}{$i}{id}, + receivedAt => $createEmails{$i}{receivedAt} + }; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Sort by id (ascending and descending)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + to => '@example.com', + }, + sort => [{ + property => 'id', + isAscending => JSON::true, + }] + }, 'R1'], + ['Email/query', { + filter => { + to => '@example.com', + }, + sort => [{ + property => 'id', + isAscending => JSON::false, + }] + }, 'R2'], + ['Email/query', { + filter => { + to => '@example.com', + }, + sort => [{ + property => 'id', + isAscending => JSON::true, + }], + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { + to => '@example.com', + }, + sort => [{ + property => 'id', + isAscending => JSON::false, + }], + disableGuidSearch => JSON::true, + }, 'R2'], + ], $using); + + my $guidSearchIds; + my @wantIds; + + # Check GUID search results + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + @wantIds = map { $_->{id} } sort { $a->{id} cmp $b->{id} } @emails; + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isGuidSearch}); + @wantIds = map { $_->{id} } sort { $b->{id} cmp $a->{id} } @emails; + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + + # Check UID search result + $self->assert_equals(JSON::false, $res->[2][1]{performance}{details}{isGuidSearch}); + @wantIds = map { $_->{id} } sort { $a->{id} cmp $b->{id} } @emails; + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); + + $self->assert_equals(JSON::false, $res->[3][1]{performance}{details}{isGuidSearch}); + @wantIds = map { $_->{id} } sort { $b->{id} cmp $a->{id} } @emails; + $self->assert_deep_equals(\@wantIds, $res->[3][1]{ids}); + + xlog "Sort by internaldate (break ties by id) (ascending and descending)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + to => '@example.com', + }, + sort => [{ + property => 'receivedAt', + isAscending => JSON::true, + }] + }, 'R1'], + ['Email/query', { + filter => { + to => '@example.com', + }, + sort => [{ + property => 'receivedAt', + isAscending => JSON::false, + }] + }, 'R2'], + ['Email/query', { + filter => { + to => '@example.com', + }, + sort => [{ + property => 'receivedAt', + isAscending => JSON::true, + }], + disableGuidSearch => JSON::true, + }, 'R3'], + ['Email/query', { + filter => { + to => '@example.com', + }, + sort => [{ + property => 'receivedAt', + isAscending => JSON::false, + }], + disableGuidSearch => JSON::true, + }, 'R4'], + ], $using); + + # Check GUID search results + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + @wantIds = map { $_->{id} } sort { + $a->{receivedAt} cmp $b->{receivedAt} or $b->{id} cmp $a->{id} + } @emails; + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isGuidSearch}); + @wantIds = map { $_->{id} } sort { + $b->{receivedAt} cmp $a->{receivedAt} or $b->{id} cmp $a->{id} + } @emails; + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + + # Check UID search result + $self->assert_equals(JSON::false, $res->[2][1]{performance}{details}{isGuidSearch}); + @wantIds = map { $_->{id} } sort { + $a->{receivedAt} cmp $b->{receivedAt} or $b->{id} cmp $a->{id} + } @emails; + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); + + $self->assert_equals(JSON::false, $res->[3][1]{performance}{details}{isGuidSearch}); + @wantIds = map { $_->{id} } sort { + $b->{receivedAt} cmp $a->{receivedAt} or $b->{id} cmp $a->{id} + } @emails; + $self->assert_deep_equals(\@wantIds, $res->[3][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_threadkeywords b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_threadkeywords new file mode 100644 index 0000000000..95ceb1c72b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_guidsearch_threadkeywords @@ -0,0 +1,148 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_guidsearch_threadkeywords + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name'], + }, "R1"] + ], $using); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + + xlog $self, "create emails"; + my %emails = ( + 'allthread1' => { + subject => 'allthread', + keywords => { + '$flagged' => JSON::true, + }, + messageId => ['allthread@local'], + }, + 'allthread2' => { + subject => 're: allthread', + keywords => { + '$flagged' => JSON::true, + }, + references => ['allthread@local'], + }, + 'somethread1' => { + subject => 'somethread', + keywords => { + '$flagged' => JSON::true, + }, + messageId => ['somethread@local'], + }, + 'somethread2' => { + subject => 're: somethread', + references => ['somethread@local'], + }, + 'nonethread1' => { + subject => 'nonethread', + messageId => ['nonethread@local'], + }, + 'nonethread2' => { + subject => 're: nonethread', + references => ['nonethread@local'], + }, + ); + + while (my ($key, $val) = each %emails) { + my $email = { + mailboxIds => { + $inbox => JSON::true, + }, + from => [{ + name => '', email => 'from@local' + }], + to => [{ + name => '', email => 'to@local' + }], + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }; + $email = { %$email, %$val }; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + $key => $email, + }, + }, 'R1'], + ], $using); + $self->assert_not_null($res->[0][1]->{created}{$key}{id}); + $val->{id} = $res->[0][1]->{created}{$key}{id}; + $self->assert_not_null($res->[0][1]->{created}{$key}{threadId}); + $val->{threadId} = $res->[0][1]->{created}{$key}{threadId}; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Running query with guidsearch"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => 'test', + allInThreadHaveKeyword => '$flagged', + }, + sort => [{ + property => 'id', + }], + }, 'R1'], + ['Email/query', { + filter => { + body => 'test', + someInThreadHaveKeyword => '$flagged', + }, + sort => [{ + property => 'id', + }], + }, 'R2'], + ['Email/query', { + filter => { + body => 'test', + noneInThreadHaveKeyword => '$flagged', + }, + sort => [{ + property => 'id', + }], + }, 'R3'], + ], $using); + + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + my @wantIds = sort $emails{allthread1}{id}, $emails{allthread2}{id}; + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isGuidSearch}); + @wantIds = sort $emails{somethread1}{id}, $emails{somethread2}{id}, + $emails{allthread1}{id}, $emails{allthread2}{id}; + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + @wantIds = sort ($emails{nonethread1}{id}, $emails{nonethread2}{id}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_header b/cassandane/tiny-tests/JMAPEmail/email_query_header new file mode 100644 index 0000000000..b63023e247 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_header @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_header + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + +use utf8; + + $self->make_message("xhdr1", + extra_headers => [['X-hdr', 'val1'], ['X-hdr', 'val2']], + body => "xhdr1" + ) || die; + $self->make_message("xhdr2", + extra_headers => [['X-hdr', 'val1']], + body => "xhdr2" + ) || die; + $self->make_message("xhdr3", + extra_headers => [['X-hdr', " s\xc3\xa4ge "]], + body => "xhdr3" + ) || die; + $self->make_message("subject1", + body => "subject1" + ) || die; + + xlog "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-Z'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'subject' ], + }, 'R2'], + ]); + my %id = map { $_->{subject} => $_->{id} } @{$res->[1][1]{list}}; + + my @testCases = ({ + desc => 'xhdr equals', + header => ['x-hdr', 'val2', 'equals'], + wantIds => [$id{'xhdr1'}], + }, { + desc => 'xhdr startsWith', + header => ['x-hdr', 'val', 'startsWith'], + wantIds => [$id{'xhdr1'}, $id{'xhdr2'}], + }, { + desc => 'xhdr endsWith', + header => ['x-hdr', 'al1', 'endsWith'], + wantIds => [$id{'xhdr1'}, $id{'xhdr2'}], + }, { + desc => 'xhdr contains', + header => ['x-hdr', 'al', 'contains'], + wantIds => [$id{'xhdr1'}, $id{'xhdr2'}], + }, { + desc => 'xhdr contains utf8 value', + header => ['x-hdr', 'SaGE', 'contains'], + wantIds => [$id{'xhdr3'}], + }, { + desc => 'subject contains ASCII', + header => ['subject', 'ubjec', 'contains'], + wantIds => [$id{'subject1'}], + }); + + foreach (@testCases) { + xlog "Running test: $_->{desc}"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + header => $_->{header}, + }, + sort => [{ property => 'subject' }], + }, 'R1'], + ]); + $self->assert_deep_equals($_->{wantIds}, $res->[0][1]{ids}); + } + +no utf8; +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_header_cost b/cassandane/tiny-tests/JMAPEmail/email_query_header_cost new file mode 100644 index 0000000000..dbb9e22c2a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_header_cost @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_header_cost + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $self->make_message() || die; + + xlog "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-Z'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + header => ['x-hdr', 'foo', 'contains'], + }, + }, 'R1'], + ['Email/query', { + filter => { + header => ['subject', 'foo', 'contains'], + }, + }, 'R2'], + ], $using); + $self->assert_deep_equals(['body'], + $res->[0][1]{performance}{details}{filters}); + $self->assert_deep_equals(['cache'], + $res->[1][1]{performance}{details}{filters}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_header_sieve b/cassandane/tiny-tests/JMAPEmail/email_query_header_sieve new file mode 100644 index 0000000000..528b314753 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_header_sieve @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_header_sieve + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions :AltNamespace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $imap->create("matches") or die; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "header" : [ "subject", "zzz", "endsWith" ] + } +. + ) +{ + fileinto "matches"; +} +EOF + ); + + xlog "Deliver matching message"; + my $msg1 = $self->{gen}->generate( + subject => 'xxxyyyzzz', + body => "msg1" + ); + $self->{instance}->deliver($msg1); + + xlog "Assert that message got moved into INBOX.matches"; + $self->{store}->set_folder('matches'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Deliver a non-matching message"; + my $msg2 = $self->{gen}->generate( + subject => 'zzzyyyyxxx', + body => "msg2" + ); + $self->{instance}->deliver($msg2); + $msg2->set_attribute(uid => 1); + + xlog "Assert that message got moved into INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_highpriority b/cassandane/tiny-tests/JMAPEmail/email_query_highpriority new file mode 100644 index 0000000000..6c1f233b5b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_highpriority @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_highpriority + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog "Append emails with and without priority"; + $self->make_message("msg1", + extra_headers => [['x-priority', '1']], + body => "msg1" + ) || die; + $self->make_message("msg2", + extra_headers => [['importance', 'high']], + body => "msg2" + ) || die; + $self->make_message("msg3", + body => "msg3" + ) || die; + + xlog "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-Z'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ + property => 'subject', + }], + }, 'R1'], + ], $using); + my @ids = @{$res->[0][1]{ids}}; + $self->assert_num_equals(3, scalar @ids); + + xlog "Query isHighPriority"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + isHighPriority => JSON::true, + }, + sort => [{ + property => 'subject', + }], + }, 'R1'], + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ + isHighPriority => JSON::true, + }], + }, + sort => [{ + property => 'subject', + }], + }, 'R2'], + ['Email/query', { + filter => { + isHighPriority => JSON::false, + }, + sort => [{ + property => 'subject', + }], + }, 'R3'], + ], $using); + $self->assert_deep_equals([$ids[0], $ids[1]], $res->[0][1]{ids}); + $self->assert_deep_equals([$ids[2]], $res->[1][1]{ids}); + $self->assert_deep_equals([$ids[2]], $res->[2][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_inmailbox_null b/cassandane/tiny-tests/JMAPEmail/email_query_inmailbox_null new file mode 100644 index 0000000000..8fd0ec6ead --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_inmailbox_null @@ -0,0 +1,20 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_inmailbox_null + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating email A"; + $self->make_message("Email A") or die; + + xlog $self, "call Email/query with null inMailbox"; + my $res = $jmap->CallMethods([['Email/query', { filter => { inMailbox => undef } }, "R1"]]); + $self->assert_str_equals("invalidArguments", $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_inmailboxid_conjunction b/cassandane/tiny-tests/JMAPEmail/email_query_inmailboxid_conjunction new file mode 100644 index 0000000000..1e1d8f3bb5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_inmailboxid_conjunction @@ -0,0 +1,149 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_inmailboxid_conjunction + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + $imap->create("INBOX.B") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $mboxByName{'A'}->{id}; + my $mboxIdB = $mboxByName{'B'}->{id}; + + xlog $self, "create emails"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'mAB' => { + mailboxIds => { + $mboxIdA => JSON::true, + $mboxIdB => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'AB', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mA' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'A', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mB' => { + mailboxIds => { + $mboxIdB => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'B', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ]); + my $emailIdAB = $res->[0][1]->{created}{mAB}{id}; + $self->assert_not_null($emailIdAB); + my $emailIdA = $res->[0][1]->{created}{mA}{id}; + $self->assert_not_null($emailIdA); + my $emailIdB = $res->[0][1]->{created}{mB}{id}; + $self->assert_not_null($emailIdB); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "query emails in mailboxes A AND B"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + inMailbox => $mboxIdA, + }, { + inMailbox => $mboxIdB, + }], + }, + disableGuidSearch => JSON::true, + }, 'R1'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailIdAB, $res->[0][1]->{ids}[0]); + + xlog $self, "query emails in mailboxes A AND B (forcing indexed search)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + inMailbox => $mboxIdA, + }, { + inMailbox => $mboxIdB, + }, { + text => "test", + }], + }, + disableGuidSearch => JSON::true, + }, 'R1'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailIdAB, $res->[0][1]->{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_inmailboxotherthan b/cassandane/tiny-tests/JMAPEmail/email_query_inmailboxotherthan new file mode 100644 index 0000000000..bfe916e13f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_inmailboxotherthan @@ -0,0 +1,116 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_inmailboxotherthan + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create mailboxes"; + $talk->create("INBOX.A") || die; + $talk->create("INBOX.B") || die; + $talk->create("INBOX.C") || die; + + $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $m{"A"}->{id}; + my $mboxIdB = $m{"B"}->{id}; + my $mboxIdC = $m{"C"}->{id}; + $self->assert_not_null($mboxIdA); + $self->assert_not_null($mboxIdB); + $self->assert_not_null($mboxIdC); + + xlog $self, "create emails"; + $store->set_folder("INBOX.A"); + $res = $self->make_message("email1") || die; + $talk->copy(1, "INBOX.B") || die; + $talk->copy(1, "INBOX.C") || die; + + $store->set_folder("INBOX.B"); + $self->make_message("email2") || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "fetch emails without filter"; + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + } + }, 'R2'], + ], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_num_equals(2, scalar @{$res->[1][1]->{list}}); + + %m = map { $_->{subject} => $_ } @{$res->[1][1]{list}}; + my $emailId1 = $m{"email1"}->{id}; + my $emailId2 = $m{"email2"}->{id}; + $self->assert_not_null($emailId1); + $self->assert_not_null($emailId2); + + $res = $jmap->CallMethods([['Email/query', { + filter => { + inMailboxOtherThan => [$mboxIdB], + }, + sort => [{ property => 'subject' }], + disableGuidSearch => JSON::true, + }, "R1"]], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[0]); + + $res = $jmap->CallMethods([['Email/query', { + filter => { + inMailboxOtherThan => [$mboxIdA], + }, + sort => [{ property => 'subject' }], + disableGuidSearch => JSON::true, + }, "R1"]], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($emailId2, $res->[0][1]->{ids}[1]); + + $res = $jmap->CallMethods([['Email/query', { + filter => { + inMailboxOtherThan => [$mboxIdA, $mboxIdC], + }, + sort => [{ property => 'subject' }], + disableGuidSearch => JSON::true, + }, "R1"]], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($emailId2, $res->[0][1]->{ids}[1]); + + $res = $jmap->CallMethods([['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ + inMailboxOtherThan => [$mboxIdB], + }], + }, + sort => [{ property => 'subject' }], + disableGuidSearch => JSON::true, + }, "R1"]], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId2, $res->[0][1]->{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_issue2905 b/cassandane/tiny-tests/JMAPEmail/email_query_issue2905 new file mode 100644 index 0000000000..05ca170f5f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_issue2905 @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_issue2905 + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPQueryCacheMaxAge1s +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "create emails"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "email1", + keywords => { + '$flagged' => JSON::true + }, + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email1 body", + }, + }, + }, + email2 => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => q{foo2@bar} }], + to => [{ email => q{bar2@foo} }], + subject => "email2", + keywords => { + '$flagged' => JSON::true + }, + bodyStructure => { + partId => '2', + }, + bodyValues => { + "2" => { + value => "email2 body", + }, + }, + }, + }, + }, 'R1'], + ]); + my $emailId1 = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId1); + my $emailId2 = $res->[0][1]{created}{email2}{id}; + $self->assert_not_null($emailId2); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + # Run query with mutable search + ['Email/query', { + filter => { + hasKeyword => '$flagged', + }, + }, 'R1'], + # Remove $flagged keyword from email2 + ['Email/set', { + update => { + $emailId2 => { + 'keywords/$flagged' => undef, + }, + }, + }, 'R2'], + # Re-run query with mutable search + ['Email/query', { + filter => { + hasKeyword => '$flagged', + }, + }, 'R3'], + ]); + + # Assert first query. + my $queryState = $res->[0][1]->{queryState}; + $self->assert_not_null($queryState); + $self->assert_equals(JSON::false, $res->[0][1]->{canCalculateChanges}); + + # Assert email update. + $self->assert(exists $res->[1][1]->{updated}{$emailId2}); + + # Assert second query. + $self->assert_str_not_equals($queryState, $res->[2][1]->{queryState}); + $self->assert_equals(JSON::false, $res->[2][1]->{canCalculateChanges}); + + $res = $jmap->CallMethods([ + ['Email/queryChanges', { + sinceQueryState => $queryState, + filter => { + hasKeyword => '$flagged', + }, + }, 'R1'] + ]); + + # Assert queryChanges error. + $self->assert_str_equals('cannotCalculateChanges', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_keywords b/cassandane/tiny-tests/JMAPEmail/email_query_keywords new file mode 100644 index 0000000000..2c60183a8e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_keywords @@ -0,0 +1,100 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_keywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create email"; + $res = $self->make_message("foo") || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch emails without filter"; + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $fooid = $res->[0][1]->{ids}[0]; + + xlog $self, "fetch emails with \$seen flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + hasKeyword => '$seen', + } + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "fetch emails without \$seen flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + notKeyword => '$seen', + } + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + + xlog $self, 'set $seen flag on email'; + $res = $jmap->CallMethods([['Email/set', { + update => { + $fooid => { + keywords => { '$seen' => JSON::true }, + }, + } + }, "R1"]]); + $self->assert(exists $res->[0][1]->{updated}{$fooid}); + + xlog $self, "fetch emails with \$seen flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + hasKeyword => '$seen', + } + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "fetch emails without \$seen flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + notKeyword => '$seen', + } + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "create email"; + $res = $self->make_message("bar") || die; + + xlog $self, "fetch emails without \$seen flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + notKeyword => '$seen', + } + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $barid = $res->[0][1]->{ids}[0]; + $self->assert_str_not_equals($fooid, $barid); + + xlog $self, "fetch emails sorted ascending by \$seen flag"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => 'hasKeyword', keyword => '$seen' }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($barid, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($fooid, $res->[0][1]->{ids}[1]); + + xlog $self, "fetch emails sorted descending by \$seen flag"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => 'hasKeyword', keyword => '$seen', isAscending => JSON::false }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($fooid, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($barid, $res->[0][1]->{ids}[1]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_language b/cassandane/tiny-tests/JMAPEmail/email_query_language new file mode 100644 index 0000000000..0b281c5138 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_language @@ -0,0 +1,214 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_language + :min_version_3_3 :needs_component_jmap :JMAPExtensions + :needs_component_sieve :SearchLanguage :needs_dependency_cld2 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + +use utf8; + + my @testEmailBodies = ({ + id => 'de', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => <<'EOF' +Jemand mußte Josef K. verleumdet haben, denn ohne daß er etwas Böses getan +hätte, wurde er eines Morgens verhaftet. Die Köchin der Frau Grubach, +seiner Zimmervermieterin, die ihm jeden Tag gegen acht Uhr früh das +Frühstück brachte, kam diesmal nicht. Das war noch niemals geschehen. K. +wartete noch ein Weilchen, sah von seinem Kopfkissen aus die alte Frau +die ihm gegenüber wohnte und die ihn mit einer an ihr ganz ungewöhnli +EOF + }, + }, + }, { + id => 'en', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => <<'EOF' +All human beings are born free and equal in dignity and rights. They are +endowed with reason and conscience and should act towards one another in +a spirit of brotherhood. Everyone has the right to life, liberty and security +of person. No one shall be held in slavery or servitude; slavery and the +slave trade shall be prohibited in all their forms. No one shall be +subjected to torture or to cruel, inhuman or degrading treatment or punishment. +EOF + }, + }, + }, { + id => 'fr', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => <<'EOF' +Hé quoi ! charmante Élise, vous devenez mélancolique, après les obligeantes +assurances que vous avez eu la bonté de me donner de votre foi ? Je vous +vois soupirer, hélas ! au milieu de ma joie ! Est-ce du regret, dites-moi, +de m'avoir fait heureux ? et vous repentez-vous de cet engagement où mes +feux ont pu vous contraindre ? +EOF + }, + }, + }, { + id => 'fr-and-de', + bodyStructure => { + type => 'multipart/mixed', + subParts => [{ + type => 'text/plain', + partId => 'part1', + }, { + type => 'text/plain', + partId => 'part2', + }], + }, + bodyValues => { + part1 => { + value => <<'EOF' +Non, Valère, je ne puis pas me repentir de tout ce que je fais pour +vous. Je m'y sens entraîner par une trop douce puissance, et je n'ai +pas même la force de souhaiter que les choses ne fussent pas. Mais, a +vous dire vrai, le succès me donne de l'inquiétude ; et je crains fort +de vous aimer un peu plus que je ne devrais. +EOF + }, + part2 => { + value => <<'EOF' +Pfingsten, das liebliche Fest, war gekommen! es grünten und blühten +Feld und Wald; auf Hügeln und Höhn, in Büschen und Hecken +Übten ein fröhliches Lied die neuermunterten Vögel; +Jede Wiese sproßte von Blumen in duftenden Gründen, +Festlich heiter glänzte der Himmel und farbig die Erde. +EOF + }, + }, + }); + +no utf8; + + my %emailIds; + foreach (@testEmailBodies) { + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + $_->{id} => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => 'foo@local' }], + to => [{ email => 'bar@local' }], + subject => $_->{id}, + bodyStructure => $_->{bodyStructure}, + bodyValues => $_->{bodyValues}, + }, + }, + }, 'R1'], + ], $using); + $emailIds{$_->{id}} = $res->[0][1]{created}{$_->{id}}{id}; + $self->assert_not_null($emailIds{$_->{id}}); + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + language => 'fr', + }, + }, 'R1'], + ['Email/query', { + filter => { + operator => 'OR', + conditions => [{ + language => 'de', + }, { + language => 'fr', + }], + }, + }, 'R2'], + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + language => 'de', + }, { + language => 'fr', + }], + }, + }, 'R3'], + ['Email/query', { + filter => { + language => 'en', + }, + }, 'R4'], + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ + language => 'de', + }], + }, + }, 'R5'], + ['Email/query', { + filter => { + language => 'chr', + }, + }, 'R6'], + ['Email/query', { + filter => { + language => 'xxxx', + }, + }, 'R7'], + ], $using); + + # fr + my @wantIds = sort ($emailIds{'fr'}, $emailIds{'fr-and-de'}); + my @gotIds = sort @{$res->[0][1]->{ids}}; + $self->assert_deep_equals(\@wantIds, \@gotIds); + + # OR de,fr + @wantIds = sort ($emailIds{'fr'}, $emailIds{'de'}, $emailIds{'fr-and-de'}); + @gotIds = sort @{$res->[1][1]->{ids}}; + $self->assert_deep_equals(\@wantIds, \@gotIds); + + # AND de,fr + $self->assert_deep_equals([$emailIds{'fr-and-de'}], $res->[2][1]->{ids}); + + # en + $self->assert_deep_equals([$emailIds{'en'}], $res->[3][1]->{ids}); + + # NOT de + @wantIds = sort ($emailIds{'en'}, $emailIds{'fr'}); + @gotIds = sort @{$res->[4][1]->{ids}}; + $self->assert_deep_equals(\@wantIds, \@gotIds); + + # chr + $self->assert_deep_equals([], $res->[5][1]->{ids}); + + # xxxx + $self->assert_str_equals('invalidArguments', $res->[6][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_language_french_contractions b/cassandane/tiny-tests/JMAPEmail/email_query_language_french_contractions new file mode 100644 index 0000000000..c4993d4f53 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_language_french_contractions @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_language_french_contractions + :min_version_3_3 :needs_component_jmap :JMAPExtensions + :needs_component_sieve :SearchLanguage :needs_dependency_cld2 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + +use utf8; + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true + }, + subject => "fr", + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => <<'EOF' +C'est dadaïste d'Amérique j’aime je l'aime Je m’appelle +n’est pas là qu’il s’escrit Je t’aime. +EOF + } + }, + }, + }, + }, 'R1'], + ], $using); + my $emailId = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId); + +no utf8; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my @tests = ({ + body => "c'est", + wantIds => [$emailId], + }, { + body => "est", + wantIds => [$emailId], + }, { + body => "p'est", + wantIds => [], + }, { + body => "amerique", + wantIds => [$emailId], + }, { + body => "s'appelle", + wantIds => [$emailId], + }, { + body => "il", + wantIds => [$emailId], + }); + + foreach (@tests) { + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => $_->{body}, + }, + }, 'R1'], + ]); + $self->assert_deep_equals($_->{wantIds}, $res->[0][1]{ids}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_language_stats b/cassandane/tiny-tests/JMAPEmail/email_query_language_stats new file mode 100644 index 0000000000..a0ca97d37d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_language_stats @@ -0,0 +1,112 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_language_stats + :min_version_3_1 :needs_component_jmap :needs_dependency_cld2 + :needs_component_sieve :SearchLanguage :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $body = "" + . "--boundary\r\n" + . "Content-Type: text/plain;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "Hoch oben in den L=C3=BCften =C3=BCber den reichgesegneten Landschaften des\r\n" + . "s=C3=BCdlichen Frankreichs schwebte eine gewaltige dunkle Kugel.\r\n" + . "\r\n" + . "Ein Luftballon war es, der, in der Nacht aufgefahren, eine lange\r\n" + . "Dauerfahrt antreten wollte.\r\n" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: text/plain;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "The Bellman, who was almost morbidly sensitive about appearances, used\r\n" + . "to have the bowsprit unshipped once or twice a week to be revarnished,\r\n" + . "and it more than once happened, when the time came for replacing it,\r\n" + . "that no one on board could remember which end of the ship it belonged to.\r\n" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: text/plain;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "Verri=C3=A8res est abrit=C3=A9e du c=C3=B4t=C3=A9 du nord par une haute mon=\r\n" + . "tagne, c'est une\r\n" + . "des branches du Jura. Les cimes bris=C3=A9es du Verra se couvrent de neige\r\n" + . "d=C3=A8s les premiers froids d'octobre. Un torrent, qui se pr=C3=A9cipite d=\r\n" + . "e la\r\n" + . "montagne, traverse Verri=C3=A8res avant de se jeter dans le Doubs et donne =\r\n" + . "le\r\n" + . "mouvement =C3=A0 un grand nombre de scies =C3=A0 bois; c'est une industrie =\r\n" + . "--boundary--\r\n"; + + $self->make_message("A multi-language email", + mime_type => "multipart/mixed", + mime_boundary => "boundary", + body => $body + ); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1' ] + ], $using); + $self->assert_deep_equals({ + iso => { + de => 1, + fr => 1, + en => 1, + }, + unknown => 0, + }, $res->[0][1]{languageStats}); +} +sub test_email_set_received_at + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => q{foo@bar} }], + to => [{ email => q{bar@foo} }], + receivedAt => '2019-05-02T03:15:00Z', + subject => "test", + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "A text body", + }, + }, + } + }, + }, 'R1'], + ['Email/get', { + ids => ['#email1'], + properties => ['receivedAt'], + }, 'R2'], + ]); + my $email = $res->[1][1]{list}[0]; + $self->assert_str_equals('2019-05-02T03:15:00Z', $email->{receivedAt}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_listid b/cassandane/tiny-tests/JMAPEmail/email_query_listid new file mode 100644 index 0000000000..b3376ffa41 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_listid @@ -0,0 +1,109 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_listid + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog "Append emails with list-id"; + $self->make_message("msg1", # RFC 2919 + extra_headers => [['list-id', "Foo "]], + body => "msg1" + ) || die; + $self->make_message("msg2", # as seen at Yahoo, Google, et al + extra_headers => [['list-id', 'list aaa@bbb.ccc; contact aaa-contact@bbb.ccc']], + body => "msg2" + ) || die; + $self->make_message("msg3", # as seen from sentry, just plain text + extra_headers => [['list-id', 'sub3.sub2.sub1.top']], + body => "msg3" + ) || die; + $self->make_message("msg4", # as seen in the wild + extra_headers => [['list-id', '"foo" "msg4" + ) || die; + $self->make_message("msg5", # as seen in the wild + extra_headers => [['list-id', '1234567890 list "msg5" + ) || die; + + xlog "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-Z'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ + property => 'subject', + }], + }, 'R1'], + ], $using); + my @ids = @{$res->[0][1]{ids}}; + $self->assert_num_equals(5, scalar @ids); + + my @testCases = ({ + desc => 'simple list-id', + listId => 'xxx.yyy.zzz', + wantIds => [$ids[0], $ids[3], $ids[4]], + }, { + desc => 'no substring search for list-id', + listId => 'yyy', + wantIds => [], + }, { + desc => 'no wildcard search for list-id', + listId => 'xxx.yyy.*', + wantIds => [], + }, { + desc => 'no substring search for list-id #2', + listId => 'foo', + wantIds => [], + }, { + desc => 'ignore whitespace', + listId => 'xxx . yyy . zzz', + wantIds => [$ids[0], $ids[3], $ids[4]], + }, { + desc => 'Groups-style list-id', + listId => 'aaa@bbb.ccc', + wantIds => [$ids[1]], + }, { + desc => 'Ignore contact in groups-style list-id', + listId => 'aaa-contact@bbb.ccc', + wantIds => [], + }, { + desc => 'Groups-style list-id with whitespace', + listId => 'aaa @ bbb . ccc', + wantIds => [$ids[1]], + }, { + desc => 'Also no substring search in groups-style list-id', + listId => 'aaa', + wantIds => [], + }, { + desc => 'unbracketed list-id', + listId => 'sub3.sub2.sub1.top', + wantIds => [$ids[2]], + }); + + foreach (@testCases) { + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + listId => $_->{listId}, + }, + sort => [{ property => 'subject' }], + }, 'R1'], + ], $using); + $self->assert_deep_equals($_->{wantIds}, $res->[0][1]{ids}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_long b/cassandane/tiny-tests/JMAPEmail/email_query_long new file mode 100644 index 0000000000..685189956d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_long @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_long + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + + my $imaptalk = $self->{store}->get_client(); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + for (1..100) { + $self->make_message("Email $_"); + } + + xlog $self, "list first 60 emails"; + $res = $jmap->CallMethods([['Email/query', { + limit => 60, + position => 0, + collapseThreads => JSON::true, + sort => [{ property => "id" }], + calculateTotal => JSON::true, + }, "R1"]]); + $self->assert_num_equals(60, scalar @{$res->[0][1]->{ids}}); + $self->assert_num_equals(100, $res->[0][1]->{total}); + $self->assert_num_equals(0, $res->[0][1]->{position}); + + xlog $self, "list 5 emails from offset 55 by anchor"; + $res = $jmap->CallMethods([['Email/query', { + limit => 5, + anchorOffset => 1, + anchor => $res->[0][1]->{ids}[55], + collapseThreads => JSON::true, + sort => [{ property => "id" }], + calculateTotal => JSON::true, + }, "R1"]]); + $self->assert_num_equals(5, scalar @{$res->[0][1]->{ids}}); + $self->assert_num_equals(100, $res->[0][1]->{total}); + $self->assert_num_equals(56, $res->[0][1]->{position}); + + my $ids = $res->[0][1]->{ids}; + my @subids; +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_mailbox_andor b/cassandane/tiny-tests/JMAPEmail/email_query_mailbox_andor new file mode 100644 index 0000000000..a6517dff55 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_mailbox_andor @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_mailbox_andor + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + mboxC => { + name => 'C', + }, + } + }, 'R1'], + ['Email/set', { + create => { + emailAB => { + mailboxIds => { + '#mboxA' => JSON::true, + '#mboxB' => JSON::true, + }, + subject => 'emailAB', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'emailAB', + } + }, + }, + }, + }, 'R2'], + ], $using); + my $mboxA = $res->[0][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[0][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + my $mboxC = $res->[0][1]{created}{mboxC}{id}; + $self->assert_not_null($mboxC); + my $emailId = $res->[1][1]{created}{emailAB}{id}; + $self->assert_not_null($emailId); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + inMailbox => $mboxA, + }, { + operator => 'OR', + conditions => [{ + inMailbox => $mboxB, + }, { + inMailbox => $mboxC, + }], + }], + }, + }, 'R1'], + ], $using); + + $self->assert_deep_equals([$emailId], $res->[0][1]{ids}); + $self->assert_equals(JSON::true, + $res->[0][1]{performance}{details}{isImapFolderSearch}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_messageid b/cassandane/tiny-tests/JMAPEmail/email_query_messageid new file mode 100644 index 0000000000..3c94ddf59a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_messageid @@ -0,0 +1,125 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_messageid + : needs_component_jmap : JMAPExtensions : needs_component_sieve { + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/debug'); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/mail'); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/performance'); + + my $mime = <<'EOF'; +From: from@local +To: to@local +Message-ID: +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +test +EOF + $mime =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $mime) || die $@; + + xlog $self, "run squatter"; + $self->{instance}->run_command({ cyrus => 1 }, 'squatter'); + + xlog $self, "Assert 'messageId' filter condition"; + + my $res = $jmap->CallMethods([ + [ 'Email/query', {}, 'R1' ], + ]); + my $emailId = $res->[0][1]{ids}[0]; + $self->assert_not_null($emailId); + + my $res = $jmap->CallMethods([ + [ + 'Email/query', + { + filter => { + messageId => 'foo@example.com', + }, + }, + 'R1' + ], + [ + 'Email/query', + { + filter => { + header => [ 'message-id', 'foo@example.com' ], + }, + }, + 'R2' + ], + ]); + $self->assert_deep_equals([$emailId], $res->[0][1]{ids}); + $self->assert_deep_equals(['xapian'], $res->[0][1]{performance}{details}{filters}); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals([$emailId], $res->[1][1]{ids}); + $self->assert_deep_equals(['cache'], $res->[1][1]{performance}{details}{filters}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + + xlog $self, "Assert 'messageId' filter in Sieve"; + + $imap->create("matches") or die; + $self->{instance}->install_sieve_script( + <<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "messageId" : "bar@example.com" + } +. + ) +{ + fileinto "matches"; +} +EOF + ); + + $mime = <<'EOF'; +From: from2@local +To: to2@local +Message-ID: +Subject: test2 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +test +EOF + $mime =~ s/\r?\n/\r\n/gs; + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + $self->assert_num_equals(1, $imap->message_count('matches')); + + xlog $self, "Assert 'messageId' filter on legacy index version falls back to cache"; + + my $xapdirs = ($self->{instance}->run_mbpath(-u => 'cassandane'))->{xapian}; + my $xdbpath = $xapdirs->{t1} . "/xapian"; + $self->{instance}->run_command( + {}, + 'xapian-metadata', 'set', $xdbpath, 'cyrus.db_version', '16,17' + ); + $res = $jmap->CallMethods([ + [ + 'Email/query', + { + filter => { + messageId => 'foo@example.com', + }, + }, + 'R1' + ], + ]); + $self->assert_deep_equals([$emailId], $res->[0][1]{ids}); + $self->assert_deep_equals(['cache'], $res->[0][1]{performance}{details}{filters}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_moved b/cassandane/tiny-tests/JMAPEmail/email_query_moved new file mode 100644 index 0000000000..37265614c0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_moved @@ -0,0 +1,149 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_moved + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + $imap->create("INBOX.B") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $mboxByName{'A'}->{id}; + my $mboxIdB = $mboxByName{'B'}->{id}; + + xlog $self, "create emails in mailbox A"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'msg1' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'message 1', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ['Email/set', { + create => { + 'msg2' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'message 2', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R2'], + ]); + my $emailId1 = $res->[0][1]->{created}{msg1}{id}; + $self->assert_not_null($emailId1); + my $emailId2 = $res->[1][1]->{created}{msg2}{id}; + $self->assert_not_null($emailId2); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "query emails"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + inMailbox => $mboxIdA, + text => 'message', + }, + sort => [{ + property => 'subject', + isAscending => JSON::true, + }], + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($emailId2, $res->[0][1]->{ids}[1]); + + xlog $self, "move msg2 to mailbox B"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId2 => { + mailboxIds => { + $mboxIdB => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId2}); + + xlog $self, "assert move"; + $res = $jmap->CallMethods([ + ['Email/get', { + ids => [$emailId1, $emailId2], + properties => ['mailboxIds'], + }, 'R1'], + ]); + $self->assert_str_equals($emailId1, $res->[0][1]{list}[0]{id}); + my $wantMailboxIds1 = { $mboxIdA => JSON::true }; + $self->assert_deep_equals($wantMailboxIds1, $res->[0][1]{list}[0]{mailboxIds}); + + $self->assert_str_equals($emailId2, $res->[0][1]{list}[1]{id}); + my $wantMailboxIds2 = { $mboxIdB => JSON::true }; + $self->assert_deep_equals($wantMailboxIds2, $res->[0][1]{list}[1]{mailboxIds}); + + xlog $self, "query emails"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + inMailbox => $mboxIdA, + text => 'message', + }, + }, 'R1'], + ['Email/query', { + filter => { + inMailbox => $mboxIdB, + text => 'message', + }, + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]->{ids}[0]); + $self->assert_num_equals(1, scalar @{$res->[1][1]->{ids}}); + $self->assert_str_equals($emailId2, $res->[1][1]->{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_multiple_to_cross_domain b/cassandane/tiny-tests/JMAPEmail/email_query_multiple_to_cross_domain new file mode 100644 index 0000000000..131c6809fd --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_multiple_to_cross_domain @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_multiple_to_cross_domain + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $account = undef; + my $store = $self->{store}; + my $mboxprefix = "INBOX"; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => $account }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create email1"; + my $msg1 = { + mailboxIds => { $inboxid => JSON::true }, + subject => 'msg1', + to => [ + { name => undef, email => "foo\@example.com" }, + { name => undef, email => "bar\@example.net" } + ] + }; + + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $msg1 }}, "R1"]]); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch emails without filter"; + $res = $jmap->CallMethods([ + ['Email/query', { accountId => $account }, 'R1'], + ['Email/get', { + accountId => $account, + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => [ 'subject', 'mailboxIds', 'to' ], + }, 'R2'], + ]); + + my %m = map { $_->{subject} => $_ } @{$res->[1][1]{list}}; + my $emailId1 = $m{"msg1"}->{id}; + $self->assert_not_null($emailId1); + + xlog $self, "filter to with mixed localpart and domain"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + to => 'foo@example.net' + } + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_negative_position b/cassandane/tiny-tests/JMAPEmail/email_query_negative_position new file mode 100644 index 0000000000..ced855dde6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_negative_position @@ -0,0 +1,116 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_negative_position + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPQueryCacheMaxAge1s :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Creating emails"; + foreach my $i (1..9) { + $self->make_message("test") || die; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query emails"; + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => 'id' }], + }, 'R1'], + ]); + my @emailIds = @{$res->[0][1]{ids}}; + $self->assert_num_equals(9, scalar @emailIds); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'https://cyrusimap.org/ns/jmap/debug', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + xlog "Query with negative position (in range)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -3, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -3, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R2'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -3, + limit => 2, + disableGuidSearch => JSON::false, + }, 'R3'], + ], $using); + my @wantIds = @emailIds[6..7]; + # Check UID search + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isCached}); + $self->assert_num_equals(6, $res->[0][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isCached}); + $self->assert_num_equals(6, $res->[1][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + # Check GUID search + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_num_equals(6, $res->[2][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); + + xlog "Create dummy message to invalidate query cache"; + $self->make_message("dummy") || die; + + xlog "Query with negative position (out of range)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -100, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -100, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R2'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -100, + limit => 2, + disableGuidSearch => JSON::false, + }, 'R3'], + ], $using); + @wantIds = @emailIds[0..1]; + # Check UID search + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isCached}); + $self->assert_num_equals(0, $res->[0][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isCached}); + $self->assert_num_equals(0, $res->[1][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + # Check GUID search + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_num_equals(0, $res->[2][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_negative_position_legacy b/cassandane/tiny-tests/JMAPEmail/email_query_negative_position_legacy new file mode 100644 index 0000000000..5f09942bd9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_negative_position_legacy @@ -0,0 +1,116 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_negative_position_legacy + :min_version_3_1 :max_version_3_4 :needs_component_sieve + :needs_component_jmap :JMAPSearchDBLegacy :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Creating emails"; + foreach my $i (1..9) { + $self->make_message("test") || die; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query emails"; + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => 'id' }], + }, 'R1'], + ]); + my @emailIds = @{$res->[0][1]{ids}}; + $self->assert_num_equals(9, scalar @emailIds); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'https://cyrusimap.org/ns/jmap/debug', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + xlog "Query with negative position (in range)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -3, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -3, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R2'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -3, + limit => 2, + disableGuidSearch => JSON::false, + }, 'R3'], + ], $using); + my @wantIds = @emailIds[6..7]; + # Check UID search + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isCached}); + $self->assert_num_equals(6, $res->[0][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isCached}); + $self->assert_num_equals(6, $res->[1][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + # Check GUID search + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_num_equals(6, $res->[2][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); + + xlog "Create dummy message to invalidate query cache"; + $self->make_message("dummy") || die; + + xlog "Query with negative position (out of range)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -100, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -100, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R2'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => -100, + limit => 2, + disableGuidSearch => JSON::false, + }, 'R3'], + ], $using); + @wantIds = @emailIds[0..1]; + # Check UID search + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isCached}); + $self->assert_num_equals(0, $res->[0][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isCached}); + $self->assert_num_equals(0, $res->[1][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + # Check GUID search + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_num_equals(0, $res->[2][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_no_guidsearch_ignore_jmapuploads b/cassandane/tiny-tests/JMAPEmail/email_query_no_guidsearch_ignore_jmapuploads new file mode 100644 index 0000000000..cc0727f801 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_no_guidsearch_ignore_jmapuploads @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_no_guidsearch_ignore_jmapuploads + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $store = $self->{store}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "Create Trash mailbox"; + $imap->create("Trash", "(USE (\\Trash))") || die; + + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sort => [{ + property => 'name', + }], + }, 'R1'], + ['Mailbox/get', { + '#ids' => { + resultOf => 'R1', + name => 'Mailbox/query', + path => '/ids', + }, + properties => ['name'], + }, 'R2'], + ], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + + my $inboxId = $res->[0][1]{ids}[0]; + my $trashId = $res->[0][1]{ids}[1]; + + xlog $self, "Create message in Inbox"; + $self->make_message('wantThisOne', body => 'blu blu'); + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ], $using); + my $wantEmailId = $res->[0][1]{ids}[0]; + $self->assert_not_null($wantEmailId); + + xlog $self, "Create message that exists both in Trash and #jmap"; + my $admin = $self->{adminstore}->get_client(); + $jmap->Upload('someblob', "text/plain"); + $store->set_folder('Trash'); + $self->make_message('dontWantThisOne', body => 'blu blu'); + $admin->select('user.cassandane.Trash'); + $admin->copy('1', 'user.cassandane.#jmap'); + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "Query emails exluding Trash"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + text => 'blu', + }, { + inMailboxOtherThan => [$trashId], + }], + }, + sort => [ + { + "isAscending" => JSON::false, + "property" => "receivedAt" + } + ], + disableGuidSearch => JSON::true, + }, 'R1'], + ], $using); + + xlog $self, "Assert that message from #jmap folder is not found"; + $self->assert_deep_equals([$wantEmailId], $res->[0][1]{ids}); + +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_not_match b/cassandane/tiny-tests/JMAPEmail/email_query_not_match new file mode 100644 index 0000000000..fbbf62a952 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_not_match @@ -0,0 +1,150 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_not_match + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "mboxA" => { + name => "A", + }, + "mboxB" => { + name => "B", + }, + "mboxC" => { + name => "C", + }, + } + }, "R1"] + ]); + my $mboxIdA = $res->[0][1]{created}{mboxA}{id}; + my $mboxIdB = $res->[0][1]{created}{mboxB}{id}; + my $mboxIdC = $res->[0][1]{created}{mboxC}{id}; + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + $mboxIdA => JSON::true + }, + from => [{ email => q{foo1@bar} }], + to => [{ email => q{bar1@foo} }], + subject => "email1", + keywords => { + keyword1 => JSON::true + }, + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email1 body", + }, + }, + }, + email2 => { + mailboxIds => { + $mboxIdB => JSON::true + }, + from => [{ email => q{foo2@bar} }], + to => [{ email => q{bar2@foo} }], + subject => "email2", + bodyStructure => { + partId => '2', + }, + bodyValues => { + "2" => { + value => "email2 body", + }, + }, + }, + email3 => { + mailboxIds => { + $mboxIdC => JSON::true + }, + from => [{ email => q{foo3@bar} }], + to => [{ email => q{bar3@foo} }], + subject => "email3", + bodyStructure => { + partId => '3', + }, + bodyValues => { + "3" => { + value => "email3 body", + }, + }, + } + }, + }, 'R1'], + ]); + my $emailId1 = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId1); + my $emailId2 = $res->[0][1]{created}{email2}{id}; + $self->assert_not_null($emailId2); + my $emailId3 = $res->[0][1]{created}{email3}{id}; + $self->assert_not_null($emailId3); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [{ + text => "email2", + }], + }, + sort => [{ property => "subject" }], + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]{ids}[0]); + $self->assert_str_equals($emailId3, $res->[0][1]{ids}[1]); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + operator => 'NOT', + conditions => [{ + text => "email1" + }], + }, { + operator => 'NOT', + conditions => [{ + text => "email3" + }], + }], + }, + sort => [{ property => "subject" }], + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($emailId2, $res->[0][1]{ids}[0]); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [{ + operator => 'NOT', + conditions => [{ + text => "email3" + }], + }, { + hasKeyword => 'keyword1', + }], + }, + sort => [{ property => "subject" }], + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($emailId1, $res->[0][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_notes b/cassandane/tiny-tests/JMAPEmail/email_query_notes new file mode 100644 index 0000000000..17a9256fe4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_notes @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; +use base qw(Cassandane::Cyrus::TestCase); + +sub test_email_query_notes + :min_version_3_1 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/notes' capability + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/notes'; + $jmap->DefaultUsing(\@using); + + # force creation of notes mailbox prior to creating notes + my $res = $jmap->CallMethods([ + ['Note/set', { + }, "R0"] + ]); + + xlog "create note"; + $res = $jmap->CallMethods([['Note/set', + { create => { "1" => {title => "foo"}, } }, + "R1"]]); + $self->assert_not_null($res); + my $note1 = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "query for notes"; + $res = $jmap->CallMethods([['Email/query', { }, "R1"], ]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_notinmailboxid_attached b/cassandane/tiny-tests/JMAPEmail/email_query_notinmailboxid_attached new file mode 100644 index 0000000000..596afe1c5e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_notinmailboxid_attached @@ -0,0 +1,214 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_notinmailboxid_attached + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + $imap->create("INBOX.B") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $mboxByName{'A'}->{id}; + my $mboxIdB = $mboxByName{'B'}->{id}; + + xlog $self, "create emails"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'mA' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'covfefe@local' + }], + to => [{ + name => '', email => 'dest@local' + }], + subject => 'AB', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'this email contains xyzzy', + } + }, + }, + }, + }, 'R1'] + ]); + + my $emailIdA = $res->[0][1]->{created}{mA}{id}; + my $blobA = $res->[0][1]{created}{mA}{blobId}; + $self->assert_not_null($emailIdA); + $self->assert_not_null($blobA); + + $res = $jmap->CallMethods([ + ['Email/set', { create => { mB => { + bcc => undef, + bodyStructure => { + subParts => [{ + partId => "text", + type => "text/plain" + },{ + blobId => $blobA, + disposition => "attachment", + type => "message/rfc822" + }], + type => "multipart/mixed", + }, + bodyValues => { + text => { + isTruncated => $JSON::false, + value => "Hello World", + }, + }, + cc => undef, + from => [{ + email => "foo\@example.com", + name => "Captain Foo", + }], + keywords => { + '$draft' => $JSON::true, + '$seen' => $JSON::true, + }, + mailboxIds => { + $mboxIdB => $JSON::true, + }, + messageId => ["9048d4db-bd84-4ea4-9be3-ae4a136c532d\@example.com"], + receivedAt => "2019-05-09T12:48:08Z", + references => undef, + replyTo => undef, + sentAt => "2019-05-09T14:48:08+02:00", + subject => "Hello again", + to => [{ + email => "bar\@example.com", + name => "Private Bar", + }], + }}}, "S1"], + ]); + my $emailIdB = $res->[0][1]->{created}{mB}{id}; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Run queries"; + $res = $jmap->CallMethods([ + ['Email/query', { + }, 'R1'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + }, + }, 'R2'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + inMailbox => $mboxIdA, + }, + }, 'R3'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + inMailboxOtherThan => [$mboxIdA], + }, + }, 'R4'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + inMailbox => $mboxIdB, + }, + }, 'R5'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + inMailboxOtherThan => [$mboxIdB], + }, + }, 'R6'], + ]); + + $self->assert_num_equals(2, scalar(@{$res->[0][1]{ids}})); + $self->assert_num_equals(1, scalar(@{$res->[1][1]{ids}})); + $self->assert_num_equals(1, scalar(@{$res->[2][1]{ids}})); + $self->assert_equals($emailIdA, $res->[2][1]{ids}[0]); + $self->assert_num_equals(0, scalar(@{$res->[3][1]{ids}})); + $self->assert_num_equals(0, scalar(@{$res->[4][1]{ids}})); + $self->assert_num_equals(1, scalar(@{$res->[5][1]{ids}})); + $self->assert_equals($emailIdA, $res->[5][1]{ids}[0]); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog "Run queries with extra using"; + $res = $jmap->CallMethods([ + ['Email/query', { + }, 'R1'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + }, + }, 'R2'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + inMailbox => $mboxIdA, + }, + }, 'R3'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + inMailboxOtherThan => [$mboxIdA], + }, + }, 'R4'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + inMailbox => $mboxIdB, + }, + }, 'R5'], + ['Email/query', { + filter => { + from => 'covfefe', + text => 'xyzzy', + inMailboxOtherThan => [$mboxIdB], + }, + }, 'R6'], + ], $using); + + $self->assert_num_equals(2, scalar(@{$res->[0][1]{ids}})); + $self->assert_num_equals(1, scalar(@{$res->[1][1]{ids}})); + $self->assert_num_equals(1, scalar(@{$res->[2][1]{ids}})); + $self->assert_equals($emailIdA, $res->[2][1]{ids}[0]); + $self->assert_num_equals(0, scalar(@{$res->[3][1]{ids}})); + $self->assert_num_equals(0, scalar(@{$res->[4][1]{ids}})); + $self->assert_num_equals(1, scalar(@{$res->[5][1]{ids}})); + $self->assert_equals($emailIdA, $res->[5][1]{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_position b/cassandane/tiny-tests/JMAPEmail/email_query_position new file mode 100644 index 0000000000..e452ef765b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_position @@ -0,0 +1,117 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_position + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPQueryCacheMaxAge1s :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Creating emails"; + foreach my $i (1..9) { + $self->make_message("test") || die; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query emails"; + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => 'id' }], + }, 'R1'], + ]); + my @emailIds = @{$res->[0][1]{ids}}; + $self->assert_num_equals(9, scalar @emailIds); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'https://cyrusimap.org/ns/jmap/debug', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + xlog "Query with positive position (in range)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 1, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 1, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R2'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 1, + limit => 2, + disableGuidSearch => JSON::false, + }, 'R3'], + ], $using); + my @wantIds = @emailIds[1..2]; + # Check UID search + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isCached}); + $self->assert_num_equals(1, $res->[0][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isCached}); + $self->assert_num_equals(1, $res->[1][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + # Check GUID search + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[2][1]{performance}{details}{isCached}); + $self->assert_num_equals(1, $res->[2][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); + + xlog "Create dummy message to invalidate query cache"; + $self->make_message("dummy") || die; + + xlog "Query with positive position (out of range)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 100, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 100, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R2'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 100, + limit => 2, + disableGuidSearch => JSON::false, + }, 'R3'], + ], $using); + @wantIds = (); + # Check UID search + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isCached}); + $self->assert_num_equals(9, $res->[0][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isCached}); + $self->assert_num_equals(9, $res->[1][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + # Check GUID search + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[2][1]{performance}{details}{isCached}); + $self->assert_num_equals(9, $res->[2][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_position_legacy b/cassandane/tiny-tests/JMAPEmail/email_query_position_legacy new file mode 100644 index 0000000000..7c2cd74789 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_position_legacy @@ -0,0 +1,117 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_position_legacy + :min_version_3_1 :max_version_3_4 :needs_component_jmap + :needs_component_sieve :JMAPSearchDBLegacy :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Creating emails"; + foreach my $i (1..9) { + $self->make_message("test") || die; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query emails"; + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => 'id' }], + }, 'R1'], + ]); + my @emailIds = @{$res->[0][1]{ids}}; + $self->assert_num_equals(9, scalar @emailIds); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'https://cyrusimap.org/ns/jmap/debug', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + xlog "Query with positive position (in range)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 1, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 1, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R2'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 1, + limit => 2, + disableGuidSearch => JSON::false, + }, 'R3'], + ], $using); + my @wantIds = @emailIds[1..2]; + # Check UID search + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isCached}); + $self->assert_num_equals(1, $res->[0][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isCached}); + $self->assert_num_equals(1, $res->[1][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + # Check GUID search + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[2][1]{performance}{details}{isCached}); + $self->assert_num_equals(1, $res->[2][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); + + xlog "Create dummy message to invalidate query cache"; + $self->make_message("dummy") || die; + + xlog "Query with positive position (out of range)"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 100, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 100, + limit => 2, + disableGuidSearch => JSON::true, + }, 'R2'], + ['Email/query', { + filter => { subject => 'test' }, + sort => [{ property => 'id' }], + position => 100, + limit => 2, + disableGuidSearch => JSON::false, + }, 'R3'], + ], $using); + @wantIds = (); + # Check UID search + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[0][1]{performance}{details}{isCached}); + $self->assert_num_equals(9, $res->[0][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::true, $res->[1][1]{performance}{details}{isCached}); + $self->assert_num_equals(9, $res->[1][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[1][1]{ids}); + # Check GUID search + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_equals(JSON::false, $res->[2][1]{performance}{details}{isCached}); + $self->assert_num_equals(9, $res->[2][1]{position}); + $self->assert_deep_equals(\@wantIds, $res->[2][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_punct_no_text b/cassandane/tiny-tests/JMAPEmail/email_query_punct_no_text new file mode 100644 index 0000000000..f6c65b7c27 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_punct_no_text @@ -0,0 +1,56 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_punct_no_text + :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + $imap->create("matches") or die; + + # Assert that punctuation-only terms in non-text criteria + # match nothing. Also see email_query_utf8punct_term. + + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +# Search: "from:\"=\"" +if allof( + not string :is "${stop}" "Y", + jmapquery text: + { + "conditions" : [ + { + "from" : "\"=\"" + } + ], + "operator" : "OR" + } +. +) { + fileinto "matches"; + set "stop" "Y"; +} +EOF + ); + + my $mime = <<'EOF'; +From: from@local +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain;charset=us-ascii +Content-Transfer-Encoding: 7bit + +hello +EOF + $mime =~ s/\r?\n/\r\n/gs; + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + + xlog "Assert that message did not match"; + $self->assert_num_equals(0, $imap->message_count('matches')); + $self->assert_num_equals(1, $imap->message_count('INBOX')); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_references_inreplyto b/cassandane/tiny-tests/JMAPEmail/email_query_references_inreplyto new file mode 100644 index 0000000000..d84ebe1058 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_references_inreplyto @@ -0,0 +1,178 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_references_inreplyto + : needs_component_jmap : JMAPExtensions : needs_component_sieve { + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/debug'); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/mail'); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/performance'); + + xlog $self, "Assert 'inReplyTo' and 'references' filter conditions"; + + my $res = $jmap->CallMethods([ [ + 'Email/set', + { + create => { + email1 => { + 'header:references' => ' ', + mailboxIds => { '$inbox' => JSON::true }, + from => [ { email => 'foo@local' } ], + to => [ { email => 'bar@local' } ], + subject => 'test1', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + } + }, + email2 => { + 'header:in-reply-to' => '', + mailboxIds => { '$inbox' => JSON::true }, + from => [ { email => 'foo@local' } ], + to => [ { email => 'bar@local' } ], + subject => 'test2', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + } + } + } + }, + 'createEmail' + ] ]); + my $email1Id = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($email1Id); + my $email2Id = $res->[0][1]{created}{email2}{id}; + $self->assert_not_null($email2Id); + + xlog $self, "run squatter"; + $self->{instance}->run_command({ cyrus => 1 }, 'squatter'); + + my $res = $jmap->CallMethods([ + [ + 'Email/query', + { + filter => { + references => 'refA@local', + }, + }, + 'R1' + ], + [ + 'Email/query', + { + filter => { + header => [ 'references', 'refA@local' ], + }, + }, + 'R2' + ], + [ + 'Email/query', + { + filter => { + references => 'refB@local', + }, + }, + 'R3' + ], + [ + 'Email/query', + { + filter => { + inReplyTo => 'replytoA@local', + }, + }, + 'R4' + ], + ]); + $self->assert_deep_equals([$email1Id], $res->[0][1]{ids}); + $self->assert_deep_equals(['xapian'], $res->[0][1]{performance}{details}{filters}); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + + $self->assert_deep_equals([$email1Id], $res->[1][1]{ids}); + $self->assert_deep_equals(['cache'], $res->[1][1]{performance}{details}{filters}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + + $self->assert_deep_equals([$email1Id], $res->[2][1]{ids}); + $self->assert_deep_equals(['xapian'], $res->[2][1]{performance}{details}{filters}); + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + + $self->assert_deep_equals([$email2Id], $res->[3][1]{ids}); + $self->assert_deep_equals(['xapian'], $res->[3][1]{performance}{details}{filters}); + $self->assert_equals(JSON::true, $res->[3][1]{performance}{details}{isGuidSearch}); + + xlog $self, "Assert 'inReplyTo' and 'references' filters in Sieve"; + + $imap->create("matches") or die; + $self->{instance}->install_sieve_script( + <<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "operator" : "OR", + "conditions": [{ + "references": "refC@local" + }, { + "inReplyTo": "replyToC@local" + }] + } +. + ) +{ + fileinto "matches"; +} +EOF + ); + + $mime = <<'EOF'; +From: foo@local +To: bar@local +Message-Id: <091e0683cc1a@example.com> +References: +Subject: sievetest1 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +test +EOF + $mime =~ s/\r?\n/\r\n/gs; + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + $self->assert_num_equals(1, $imap->message_count('matches')); + + $mime = <<'EOF'; +From: foo@local +To: bar@local +Message-Id: <2931db203612@example.com> +In-Reply-To: +Subject: sievetest2 +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain + +test +EOF + $mime =~ s/\r?\n/\r\n/gs; + $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + $self->assert_num_equals(2, $imap->message_count('matches')); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_seen_ignore_jmapupload_folder b/cassandane/tiny-tests/JMAPEmail/email_query_seen_ignore_jmapupload_folder new file mode 100644 index 0000000000..05de5272c5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_seen_ignore_jmapupload_folder @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_seen_ignore_jmapupload_folder + :min_version_3_7 :needs_component_jmap :JMAPExtensions :MagicPlus :AllowDeleted +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog 'Upload some blob to create upload folder'; + $jmap->Upload('test', 'application/octets') or die; + + xlog 'Upload MIME message'; + my $mime = <<'EOF'; +From: 'Some Example Sender' +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset='UTF-8' +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $mime =~ s/\r?\n/\r\n/gs; + my $blobId = $jmap->Upload($mime, 'message/rfc822')->{blobId}; + $self->assert_not_null($blobId); + + xlog "Undelete blobs in #jmap folder"; + $self->{instance}->run_command({ cyrus => 1 }, + 'unexpunge', '-a', '-d', 'user.cassandane.#jmap'); + + xlog 'Import message'; + my $res = $jmap->CallMethods([ + ['Email/import', { + emails => { + email1 => { + blobId => $blobId, + mailboxIds => { + '$inbox' => JSON::true, + }, + keywords => { + '$seen' => JSON::true, + }, + } + }, + }, 'R1'], + ]); + my $emailId = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($emailId); + + xlog 'Query email by $seen'; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + hasKeyword => '$seen', + }, + }, 'R1'], + ['Email/query', { + filter => { + allInThreadHaveKeyword => '$seen', + }, + }, 'R2'], + ['Email/query', { + filter => { + someInThreadHaveKeyword => '$seen', + }, + }, 'R2'], + ['Email/query', { + filter => { + noneInThreadHaveKeyword => '$seen', + }, + }, 'R4'], + ]); + $self->assert_deep_equals([$emailId], $res->[0][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[1][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[2][1]{ids}); + $self->assert_deep_equals([], $res->[3][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_seen_multimbox b/cassandane/tiny-tests/JMAPEmail/email_query_seen_multimbox new file mode 100644 index 0000000000..7d7763127e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_seen_multimbox @@ -0,0 +1,193 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_seen_multimbox + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog 'Create email in mailboxes A and B'; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + }, + }, 'R1'], + ['Email/set', { + create => { + email => { + mailboxIds => { + '#mboxA' => JSON::true, + '#mboxB' => JSON::true, + }, + from => [{ + email => 'from@local' + }], + subject => 'test', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + }, + }, + }, + }, + }, 'R2'], + ]); + my $mboxA = $res->[0][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[0][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + my $emailId = $res->[1][1]{created}{email}{id}; + $self->assert_not_null($emailId); + + xlog "Assert email is unseen"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + hasKeyword => '$seen', + }, + }, 'R1'], + ['Email/query', { + filter => { + inMailbox => $mboxA, + hasKeyword => '$seen', + }, + }, 'R2'], + ['Email/query', { + filter => { + inMailbox => $mboxB, + hasKeyword => '$seen', + }, + }, 'R3'], + ['Email/query', { + filter => { + notKeyword => '$seen', + }, + }, 'R4'], + ['Email/query', { + filter => { + inMailbox => $mboxA, + notKeyword => '$seen', + }, + }, 'R5'], + ['Email/query', { + filter => { + inMailbox => $mboxB, + notKeyword => '$seen', + }, + }, 'R6'], + ]); + $self->assert_deep_equals([], $res->[0][1]{ids}); + $self->assert_deep_equals([], $res->[1][1]{ids}); + $self->assert_deep_equals([], $res->[2][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[3][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[4][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[5][1]{ids}); + + xlog 'Set \Seen on message in mailbox A'; + $imap->select('A'); + $imap->store('1', '+flags', '(\Seen)'); + + xlog "Assert email still is unseen"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + hasKeyword => '$seen', + }, + }, 'R1'], + ['Email/query', { + filter => { + inMailbox => $mboxA, + hasKeyword => '$seen', + }, + }, 'R2'], + ['Email/query', { + filter => { + inMailbox => $mboxB, + hasKeyword => '$seen', + }, + }, 'R3'], + ['Email/query', { + filter => { + notKeyword => '$seen', + }, + }, 'R4'], + ['Email/query', { + filter => { + inMailbox => $mboxA, + notKeyword => '$seen', + }, + }, 'R5'], + ['Email/query', { + filter => { + inMailbox => $mboxB, + notKeyword => '$seen', + }, + }, 'R6'], + ]); + $self->assert_deep_equals([], $res->[0][1]{ids}); + $self->assert_deep_equals([], $res->[1][1]{ids}); + $self->assert_deep_equals([], $res->[2][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[3][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[4][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[5][1]{ids}); + + xlog 'Set \Seen on message in mailbox B'; + $imap->select('B'); + $imap->store('1', '+flags', '(\Seen)'); + + xlog "Assert email seen"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + hasKeyword => '$seen', + }, + }, 'R1'], + ['Email/query', { + filter => { + inMailbox => $mboxA, + hasKeyword => '$seen', + }, + }, 'R2'], + ['Email/query', { + filter => { + inMailbox => $mboxB, + hasKeyword => '$seen', + }, + }, 'R3'], + ['Email/query', { + filter => { + notKeyword => '$seen', + }, + }, 'R4'], + ['Email/query', { + filter => { + inMailbox => $mboxA, + notKeyword => '$seen', + }, + }, 'R5'], + ['Email/query', { + filter => { + inMailbox => $mboxB, + notKeyword => '$seen', + }, + }, 'R6'], + ]); + $self->assert_deep_equals([$emailId], $res->[0][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[1][1]{ids}); + $self->assert_deep_equals([$emailId], $res->[2][1]{ids}); + $self->assert_deep_equals([], $res->[3][1]{ids}); + $self->assert_deep_equals([], $res->[4][1]{ids}); + $self->assert_deep_equals([], $res->[5][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_seen_shared b/cassandane/tiny-tests/JMAPEmail/email_query_seen_shared new file mode 100644 index 0000000000..d71668fde8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_seen_shared @@ -0,0 +1,146 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_seen_shared + :min_version_3_5 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $admin = $self->{adminstore}->get_client(); + $admin->create("user.other"); + $admin->setacl("user.other", admin => 'lrswipkxtecdan') or die; + $admin->setacl("user.other", other => 'lrswipkxtecdn') or die; + $admin->setacl("user.other", cassandane => 'lrswipkxtecdn') or die; + + my $service = $self->{instance}->get_service("http"); + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + ]); + + xlog "create two emails in shared mailbox"; + my $res = $otherJmap->CallMethods([ + ['Email/set', { + create => { + 'email1' => { + mailboxIds => { + '$inbox' => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'email1', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'email2' => { + mailboxIds => { + '$inbox' => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'email2', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ]); + my $email1 = $res->[0][1]->{created}{email1}{id}; + $self->assert_not_null($email1); + my $email2 = $res->[0][1]->{created}{email2}{id}; + $self->assert_not_null($email2); + my @emailIds = sort ($email1, $email2); + + $res = $jmap->CallMethods([ + ['Email/set', { + accountId => 'other', + update => { + $email1 => { + keywords => { + '$seen' => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$email1}); + + my $methods = [ + ['Email/get', { + accountId => 'other', + ids => [$email1], + properties => [ 'keywords' ], + }, 'R1'], + ['Email/get', { + accountId => 'other', + ids => [$email2], + properties => [ 'keywords' ], + }, 'R2'], + ['Email/query', { + accountId => 'other', + filter => { + hasKeyword => '$seen', + }, + sort => [{ + property => 'id' + }], + }, 'R3'], + ['Email/query', { + accountId => 'other', + filter => { + notKeyword => '$seen', + }, + sort => [{ + property => 'id' + }], + }, 'R4'], + ]; + + $res = $otherJmap->CallMethods($methods); + $self->assert_deep_equals({}, + $res->[0][1]{list}[0]{keywords}); + $self->assert_deep_equals({}, + $res->[1][1]{list}[0]{keywords}); + $self->assert_deep_equals([], $res->[2][1]{ids}); + $self->assert_deep_equals(\@emailIds, $res->[3][1]{ids}); + + $res = $jmap->CallMethods($methods); + $self->assert_deep_equals({'$seen' => JSON::true}, + $res->[0][1]{list}[0]{keywords}); + $self->assert_deep_equals({}, + $res->[1][1]{list}[0]{keywords}); + $self->assert_deep_equals([$email1], $res->[2][1]{ids}); + $self->assert_deep_equals([$email2], $res->[3][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_shared b/cassandane/tiny-tests/JMAPEmail/email_query_shared new file mode 100644 index 0000000000..0b4275b54c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_shared @@ -0,0 +1,356 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $admintalk = $self->{adminstore}->get_client(); + $self->{instance}->create_user("test"); + $admintalk->setacl("user.test", "cassandane", "lrwkx") or die; + + # run tests for both the main and "test" account + foreach (undef, "test") { + my $account = $_; + my $store = defined $account ? $self->{adminstore} : $self->{store}; + my $mboxprefix = defined $account ? "user.$account" : "INBOX"; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => $account }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create mailboxes"; + $talk->create("$mboxprefix.A") || die; + $talk->create("$mboxprefix.B") || die; + $talk->create("$mboxprefix.C") || die; + + $res = $jmap->CallMethods([['Mailbox/get', { accountId => $account }, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxa = $m{"A"}->{id}; + my $mboxb = $m{"B"}->{id}; + my $mboxc = $m{"C"}->{id}; + $self->assert_not_null($mboxa); + $self->assert_not_null($mboxb); + $self->assert_not_null($mboxc); + + xlog $self, "create emails"; + my %params; + $store->set_folder("$mboxprefix.A"); + my $dtfoo = DateTime->new( + year => 2016, + month => 11, + day => 1, + hour => 7, + time_zone => 'Etc/UTC', + ); + my $bodyfoo = "A rather short email"; + %params = ( + date => $dtfoo, + body => $bodyfoo, + store => $store, + ); + $res = $self->make_message("foo", %params) || die; + $talk->copy(1, "$mboxprefix.C") || die; + + $store->set_folder("$mboxprefix.B"); + my $dtbar = DateTime->new( + year => 2016, + month => 3, + day => 1, + hour => 19, + time_zone => 'Etc/UTC', + ); + my $bodybar = "" + . "In the context of electronic mail, emails are viewed as having an\r\n" + . "envelope and contents. The envelope contains whatever information is\r\n" + . "needed to accomplish transmission and delivery. (See [RFC5321] for a\r\n" + . "discussion of the envelope.) The contents comprise the object to be\r\n" + . "delivered to the recipient. This specification applies only to the\r\n" + . "format and some of the semantics of email contents. It contains no\r\n" + . "specification of the information in the envelope.i\r\n" + . "\r\n" + . "However, some email systems may use information from the contents\r\n" + . "to create the envelope. It is intended that this specification\r\n" + . "facilitate the acquisition of such information by programs.\r\n" + . "\r\n" + . "This specification is intended as a definition of what email\r\n" + . "content format is to be passed between systems. Though some email\r\n" + . "systems locally store emails in this format (which eliminates the\r\n" + . "need for translation between formats) and others use formats that\r\n" + . "differ from the one specified in this specification, local storage is\r\n" + . "outside of the scope of this specification.\r\n"; + + %params = ( + date => $dtbar, + body => $bodybar, + extra_headers => [ + ['x-tra', "baz"], + ], + store => $store, + ); + $self->make_message("bar", %params) || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch emails without filter"; + $res = $jmap->CallMethods([ + ['Email/query', { accountId => $account }, 'R1'], + ['Email/get', { + accountId => $account, + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } + }, 'R2'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_num_equals(2, scalar @{$res->[1][1]->{list}}); + + %m = map { $_->{subject} => $_ } @{$res->[1][1]{list}}; + my $foo = $m{"foo"}->{id}; + my $bar = $m{"bar"}->{id}; + $self->assert_not_null($foo); + $self->assert_not_null($bar); + + xlog $self, "filter text"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + text => "foo", + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter NOT text"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + operator => "NOT", + conditions => [ {text => "foo"} ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailbox A"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + inMailbox => $mboxa, + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailboxes"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + operator => 'OR', + conditions => [ + { + inMailbox => $mboxa, + }, + { + inMailbox => $mboxc, + }, + ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailboxes with not in"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + inMailboxOtherThan => [$mboxb], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailboxes with not in"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + inMailboxOtherThan => [$mboxa], + }, + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "filter mailboxes with not in"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + inMailboxOtherThan => [$mboxa, $mboxc], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter mailboxes"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + operator => 'AND', + conditions => [ + { + inMailbox => $mboxa, + }, + { + inMailbox => $mboxb, + }, + { + inMailbox => $mboxc, + }, + ], + }, + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "filter not in mailbox A"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + operator => 'NOT', + conditions => [ + { + inMailbox => $mboxa, + }, + ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by before"; + my $dtbefore = $dtfoo->clone()->subtract(seconds => 1); + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + before => $dtbefore->strftime('%Y-%m-%dT%TZ'), + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by after", + my $dtafter = $dtbar->clone()->add(seconds => 1); + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + after => $dtafter->strftime('%Y-%m-%dT%TZ'), + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by after and before", + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + after => $dtafter->strftime('%Y-%m-%dT%TZ'), + before => $dtbefore->strftime('%Y-%m-%dT%TZ'), + }, + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "filter by minSize"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + minSize => length($bodybar), + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by maxSize"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + maxSize => length($bodybar), + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by header"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + header => [ "x-tra" ], + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + + xlog $self, "filter by header and value"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + filter => { + header => [ "x-tra", "bam" ], + }, + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "sort by ascending receivedAt"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "receivedAt" }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by descending receivedAt"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "receivedAt", isAscending => JSON::false, }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by ascending size"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "size" }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by descending size"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "size", isAscending => JSON::false }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($bar, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($foo, $res->[0][1]->{ids}[1]); + + xlog $self, "sort by ascending id"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "id" }], + }, "R1"]]); + my @ids = sort ($foo, $bar); + $self->assert_deep_equals(\@ids, $res->[0][1]->{ids}); + + xlog $self, "sort by descending id"; + $res = $jmap->CallMethods([['Email/query', { + accountId => $account, + sort => [{ property => "id", isAscending => JSON::false }], + }, "R1"]]); + @ids = reverse sort ($foo, $bar); + $self->assert_deep_equals(\@ids, $res->[0][1]->{ids}); + + xlog $self, "delete mailboxes"; + $talk->delete("$mboxprefix.A") or die; + $talk->delete("$mboxprefix.B") or die; + $talk->delete("$mboxprefix.C") or die; + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_shared_move b/cassandane/tiny-tests/JMAPEmail/email_query_shared_move new file mode 100644 index 0000000000..9c9b2f4bde --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_shared_move @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_shared_move + :min_version_3_5 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $admintalk = $self->{adminstore}->get_client(); + + # Share account + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lr") or die; + + # Create mailbox A + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lr") or die; + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + $self->make_message("Email", store => $self->{adminstore}) or die; + + my $res = $jmap->Call('Email/get', { + accountId => 'other', + ids => [], + }); + $self->assert_not_null($res); + my $oldState = $res->{state}; + + # Move the message to invisible shared folder (leaving a + # removed instance in the visibile folder) + $admintalk->create("user.other.B") or die; + $admintalk->setacl("user.other.B", "cassandane", "") or die; + $admintalk->move(1, "user.other.B"); + + # Fetch Changes + $res = $jmap->Call('Email/changes', { + accountId => 'other', + sinceState => $oldState, + }); + $self->assert_not_null($res); + $self->assert_num_equals(0, scalar @{$res->{created}}); + $self->assert_num_equals(1, scalar @{$res->{destroyed}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_sieve_8bit_header b/cassandane/tiny-tests/JMAPEmail/email_query_sieve_8bit_header new file mode 100644 index 0000000000..3ae5e79c3f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_sieve_8bit_header @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_sieve_8bit_header + :min_version_3_9 :needs_component_jmap :needs_component_sieve :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $imap->create("matches") or die; + + xlog "Assert that message got moved into INBOX.matches"; + $imap->select('matches'); + $self->assert_num_equals(0, $imap->get_response_code('exists')); + $imap->unselect(); + + # "subject" : "płatność" + +use utf8; + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "subject" : "płatność" + } +. + ) +{ + fileinto "matches"; +} +EOF + ); + + my $mime = <<'EOF'; +From: from@local +To: to@local +Subject: test płatność +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain;charset=us-ascii +Content-Transfer-Encoding: 7bit + +hello + +EOF + $mime =~ s/\r?\n/\r\n/gs; +no utf8; + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + + xlog "Assert that message got moved into INBOX.matches"; + $imap->select('matches'); + $self->assert_num_equals(1, $imap->get_response_code('exists')); + $imap->unselect(); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_sieve_some_in_thread_have_keyword b/cassandane/tiny-tests/JMAPEmail/email_query_sieve_some_in_thread_have_keyword new file mode 100644 index 0000000000..bca50946bf --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_sieve_some_in_thread_have_keyword @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_sieve_some_in_thread_have_keyword + :needs_component_jmap :needs_component_sieve :ConversationMaxThread10 +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Set up Sieve script"; + $imap->create("matches") or die; + my $sieve = <{instance}->install_sieve_script($sieve); + + xlog $self, "Create split conversation"; + my $messageId = 'messageid1@example.com'; + $self->make_message('Email A', messageid => $messageId); + my $convMaxthread = $self->{instance}->{config}->get('conversations_max_thread'); + foreach (1 .. 2 * $convMaxthread - 1) { + $self->make_message("Re: Email A", + references => [ "<$messageId>" ], + ); + } + + xlog $self, "Set flag on message in first conversation split"; + my $res = $imap->store($convMaxthread - 1, '+flags', '($IsMailingList)'); + + my $mime = < +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain;charset=us-ascii +Content-Transfer-Encoding: 7bit + +hello +EOF + $mime =~ s/\r?\n/\r\n/gs; + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + + xlog $self, "Deliver message for conversation"; + $msg->remove_headers('Message-Id'); + $msg->set_headers('Message-Id', '<4bb20c19-3a9d-483f@local>'); + $self->{instance}->deliver($msg); + + xlog $self, "Assert that someInThreadHaveKeyword did not match"; + # XXX Might be OK to match here. But that's not how it is implemented. + $self->assert_num_equals(0, $imap->message_count('matches')); + + xlog $self, "Set flag on message in second conversation split"; + my $res = $imap->store($convMaxthread + 1, '+flags', '($IsMailingList)'); + + xlog $self, "Deliver message for conversation"; + $msg->remove_headers('Message-Id'); + $msg->set_headers('Message-Id', ''); + $self->{instance}->deliver($msg); + + xlog $self, "Assert that someInThreadHaveKeyword did match"; + $self->assert_num_equals(1, $imap->message_count('matches')); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_sieve_some_in_thread_have_keyword_utf8_subject b/cassandane/tiny-tests/JMAPEmail/email_query_sieve_some_in_thread_have_keyword_utf8_subject new file mode 100644 index 0000000000..76d4b96831 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_sieve_some_in_thread_have_keyword_utf8_subject @@ -0,0 +1,105 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_sieve_some_in_thread_have_keyword_utf8_subject + : needs_component_jmap : needs_component_sieve : NoMunge8Bit : RFC2047_UTF8 { + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Set up Sieve script"; + $imap->create("matches") or die; + my $sieve = <{instance}->install_sieve_script($sieve); + + xlog $self, "Create message with UTF-8 subject"; + my $mime = <<'EOF'; +From: alice@local +To: bob@local +Subject: ¡Hola, señor! +Message-ID: <1390d09d-60f7-4840-88a2-9f319024b156@local> +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain;charset=us-ascii +Content-Transfer-Encoding: 7bit + +hello +EOF + $mime =~ s/\r?\n/\r\n/gs; + $imap->append('matches', $mime); + + xlog $self, "Set keyword on message"; + my $res = $jmap->CallMethods([ [ 'Email/query', {}, 'R1' ] ]); + my $emailId = $res->[0][1]{ids}[0]; + $self->assert_not_null($emailId); + $res = $jmap->CallMethods([ [ + 'Email/set', + { + update => { + $emailId => { + 'keywords/$muted' => JSON::true, + }, + } + }, + 'R1' + ] ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + + xlog $self, "Deliver reply with encoded UTF-8 subject"; + my $mime = <<'EOF'; +From: bob@local +To: alic@local +Subject: =?utf-8?q?Re:_=C2=A1Hola,_se=C3=B1or!?= +Message-ID: <0d7c0d81-f9b8-41aa-8d05-edd12deda48c@local> +In-Reply-To: <1390d09d-60f7-4840-88a2-9f319024b156@local> +Date: Mon, 13 Apr 2020 16:24:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain;charset=us-ascii +Content-Transfer-Encoding: 7bit + +hi there! +EOF + $mime =~ s/\r?\n/\r\n/gs; + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + + xlog $self, "Assert that threadId and mailboxIds match"; + $res = $jmap->CallMethods([ + [ 'Email/query', {}, 'R1' ], + [ + 'Email/get', + { + '#ids' => { + resultOf => 'R1', + path => '/ids', + name => 'Email/query', + }, + properties => [ 'subject', 'mailboxIds', 'keywords', 'threadId' ], + }, + 'R2' + ], + ]); + $self->assert_num_equals(2, scalar @{ $res->[0][1]{ids} }); + $self->assert_str_equals( + $res->[1][1]{list}[0]{threadId}, + $res->[1][1]{list}[1]{threadId} + ); + $self->assert_deep_equals( + $res->[1][1]{list}[0]{mailboxIds}, + $res->[1][1]{list}[1]{mailboxIds} + ); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_snippets b/cassandane/tiny-tests/JMAPEmail/email_query_snippets new file mode 100644 index 0000000000..a4a3aa837b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_snippets @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_snippets + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + + my $imaptalk = $self->{store}->get_client(); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating email A"; + $exp{A} = $self->make_message("Email A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch email and snippet"; + $res = $jmap->CallMethods([ + ['Email/query', { filter => { text => "email" }}, "R1"], + ['SearchSnippet/get', { + '#emailIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + '#filter' => { + resultOf => 'R1', + name => 'Email/query', + path => '/filter', + }, + }, 'R2'], + ]); + + my $snippet = $res->[1][1]{list}[0]; + $self->assert_not_null($snippet); + $self->assert_num_not_equals(-1, index($snippet->{subject}, "Email A")); + + xlog $self, "fetch email and snippet with no filter"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['SearchSnippet/get', { + '#emailIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + }, 'R2'], + ]); + $snippet = $res->[1][1]{list}[0]; + $self->assert_not_null($snippet); + $self->assert_null($snippet->{subject}); + $self->assert_null($snippet->{preview}); + + xlog $self, "fetch email and snippet with no text filter"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => "OR", + conditions => [{minSize => 1}, {maxSize => 1}] + }, + }, "R1"], + ['SearchSnippet/get', { + '#emailIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + '#filter' => { + resultOf => 'R1', + name => 'Email/query', + path => '/filter', + }, + }, 'R2'], + ]); + + $snippet = $res->[1][1]{list}[0]; + $self->assert_not_null($snippet); + $self->assert_null($snippet->{subject}); + $self->assert_null($snippet->{preview}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_snooze b/cassandane/tiny-tests/JMAPEmail/email_query_snooze new file mode 100644 index 0000000000..a59e277b83 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_snooze @@ -0,0 +1,144 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_snooze + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd + :needs_component_sieve :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # snoozed property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + xlog $self, "Get mailbox id of Inbox"; + my $res = $jmap->CallMethods([['Mailbox/query', + {filter => {role => 'inbox'}}, "R1"]]); + my $inbox = $res->[0][1]->{ids}[0]; + + xlog $self, "create snooze mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "snoozed", + parentId => undef, + role => "snoozed" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $snoozedmbox = $res->[0][1]{created}{"1"}{id}; + + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => 30)); + my $datestr1 = $maildate->strftime('%Y-%m-%dT%TZ'); + + my $draft1 = { + mailboxIds => { $snoozedmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo1", + snoozed => { "until" => "$datestr1" }, + }; + + $maildate->add(DateTime::Duration->new(seconds => -15)); + my $datestr2 = $maildate->strftime('%Y-%m-%dT%TZ'); + + my $draft2 = { + mailboxIds => { $snoozedmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo2", + snoozed => { "until" => "$datestr2" }, + }; + + $maildate->add(DateTime::Duration->new(seconds => 30)); + my $datestr3 = $maildate->strftime('%Y-%m-%dT%TZ'); + + my $draft3 = { + mailboxIds => { $snoozedmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo3", + snoozed => { "until" => "$datestr3" }, + }; + + $maildate->add(DateTime::Duration->new(seconds => -1)); + my $datestr4 = $maildate->strftime('%Y-%m-%dT%TZ'); + + my $draft4 = { + mailboxIds => { $snoozedmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo4", + snoozed => { "until" => "$datestr4" }, + }; + + $maildate->add(DateTime::Duration->new(seconds => 10)); + my $datestr5 = $maildate->strftime('%Y-%m-%dT%TZ'); + + my $draft5 = { + mailboxIds => { $inbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo5", + receivedAt => "$datestr5", + }; + + $maildate->add(DateTime::Duration->new(seconds => -5)); + my $datestr6 = $maildate->strftime('%Y-%m-%dT%TZ'); + + my $draft6 = { + mailboxIds => { $inbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo6", + receivedAt => "$datestr6", + }; + + xlog $self, "Create 6 drafts"; + $res = $jmap->CallMethods([['Email/set', + { create => + { "1" => $draft1, + "2" => $draft2, + "3" => $draft3, + "4" => $draft4, + "5" => $draft5, + "6" => $draft6 }}, "R1"]]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + my $id2 = $res->[0][1]{created}{"2"}{id}; + my $id3 = $res->[0][1]{created}{"3"}{id}; + my $id4 = $res->[0][1]{created}{"4"}{id}; + my $id5 = $res->[0][1]{created}{"5"}{id}; + my $id6 = $res->[0][1]{created}{"6"}{id}; + + xlog $self, "sort by ascending snoozedUntil"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => "snoozedUntil", + mailboxId => "$snoozedmbox" }], + }, "R1"]]); + $self->assert_num_equals(6, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($id2, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($id1, $res->[0][1]->{ids}[1]); + $self->assert_str_equals($id4, $res->[0][1]->{ids}[2]); + $self->assert_str_equals($id3, $res->[0][1]->{ids}[3]); + $self->assert_str_equals($id6, $res->[0][1]->{ids}[4]); + $self->assert_str_equals($id5, $res->[0][1]->{ids}[5]); + + xlog $self, "sort by descending snoozedUntil"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => "snoozedUntil", + mailboxId => "$snoozedmbox", + isAscending => JSON::false }], + }, "R1"]]); + $self->assert_num_equals(6, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($id5, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($id6, $res->[0][1]->{ids}[1]); + $self->assert_str_equals($id3, $res->[0][1]->{ids}[2]); + $self->assert_str_equals($id4, $res->[0][1]->{ids}[3]); + $self->assert_str_equals($id1, $res->[0][1]->{ids}[4]); + $self->assert_str_equals($id2, $res->[0][1]->{ids}[5]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_sort_break_tie b/cassandane/tiny-tests/JMAPEmail/email_query_sort_break_tie new file mode 100644 index 0000000000..ff8d265fd3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_sort_break_tie @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_sort_break_tie + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $emailCount = 10; + my %createEmails; + for (my $i = 1; $i <= $emailCount; $i++) { + $createEmails{$i} = { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => "from\@local" }], + to => [{ email => "to\@local" }], + subject => "email$i", + receivedAt => sprintf('2020-03-25T10:%02d:00Z', $i), + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email$i body", + }, + }, + } + } + my $res = $jmap->CallMethods([ + ['Email/set', { + create => \%createEmails, + }, 'R1'], + ]); + $self->assert_num_equals($emailCount, scalar keys %{$res->[0][1]{created}}); + my @wantEmailIds; + # Want emails returned in descending receivedAt. + for (my $i = $emailCount; $i >= 1; $i--) { + push @wantEmailIds, $res->[0][1]{created}{$i}{id}; + } + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Run queries"; + $res = $jmap->CallMethods([ + ['Email/query', { + }, 'R1'], + ['Email/query', { + sort => [{ + property => 'from', + }], + }, 'R2'], + ['Email/query', { + filter => { + body => 'body', + }, + }, 'R3'], + ], $using); + + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals(\@wantEmailIds, $res->[0][1]{ids}); + $self->assert_equals(JSON::false, $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals(\@wantEmailIds, $res->[1][1]{ids}); + $self->assert_equals(JSON::true, $res->[2][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals(\@wantEmailIds, $res->[2][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_text_nomail b/cassandane/tiny-tests/JMAPEmail/email_query_text_nomail new file mode 100644 index 0000000000..a9cc99d6ba --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_text_nomail @@ -0,0 +1,15 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_text_nomail + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "search for some text"; + my $res = $jmap->CallMethods([['Email/query', { filter => { text => 'foo' } }, "R1"]]); + + # check that the query succeeded + $self->assert_str_equals($res->[0][0], "Email/query"); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_threadkeywords b/cassandane/tiny-tests/JMAPEmail/email_query_threadkeywords new file mode 100644 index 0000000000..aec411965d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_threadkeywords @@ -0,0 +1,158 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_threadkeywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + + my $imaptalk = $self->{store}->get_client(); + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + my $convflags = $self->{instance}->{config}->get('conversations_counted_flags'); + if (not defined $convflags) { + xlog $self, "conversations_counted_flags not configured. Skipping test"; + return; + } + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my %params = (store => $store); + $store->set_folder("INBOX"); + + xlog $self, "generating email A"; + $exp{A} = $self->make_message("Email A", %params); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B", %params); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + %params = ( + references => [ $exp{A} ], + store => $store, + ); + $exp{C} = $self->make_message("Re: Email A", %params); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch email ids"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2' ], + ]); + my %m = map { $_->{subject} => $_ } @{$res->[1][1]{list}}; + my $msga = $m{"Email A"}; + my $msgb = $m{"Email B"}; + my $msgc = $m{"Re: Email A"}; + $self->assert_not_null($msga); + $self->assert_not_null($msgb); + $self->assert_not_null($msgc); + + my @flags = split ' ', $convflags; + foreach (@flags) { + my $flag = $_; + next if lc $flag eq '$hasattachment'; # special case + + xlog $self, "Testing for counted conversation flag $flag"; + $flag =~ s+^\\+\$+ ; + + xlog $self, "fetch collapsed threads with some $flag flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + someInThreadHaveKeyword => $flag, + }, + collapseThreads => JSON::true, + }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "set $flag flag on email email A"; + $res = $jmap->CallMethods([['Email/set', { + update => { + $msga->{id} => { + keywords => { $flag => JSON::true }, + }, + } + }, "R1"]]); + + xlog $self, "fetch collapsed threads with some $flag flag"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + someInThreadHaveKeyword => $flag, + }, + collapseThreads => JSON::true, + }, "R1"], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert( + ($msga->{id} eq $res->[0][1]->{ids}[0]) or + ($msgc->{id} eq $res->[0][1]->{ids}[0]) + ); + + xlog $self, "fetch collapsed threads with no $flag flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + noneInThreadHaveKeyword => $flag, + }, + collapseThreads => JSON::true, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($msgb->{id}, $res->[0][1]->{ids}[0]); + + xlog $self, "fetch collapsed threads sorted ascending by $flag"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => "someInThreadHaveKeyword", keyword => $flag }], + collapseThreads => JSON::true, + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($msgb->{id}, $res->[0][1]->{ids}[0]); + $self->assert( + ($msga->{id} eq $res->[0][1]->{ids}[1]) or + ($msgc->{id} eq $res->[0][1]->{ids}[1]) + ); + + xlog $self, "fetch collapsed threads sorted descending by $flag"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => "someInThreadHaveKeyword", keyword => $flag, isAscending => JSON::false }], + collapseThreads => JSON::true, + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert( + ($msga->{id} eq $res->[0][1]->{ids}[0]) or + ($msgc->{id} eq $res->[0][1]->{ids}[0]) + ); + $self->assert_str_equals($msgb->{id}, $res->[0][1]->{ids}[1]); + + xlog $self, 'reset keywords on email email A'; + $res = $jmap->CallMethods([['Email/set', { + update => { + $msga->{id} => { + keywords => { }, + }, + } + }, "R1"]]); + } + + # test that 'someInThreadHaveKeyword' filter fail + # with an 'cannotDoFilter' error for flags that are not defined + # in the conversations_counted_flags config option + xlog $self, "fetch collapsed threads with unsupported flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + someInThreadHaveKeyword => 'notcountedflag', + }, + collapseThreads => JSON::true, + }, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('unsupportedFilter', $res->[0][1]->{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_toaddress b/cassandane/tiny-tests/JMAPEmail/email_query_toaddress new file mode 100644 index 0000000000..40db4efb4b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_toaddress @@ -0,0 +1,137 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_toaddress + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + $imap->create("INBOX.B") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $mboxByName{'A'}->{id}; + my $mboxIdB = $mboxByName{'B'}->{id}; + + xlog $self, "create emails"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'mAB' => { + mailboxIds => { + $mboxIdA => JSON::true, + $mboxIdB => JSON::true, + }, + from => [{ + name => '', email => 'bar@local' + }], + to => [{ + name => '', email => 'xyzzy@remote' + }], + subject => 'AB', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mA' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'A', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mB' => { + mailboxIds => { + $mboxIdB => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'B', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ]); + my $emailIdAB = $res->[0][1]->{created}{mAB}{id}; + $self->assert_not_null($emailIdAB); + my $emailIdA = $res->[0][1]->{created}{mA}{id}; + $self->assert_not_null($emailIdA); + my $emailIdB = $res->[0][1]->{created}{mB}{id}; + $self->assert_not_null($emailIdB); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "query emails that are to bar"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'AND', + conditions => [ + { + operator => 'OR', + conditions => [ + { "to" => 'bar@local' }, + { "cc" => 'bar@local' }, + { "bcc" => 'bar@local' }, + ], + }, + { "text" => "test" }, + { "inMailboxOtherThan" => [ $mboxIdB ] }, + ], + }, + disableGuidSearch => JSON::true, + }, 'R1'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($emailIdA, $res->[0][1]->{ids}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_toplevel_calendar b/cassandane/tiny-tests/JMAPEmail/email_query_toplevel_calendar new file mode 100644 index 0000000000..f469eb0097 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_toplevel_calendar @@ -0,0 +1,78 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_toplevel_calendar + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $rawMessage = <<'EOF'; +From: from@local +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/calendar; charset="UTF-8" + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:event +ORGANIZER:mailto:organizer@local +ATTENDEE:mailto:attendee@local +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + xlog $self, 'run squatter'; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + from => 'organizer@local', + }, + }, 'R1'], + ['Email/query', { + filter => { + to => 'attendee@local', + }, + }, 'R2'], + ['Email/query', { + filter => { + from => 'from@local', + }, + }, 'R3'], + ['Email/query', { + filter => { + to => 'to@local', + }, + }, 'R4'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{ids}}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{ids}}); + $self->assert_num_equals(1, scalar @{$res->[3][1]{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_toplevel_calendar_sieve b/cassandane/tiny-tests/JMAPEmail/email_query_toplevel_calendar_sieve new file mode 100644 index 0000000000..c30e2312c5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_toplevel_calendar_sieve @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_toplevel_calendar_sieve + :min_version_3_5 :needs_component_jmap :JMAPExtensions + :needs_component_sieve +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + $imap->create("INBOX.matches") or die; + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "from" : "from@local" + } +. + ) +{ + fileinto "INBOX.matches"; +} +EOF + ); + + my $rawMessage = <<'EOF'; +From: from@local +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/calendar; charset="UTF-8" + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.5//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART:20160928T160000Z +DTEND:20160928T170000Z +UID:2a358cee-6489-4f14-a57f-c104db4dc357 +DTSTAMP:20150928T132434Z +CREATED:20150928T125212Z +SUMMARY:event +ORGANIZER:mailto:organizer@local +ATTENDEE:mailto:attendee@local +LAST-MODIFIED:20150928T132434Z +END:VEVENT +END:VCALENDAR +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $rawMessage); + $self->{instance}->deliver($msg); + $self->assert_num_equals(1, $imap->message_count('INBOX.matches')); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_unicodefdfx b/cassandane/tiny-tests/JMAPEmail/email_query_unicodefdfx new file mode 100644 index 0000000000..7c1a440c06 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_unicodefdfx @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_unicodefdfx + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :SearchLanguage +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # Unicode block FDFX for Arabic contains some code points that + # make Cyrus search form blow up the stem word length over the + # allowed limit of 200 bytes. This test asserts that Cyrus doesn't + # choke on these and still indexes the unstemmed form. + + my $binary = slurp_file(abs_path('data/mime/unicodefdfx.eml')); + my $data = $jmap->Upload($binary, "message/rfc822"); + my $blobId = $data->{blobId}; + + my $res = $jmap->CallMethods([ + ['Email/import', { + emails => { + "1" => { + blobId => $blobId, + mailboxIds => { + '$inbox' => JSON::true}, + }, + }, + }, "R1"] + ]); + $self->assert_not_null($res->[0][1]{created}{1}); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + # As seen in the wild: multiple U+FDFA codepoints without separating + # spaces. The unstemmed form in UTF-8 is about 30 bytes long, but + # the stemmed term in Cyrus search form is 270 bytes long. + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => "" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}" . + "\N{ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM}", + }, + }, 'R1'] + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_unknown_mailbox b/cassandane/tiny-tests/JMAPEmail/email_query_unknown_mailbox new file mode 100644 index 0000000000..c1c60b523b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_unknown_mailbox @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_unknown_mailbox + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "filter inMailbox with unknown mailbox"; + $res = $jmap->CallMethods([['Email/query', { filter => { inMailbox => "foo" } }, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); + $self->assert_str_equals('filter/inMailbox', $res->[0][1]{arguments}[0]); + + xlog $self, "filter inMailboxOtherThan with unknown mailbox"; + $res = $jmap->CallMethods([['Email/query', { filter => { inMailboxOtherThan => ["foo"] } }, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); + $self->assert_str_equals('filter/inMailboxOtherThan[0:foo]', $res->[0][1]{arguments}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_userkeywords b/cassandane/tiny-tests/JMAPEmail/email_query_userkeywords new file mode 100644 index 0000000000..067496bcc3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_userkeywords @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_userkeywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "create email foo"; + my $res = $self->make_message("foo") || die; + + xlog $self, "fetch foo's id"; + $res = $jmap->CallMethods([['Email/query', { }, "R1"]]); + my $fooid = $res->[0][1]->{ids}[0]; + $self->assert_not_null($fooid); + + xlog $self, 'set foo flag on email foo'; + $res = $jmap->CallMethods([['Email/set', { + update => { + $fooid => { + keywords => { 'foo' => JSON::true }, + }, + } + }, "R1"]]); + $self->assert(exists $res->[0][1]->{updated}{$fooid}); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch emails with foo flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + hasKeyword => 'foo', + } + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($fooid, $res->[0][1]->{ids}[0]); + + xlog $self, "create email bar"; + $res = $self->make_message("bar") || die; + + xlog $self, "fetch emails without foo flag"; + $res = $jmap->CallMethods([['Email/query', { + filter => { + notKeyword => 'foo', + } + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $barid = $res->[0][1]->{ids}[0]; + $self->assert_str_not_equals($barid, $fooid); + + xlog $self, "fetch emails sorted ascending by foo flag"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => 'hasKeyword', keyword => 'foo' }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($barid, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($fooid, $res->[0][1]->{ids}[1]); + + xlog $self, "fetch emails sorted descending by foo flag"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => 'hasKeyword', keyword => 'foo', isAscending => JSON::false }], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($fooid, $res->[0][1]->{ids}[0]); + $self->assert_str_equals($barid, $res->[0][1]->{ids}[1]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_utf8punct_term b/cassandane/tiny-tests/JMAPEmail/email_query_utf8punct_term new file mode 100644 index 0000000000..de0b73bd02 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_utf8punct_term @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; +use Encode qw(decode encode); +use JSON qw(encode_json); + +sub test_email_query_utf8punct_term + :needs_component_jmap :needs_component_sieve :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/performance'); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/debug'); + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/mail'); + + xlog $self, "Create MIME message containing a non-ASCII punctuation char"; + my $mime = <<"EOF"; +From: +To: +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Transfer-Encoding: 8BIT +Content-Type: text/plain; charset=utf-8 + +hello \N{U+2013} world +EOF + $mime =~ s/\r?\n/\r\n/gs; + $mime = encode('utf-8', $mime); + $imap->append('INBOX', $mime) || die $@; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $filter = { + operator => 'AND', + conditions => [{ + body => 'hello' + }, { + body => "\N{U+2013}", + }, { + body => 'world', + }], + }; + + xlog $self, "Assert Email/query ignores punctuation character in filter"; + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => $filter, + disableGuidSearch => JSON::true, + }, 'R1'], + ['Email/query', { + filter => $filter, + }, 'R2'], + ]); + $self->assert_equals(JSON::false, + $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_equals(JSON::true, + $res->[1][1]{performance}{details}{isGuidSearch}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{ids}}); + + xlog $self, "Assert JMAP Sieve ignores punctuation character in filter"; + $imap->create("matches") or die; + my $filterAsStr = encode_json($filter); + $self->{instance}->install_sieve_script(<<"EOF" +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + $filterAsStr +. + ) +{ + fileinto "matches"; +} +EOF + ); + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + + $imap->select('matches'); + $self->assert_num_equals(1, $imap->get_response_code('exists')); + $imap->unselect(); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_window b/cassandane/tiny-tests/JMAPEmail/email_query_window new file mode 100644 index 0000000000..d73bdea7cb --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_window @@ -0,0 +1,10 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_window + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + $self->email_query_window_internal(); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_window_cached b/cassandane/tiny-tests/JMAPEmail/email_query_window_cached new file mode 100644 index 0000000000..37af0af49c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_window_cached @@ -0,0 +1,10 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_window_cached + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPQueryCacheMaxAge1s :JMAPExtensions +{ + my ($self) = @_; + $self->email_query_window_internal(); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_query_window_guidsearch b/cassandane/tiny-tests/JMAPEmail/email_query_window_guidsearch new file mode 100644 index 0000000000..2115c228b8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_query_window_guidsearch @@ -0,0 +1,19 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_query_window_guidsearch + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + + # guidsearch supports calculating total if version >= 3.5 + my ($maj, $min) = Cassandane::Instance->get_version(); + my $calculateTotal = ($maj > 3 || ($maj == 3 && $min >= 5)) ? JSON::true : JSON::false; + + $self->email_query_window_internal( + wantGuidSearch => JSON::true, + calculateTotal => $calculateTotal, + filter => { subject => 'Email'}, + ); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges b/cassandane/tiny-tests/JMAPEmail/email_querychanges new file mode 100644 index 0000000000..ab7311f491 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("Email A") || die; + + xlog $self, "Get email id"; + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + + $state = $res->[0][1]->{queryState}; + + $self->make_message("Email B") || die; + + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + + my ($idb) = grep { $_ ne $ida } @{$res->[0][1]->{ids}}; + + xlog $self, "get email list updates"; + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state }, "R1"]]); + + $self->assert_equals($idb, $res->[0][1]{added}[0]{id}); + + xlog $self, "get email list updates with threads collapsed"; + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state, collapseThreads => JSON::true }, "R1"]]); + + $self->assert_equals($idb, $res->[0][1]{added}[0]{id}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic b/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic new file mode 100644 index 0000000000..69d382d68b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_basic + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $draftsmbox; + + xlog $self, "Generate some email in INBOX via IMAP"; + $self->make_message("Email A") || die; + $self->make_message("Email B") || die; + $self->make_message("Email C") || die; + $self->make_message("Email D") || die; + + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + }, 'R1']]); + + $talk->select("INBOX"); + $talk->store("3", "+flags", "(\\Flagged)"); + + my $old = $res->[0][1]; + + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + sinceQueryState => $old->{queryState}, + }, 'R2']]); + + my $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + $self->assert_num_equals(1, scalar @{$new->{added}}); + $self->assert_num_equals(1, scalar @{$new->{removed}}); + $self->assert_str_equals($new->{removed}[0], $new->{added}[0]{id}); + $self->assert_str_equals($new->{removed}[0], $old->{ids}[$new->{added}[0]{index}]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_collapse b/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_collapse new file mode 100644 index 0000000000..92a7a9feed --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_collapse @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_basic_collapse + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $draftsmbox; + + xlog $self, "Generate some email in INBOX via IMAP"; + $self->make_message("Email A") || die; + $self->make_message("Email B") || die; + $self->make_message("Email C") || die; + $self->make_message("Email D") || die; + + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + collapseThreads => $JSON::true, + }, 'R1']]); + + $talk->select("INBOX"); + $talk->store("3", "+flags", "(\\Flagged)"); + + my $old = $res->[0][1]; + + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + collapseThreads => $JSON::true, + sinceQueryState => $old->{queryState}, + }, 'R2']]); + + my $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + $self->assert_num_equals(1, scalar @{$new->{added}}); + $self->assert_num_equals(1, scalar @{$new->{removed}}); + $self->assert_str_equals($new->{removed}[0], $new->{added}[0]{id}); + $self->assert_str_equals($new->{removed}[0], $old->{ids}[$new->{added}[0]{index}]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_mb b/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_mb new file mode 100644 index 0000000000..6d5418dcc6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_mb @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_basic_mb + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "Generate some email in INBOX via IMAP"; + $self->make_message("Email A") || die; + $self->make_message("Email B") || die; + $self->make_message("Email C") || die; + $self->make_message("Email D") || die; + + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + }, 'R1']]); + + $talk->select("INBOX"); + $talk->store("3", "+flags", "(\\Flagged)"); + + my $old = $res->[0][1]; + + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + sinceQueryState => $old->{queryState}, + }, 'R2']]); + + my $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + $self->assert_num_equals(1, scalar @{$new->{added}}); + $self->assert_num_equals(1, scalar @{$new->{removed}}); + $self->assert_str_equals($new->{removed}[0], $new->{added}[0]{id}); + $self->assert_str_equals($new->{removed}[0], $old->{ids}[$new->{added}[0]{index}]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_mb_collapse b/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_mb_collapse new file mode 100644 index 0000000000..5ab8761616 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_basic_mb_collapse @@ -0,0 +1,124 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_basic_mb_collapse + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "Generate some email in INBOX via IMAP"; + $self->make_message("Email A") || die; + $self->make_message("Email B") || die; + $self->make_message("Email C") || die; + $self->make_message("Email D") || die; + + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + }, 'R1']]); + + $talk->select("INBOX"); + $talk->store("3", "+flags", "(\\Flagged)"); + $self->assert_equals(JSON::true, $res->[0][1]{canCalculateChanges}); + + my $old = $res->[0][1]; + + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + sinceQueryState => $old->{queryState}, + ##upToId => $old->{ids}[3], + }, 'R2']]); + + my $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + # with collased threads we have to check + $self->assert_num_equals(1, scalar @{$new->{added}}); + $self->assert_num_equals(1, scalar @{$new->{removed}}); + $self->assert_str_equals($new->{removed}[0], $new->{added}[0]{id}); + $self->assert_str_equals($new->{removed}[0], $old->{ids}[$new->{added}[0]{index}]); + + xlog $self, "now with upto past"; + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + sinceQueryState => $old->{queryState}, + upToId => $old->{ids}[3], + }, 'R2']]); + + $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + $self->assert_num_equals(1, scalar @{$new->{added}}); + $self->assert_num_equals(1, scalar @{$new->{removed}}); + $self->assert_str_equals($new->{removed}[0], $new->{added}[0]{id}); + $self->assert_str_equals($new->{removed}[0], $old->{ids}[$new->{added}[0]{index}]); + + xlog $self, "now with upto equal"; + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + sinceQueryState => $old->{queryState}, + upToId => $old->{ids}[2], + }, 'R2']]); + + $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + $self->assert_num_equals(1, scalar @{$new->{added}}); + $self->assert_num_equals(1, scalar @{$new->{removed}}); + $self->assert_str_equals($new->{removed}[0], $new->{added}[0]{id}); + $self->assert_str_equals($new->{removed}[0], $old->{ids}[$new->{added}[0]{index}]); + + xlog $self, "now with upto early"; + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + sinceQueryState => $old->{queryState}, + upToId => $old->{ids}[1], + }, 'R2']]); + + $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + $self->assert_num_equals(0, scalar @{$new->{added}}); + $self->assert_num_equals(0, scalar @{$new->{removed}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_deletedcopy b/cassandane/tiny-tests/JMAPEmail/email_querychanges_deletedcopy new file mode 100644 index 0000000000..efaa1bb7c2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_deletedcopy @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_deletedcopy + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "Generate some email in INBOX via IMAP"; + $self->make_message("Email A") || die; + $self->make_message("Email B") || die; + $self->make_message("Email C") || die; + $self->make_message("Email D") || die; + + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + }, 'R1']]); + + $talk->create("INBOX.foo"); + $talk->select("INBOX"); + $talk->move("2", "INBOX.foo"); + $talk->select("INBOX.foo"); + $talk->move("1", "INBOX"); + $talk->select("INBOX"); + $talk->store("2", "+flags", "(\\Flagged)"); + + # order is now A (B) C D B, and (B), C and B are "changed" + + my $old = $res->[0][1]; + + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + sinceQueryState => $old->{queryState}, + }, 'R2']]); + + my $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + # with collased threads we have to check + $self->assert_num_equals(2, scalar @{$new->{added}}); + $self->assert_num_equals(2, scalar @{$new->{removed}}); + $self->assert_str_equals($new->{added}[0]{id}, $old->{ids}[$new->{added}[0]{index}]); + $self->assert_str_equals($new->{added}[1]{id}, $old->{ids}[$new->{added}[1]{index}]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_fromcontactgroupid b/cassandane/tiny-tests/JMAPEmail/email_querychanges_fromcontactgroupid new file mode 100644 index 0000000000..3066ff7829 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_fromcontactgroupid @@ -0,0 +1,122 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_fromcontactgroupid + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/contacts', + ]; + + my $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact1 => { + emails => [{ + type => 'personal', + value => 'contact1@local', + }] + }, + } + }, 'R1'], + ['ContactGroup/set', { + create => { + contactGroup1 => { + name => 'contactGroup1', + contactIds => ['#contact1'], + } + } + }, 'R2'], + ], $using); + my $contactId1 = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($contactId1); + my $contactGroupId1 = $res->[1][1]{created}{contactGroup1}{id}; + $self->assert_not_null($contactGroupId1); + + # Make emails. + $self->make_message("msg1", from => Cassandane::Address->new( + localpart => 'contact1', domain => 'local' + )) or die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => "subject" }], + }, 'R1'] + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + my $emailId1 = $res->[0][1]{ids}[0]; + + # Query changes. + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + fromContactGroupId => $contactGroupId1 + }, + sort => [ + { property => "subject" } + ], + }, 'R1'] + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{canCalculateChanges}); + my $queryState = $res->[0][1]{queryState}; + $self->assert_not_null($queryState); + + # Add new matching email. + $self->make_message("msg2", from => Cassandane::Address->new( + localpart => 'contact1', domain => 'local' + )) or die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + $res = $jmap->CallMethods([ + ['Email/queryChanges', { + filter => { + fromContactGroupId => $contactGroupId1 + }, + sort => [ + { property => "subject" } + ], + sinceQueryState => $queryState, + }, 'R1'] + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{added}}); + + # Invalidate query state for ContactGroup state changes. + $res = $jmap->CallMethods([ + ['Contact/set', { + create => { + contact2 => { + emails => [{ + type => 'personal', + value => 'contact2@local', + }] + }, + } + }, 'R1'], + ['ContactGroup/set', { + update => { + $contactGroupId1 => { + contactIds => [$contactId1, '#contact2'], + } + } + }, 'R2'], + ['Email/queryChanges', { + filter => { + fromContactGroupId => $contactGroupId1 + }, + sort => [ + { property => "subject" } + ], + sinceQueryState => $queryState, + }, 'R3'] + ], $using); + my $contactId2 = $res->[0][1]{created}{contact2}{id}; + $self->assert_not_null($contactId2); + $self->assert(exists $res->[1][1]{updated}{$contactGroupId1}); + $self->assert_str_equals('cannotCalculateChanges', $res->[2][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_implementation b/cassandane/tiny-tests/JMAPEmail/email_querychanges_implementation new file mode 100644 index 0000000000..5ffd9d26ac --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_implementation @@ -0,0 +1,149 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_implementation + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # Also see https://github.com/cyrusimap/cyrus-imapd/issues/2294 + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate two emails via IMAP"; + $self->make_message("EmailA") || die; + $self->make_message("EmailB") || die; + + # The JMAP implementation in Cyrus uses two strategies + # for processing an Email/queryChanges request, depending + # on the query arguments: + # + # (1) 'trivial': if collapseThreads is false + # + # (2) 'collapse': if collapseThreads is true + # + # The results should be the same for (1) and (2), where + # updated message are reported as both 'added' and 'removed'. + + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "Get email ids and state"; + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [ + { isAscending => JSON::true, property => 'subject' } + ], + collapseThreads => JSON::false, + }, "R1"], + ['Email/query', { + sort => [ + { isAscending => JSON::true, property => 'subject' } + ], + collapseThreads => JSON::true, + }, "R2"], + ]); + my $msgidA = $res->[0][1]->{ids}[0]; + $self->assert_not_null($msgidA); + my $msgidB = $res->[0][1]->{ids}[1]; + $self->assert_not_null($msgidB); + + my $state_trivial = $res->[0][1]->{queryState}; + $self->assert_not_null($state_trivial); + my $state_collapsed = $res->[1][1]->{queryState}; + $self->assert_not_null($state_collapsed); + + xlog $self, "update email B"; + $res = $jmap->CallMethods([['Email/set', { + update => { $msgidB => { + 'keywords/$seen' => JSON::true } + }, + }, "R1"]]); + $self->assert(exists $res->[0][1]->{updated}{$msgidB}); + + xlog $self, "Create two new emails via IMAP"; + $self->make_message("EmailC") || die; + $self->make_message("EmailD") || die; + + xlog $self, "Get email ids"; + $res = $jmap->CallMethods([['Email/query', { + sort => [{ isAscending => JSON::true, property => 'subject' }], + }, "R1"]]); + my $msgidC = $res->[0][1]->{ids}[2]; + $self->assert_not_null($msgidC); + my $msgidD = $res->[0][1]->{ids}[3]; + $self->assert_not_null($msgidD); + + xlog $self, "Query changes up to first newly created message"; + $res = $jmap->CallMethods([ + ['Email/queryChanges', { + sort => [ + { isAscending => JSON::true, property => 'subject' } + ], + sinceQueryState => $state_trivial, + collapseThreads => JSON::false, + upToId => $msgidC, + }, "R1"], + ['Email/queryChanges', { + sort => [ + { isAscending => JSON::true, property => 'subject' } + ], + sinceQueryState => $state_collapsed, + collapseThreads => JSON::true, + upToId => $msgidC, + }, "R2"], + ]); + + # 'trivial' case + $self->assert_num_equals(2, scalar @{$res->[0][1]{added}}); + $self->assert_str_equals($msgidB, $res->[0][1]{added}[0]{id}); + $self->assert_num_equals(1, $res->[0][1]{added}[0]{index}); + $self->assert_str_equals($msgidC, $res->[0][1]{added}[1]{id}); + $self->assert_num_equals(2, $res->[0][1]{added}[1]{index}); + $self->assert_deep_equals([$msgidB, $msgidC], $res->[0][1]{removed}); + $self->assert_num_equals(4, $res->[0][1]{total}); + $state_trivial = $res->[0][1]{newQueryState}; + + # 'collapsed' case + $self->assert_num_equals(2, scalar @{$res->[1][1]{added}}); + $self->assert_str_equals($msgidB, $res->[1][1]{added}[0]{id}); + $self->assert_num_equals(1, $res->[1][1]{added}[0]{index}); + $self->assert_str_equals($msgidC, $res->[1][1]{added}[1]{id}); + $self->assert_num_equals(2, $res->[1][1]{added}[1]{index}); + $self->assert_deep_equals([$msgidB, $msgidC], $res->[1][1]{removed}); + $self->assert_num_equals(4, $res->[0][1]{total}); + $state_collapsed = $res->[1][1]{newQueryState}; + + xlog $self, "delete email C ($msgidC)"; + $res = $jmap->CallMethods([['Email/set', { destroy => [ $msgidC ] }, "R1"]]); + $self->assert_str_equals($msgidC, $res->[0][1]->{destroyed}[0]); + + xlog $self, "Query changes"; + $res = $jmap->CallMethods([ + ['Email/queryChanges', { + sort => [ + { isAscending => JSON::true, property => 'subject' } + ], + sinceQueryState => $state_trivial, + collapseThreads => JSON::false, + }, "R1"], + ['Email/queryChanges', { + sort => [ + { isAscending => JSON::true, property => 'subject' } + ], + sinceQueryState => $state_collapsed, + collapseThreads => JSON::true, + }, "R2"], + ]); + + # 'trivial' case + $self->assert_num_equals(0, scalar @{$res->[0][1]{added}}); + $self->assert_deep_equals([$msgidC], $res->[0][1]{removed}); + $self->assert_num_equals(3, $res->[0][1]{total}); + + # 'collapsed' case + $self->assert_num_equals(0, scalar @{$res->[1][1]{added}}); + $self->assert_deep_equals([$msgidC], $res->[1][1]{removed}); + $self->assert_num_equals(3, $res->[0][1]{total}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_mailbox_or b/cassandane/tiny-tests/JMAPEmail/email_querychanges_mailbox_or new file mode 100644 index 0000000000..fbc9f51bff --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_mailbox_or @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_mailbox_or + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email => { + mailboxIds => { + '$inbox' => JSON::true, + }, + subject => 'email', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'email', + } + }, + }, + }, + }, 'R1'], + ['Mailbox/query', { + }, 'R2'], + ], $using); + my $emailId = $res->[0][1]{created}{email}{id}; + $self->assert_not_null($emailId); + my $inboxId = $res->[1][1]{ids}[0]; + $self->assert_not_null($inboxId); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'OR', + conditions => [{ + inMailbox => $inboxId, + }], + }, + }, 'R1'], + ], $using); + + $self->assert_deep_equals([$emailId], $res->[0][1]{ids}); + $self->assert_equals(JSON::true, $res->[0][1]{canCalculateChanges}); + my $queryState = $res->[0][1]{queryState}; + + $res = $jmap->CallMethods([ + ['Email/queryChanges', { + filter => { + operator => 'OR', + conditions => [{ + inMailbox => $inboxId, + }], + }, + sinceQueryState => $queryState, + }, 'R1'], + ], $using); + $self->assert_deep_equals([], $res->[0][1]{added}); + $self->assert_deep_equals([], $res->[0][1]{removed}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_order b/cassandane/tiny-tests/JMAPEmail/email_querychanges_order new file mode 100644 index 0000000000..18f4254193 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_order @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_order + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("A") || die; + + # First order descending by subject. We expect Email/queryChanges + # to return any items added after 'state' to show up at the start of + # the result list. + my $sort = [{ property => "subject", isAscending => JSON::false }]; + + xlog $self, "Get email id and state"; + $res = $jmap->CallMethods([['Email/query', { sort => $sort }, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + $state = $res->[0][1]->{queryState}; + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("B") || die; + + xlog $self, "Fetch updated list"; + $res = $jmap->CallMethods([['Email/query', { sort => $sort }, "R1"]]); + my $idb = $res->[0][1]->{ids}[0]; + $self->assert_str_not_equals($ida, $idb); + + xlog $self, "get email list updates"; + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state, sort => $sort }, "R1"]]); + $self->assert_equals($idb, $res->[0][1]{added}[0]{id}); + $self->assert_num_equals(0, $res->[0][1]{added}[0]{index}); + + # Now restart with sorting by ascending subject. We refetch the state + # just to be sure. Then we expect an additional item to show up at the + # end of the result list. + xlog $self, "Fetch reverse sorted list and state"; + $sort = [{ property => "subject" }]; + $res = $jmap->CallMethods([['Email/query', { sort => $sort }, "R1"]]); + $ida = $res->[0][1]->{ids}[0]; + $self->assert_str_not_equals($ida, $idb); + $idb = $res->[0][1]->{ids}[1]; + $state = $res->[0][1]->{queryState}; + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("C") || die; + + xlog $self, "get email list updates"; + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state, sort => $sort }, "R1"]]); + $self->assert_str_not_equals($ida, $res->[0][1]{added}[0]{id}); + $self->assert_str_not_equals($idb, $res->[0][1]{added}[0]{id}); + $self->assert_num_equals(2, $res->[0][1]{added}[0]{index}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_skipdeleted b/cassandane/tiny-tests/JMAPEmail/email_querychanges_skipdeleted new file mode 100644 index 0000000000..f01087614f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_skipdeleted @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_skipdeleted + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "Generate some email in INBOX via IMAP"; + $self->make_message("Email A") || die; + $self->make_message("Email B") || die; + $self->make_message("Email C") || die; + $self->make_message("Email D") || die; + + $talk->create("INBOX.foo"); + $talk->select("INBOX"); + $talk->move("1:2", "INBOX.foo"); + $talk->select("INBOX.foo"); + $talk->move("1:2", "INBOX"); + + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + }, 'R1']]); + + my $old = $res->[0][1]; + + $talk->select("INBOX"); + $talk->store("1", "+flags", "(\\Flagged)"); + + $res = $jmap->CallMethods([['Email/queryChanges', { + sort => [ + { + property => "subject", + isAscending => $JSON::true, + } + ], + filter => { inMailbox => $inboxid }, + collapseThreads => $JSON::true, + sinceQueryState => $old->{queryState}, + }, 'R2']]); + + my $new = $res->[0][1]; + $self->assert_str_equals($old->{queryState}, $new->{oldQueryState}); + $self->assert_str_not_equals($old->{queryState}, $new->{newQueryState}); + # with collased threads we have to check + $self->assert_num_equals(1, scalar @{$new->{added}}); + $self->assert_num_equals(1, scalar @{$new->{removed}}); + $self->assert_str_equals($new->{removed}[0], $new->{added}[0]{id}); + $self->assert_str_equals($new->{removed}[0], $old->{ids}[$new->{added}[0]{index}]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged b/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged new file mode 100644 index 0000000000..8493bc8cf1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged @@ -0,0 +1,139 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_sortflagged + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + my %exp; + my $dt; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "generating email A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -3)); + $exp{A} = $self->make_message("Email A", date => $dt, body => "a"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "Get email id"; + $res = $jmap->CallMethods([['Email/query', { + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + + $state = $res->[0][1]->{queryState}; + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B", body => "b"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -2)); + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "c"); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "generating email D referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -1)); + $exp{D} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "d"); + $exp{D}->set_attributes(uid => 4, cid => $exp{A}->get_attribute('cid')); + + # EXPECTED ORDER OF MESSAGES NOW BY DATE IS: + # A C D B + # fetch them all by ID now to get an ID map + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { property => "receivedAt", + "isAscending" => $JSON::true }, + ], + }, "R1"]]); + my @ids = @{$res->[0][1]->{ids}}; + $self->assert_num_equals(4, scalar @ids); + $self->assert_str_equals($ida, $ids[0]); + my $idc = $ids[1]; + my $idd = $ids[2]; + my $idb = $ids[3]; + + # raw fetch - check order now + $res = $jmap->CallMethods([['Email/query', { + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $self->assert_deep_equals([$idb, $idd], $res->[0][1]->{ids}); + + $res = $jmap->CallMethods([['Email/queryChanges', { + sinceQueryState => $state, collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $state = $res->[0][1]{newQueryState}; + + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{removed}}); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{added}}); + # check that the order is B D + $self->assert_deep_equals([{id => $idb, index => 0}, {id => $idd, index => 1}], $res->[0][1]{added}); + + $talk->select("INBOX"); + $talk->store('1', "+flags", '\\Flagged'); + + # this will sort D to the top because of the flag on A + + # raw fetch - check order now + $res = $jmap->CallMethods([['Email/query', { + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $self->assert_deep_equals([$idd, $idb], $res->[0][1]->{ids}); + + $res = $jmap->CallMethods([['Email/queryChanges', { + sinceQueryState => $state, collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $state = $res->[0][1]{newQueryState}; + + $self->assert_num_equals(2, $res->[0][1]{total}); + # will have removed 'D' (old exemplar) and 'A' (touched) + $self->assert_num_equals(3, scalar @{$res->[0][1]->{removed}}); + $self->assert_not_null(grep { $_ eq $idd } map { $_ } @{$res->[0][1]->{removed}}); + $self->assert_not_null(grep { $_ eq $ida } map { $_ } @{$res->[0][1]->{removed}}); + $self->assert_not_null(grep { $_ eq $idc } map { $_ } @{$res->[0][1]->{removed}}); + $self->assert_deep_equals([{id => $idd, index => 0}], $res->[0][1]{added}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged_otherfolder b/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged_otherfolder new file mode 100644 index 0000000000..5881cd9974 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged_otherfolder @@ -0,0 +1,151 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_sortflagged_otherfolder + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + my %exp; + my $dt; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "generating email A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -3)); + $exp{A} = $self->make_message("Email A", date => $dt, body => "a"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "Get mailbox id"; + $res = $jmap->CallMethods([['Mailbox/query', {}, "R1"]]); + my $mbid = $res->[0][1]->{ids}[0]; + $self->assert_not_null($mbid); + + xlog $self, "Get email id"; + $res = $jmap->CallMethods([['Email/query', { + filter => { inMailbox => $mbid }, + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + + $state = $res->[0][1]->{queryState}; + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B", body => "b"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -2)); + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "c"); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "Create new mailbox"; + $res = $jmap->CallMethods([['Mailbox/set', { create => { 1 => { name => "foo" } } }, "R1"]]); + + $self->{store}->set_folder("INBOX.foo"); + xlog $self, "generating email D referencing A (in foo)"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -1)); + $exp{D} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "d"); + $exp{D}->set_attributes(uid => 1, cid => $exp{A}->get_attribute('cid')); + + # EXPECTED ORDER OF MESSAGES NOW BY DATE IS: + # A C B (with D in the other mailbox) + # fetch them all by ID now to get an ID map + $res = $jmap->CallMethods([['Email/query', { + filter => { inMailbox => $mbid }, + sort => [ + { property => "receivedAt", + "isAscending" => $JSON::true }, + ], + }, "R1"]]); + my @ids = @{$res->[0][1]->{ids}}; + $self->assert_num_equals(3, scalar @ids); + $self->assert_str_equals($ida, $ids[0]); + my $idc = $ids[1]; + my $idb = $ids[2]; + + # raw fetch - check order now + $res = $jmap->CallMethods([['Email/query', { + filter => { inMailbox => $mbid }, + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $self->assert_deep_equals([$idb, $idc], $res->[0][1]->{ids}); + + $res = $jmap->CallMethods([['Email/queryChanges', { + filter => { inMailbox => $mbid }, + sinceQueryState => $state, collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $state = $res->[0][1]{newQueryState}; + + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{removed}}); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{added}}); + # check that the order is B C + $self->assert_deep_equals([{id => $idb, index => 0}, {id => $idc, index => 1}], $res->[0][1]{added}); + + $talk->select("INBOX.foo"); + $talk->store('1', "+flags", '\\Flagged'); + + # this has put the flag on D, which should sort C to the top! + + # raw fetch - check order now + $res = $jmap->CallMethods([['Email/query', { + filter => { inMailbox => $mbid }, + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $self->assert_deep_equals([$idc, $idb], $res->[0][1]->{ids}); + + $res = $jmap->CallMethods([['Email/queryChanges', { + filter => { inMailbox => $mbid }, + sinceQueryState => $state, collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $state = $res->[0][1]{newQueryState}; + + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{removed}}); + $self->assert_not_null(grep { $_ eq $ida } map { $_ } @{$res->[0][1]->{removed}}); + $self->assert_not_null(grep { $_ eq $idc } map { $_ } @{$res->[0][1]->{removed}}); + $self->assert_deep_equals([{id => $idc, index => 0}], $res->[0][1]{added}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged_topmessage b/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged_topmessage new file mode 100644 index 0000000000..cc95d52231 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_sortflagged_topmessage @@ -0,0 +1,140 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_sortflagged_topmessage + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + my %exp; + my $dt; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "generating email A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -3)); + $exp{A} = $self->make_message("Email A", date => $dt, body => "a"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "Get email id"; + $res = $jmap->CallMethods([['Email/query', { + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + + $state = $res->[0][1]->{queryState}; + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B", body => "b"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -2)); + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "c"); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "generating email D referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -1)); + $exp{D} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "d"); + $exp{D}->set_attributes(uid => 4, cid => $exp{A}->get_attribute('cid')); + + # EXPECTED ORDER OF MESSAGES NOW BY DATE IS: + # A C D B + # fetch them all by ID now to get an ID map + $res = $jmap->CallMethods([['Email/query', { + sort => [ + { property => "receivedAt", + "isAscending" => $JSON::true }, + ], + }, "R1"]]); + my @ids = @{$res->[0][1]->{ids}}; + $self->assert_num_equals(4, scalar @ids); + $self->assert_str_equals($ida, $ids[0]); + my $idc = $ids[1]; + my $idd = $ids[2]; + my $idb = $ids[3]; + + # raw fetch - check order now + $res = $jmap->CallMethods([['Email/query', { + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $self->assert_deep_equals([$idb, $idd], $res->[0][1]->{ids}); + + $res = $jmap->CallMethods([['Email/queryChanges', { + sinceQueryState => $state, collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $state = $res->[0][1]{newQueryState}; + + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{removed}}); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{added}}); + # check that the order is B D + $self->assert_deep_equals([{id => $idb, index => 0}, {id => $idd, index => 1}], $res->[0][1]{added}); + + $talk->select("INBOX"); + $talk->store('4', "+flags", '\\Flagged'); + + # this will sort D to the top because of the flag on D + + # raw fetch - check order now + $res = $jmap->CallMethods([['Email/query', { + collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $self->assert_deep_equals([$idd, $idb], $res->[0][1]->{ids}); + + $res = $jmap->CallMethods([['Email/queryChanges', { + sinceQueryState => $state, collapseThreads => $JSON::true, + sort => [ + { property => "someInThreadHaveKeyword", + keyword => "\$flagged", + isAscending => $JSON::false }, + { property => "receivedAt", + isAscending => $JSON::false }, + ], + }, "R1"]]); + $state = $res->[0][1]{newQueryState}; + + $self->assert_num_equals(2, $res->[0][1]{total}); + # will have removed 'D' (touched) as well as + # XXX: C and A because it can't know what the old order was, oh well + $self->assert_num_equals(3, scalar @{$res->[0][1]->{removed}}); + $self->assert_not_null(grep { $_ eq $idd } map { $_ } @{$res->[0][1]->{removed}}); + $self->assert_not_null(grep { $_ eq $ida } map { $_ } @{$res->[0][1]->{removed}}); + $self->assert_not_null(grep { $_ eq $idc } map { $_ } @{$res->[0][1]->{removed}}); + $self->assert_deep_equals([{id => $idd, index => 0}], $res->[0][1]{added}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_thread b/cassandane/tiny-tests/JMAPEmail/email_querychanges_thread new file mode 100644 index 0000000000..02f59ae401 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_thread @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_thread + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + my %exp; + my $dt; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "generating email A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -3)); + $exp{A} = $self->make_message("Email A", date => $dt, body => "a"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "Get email id"; + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + + $state = $res->[0][1]->{queryState}; + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B", body => "b"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -2)); + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "c"); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "generating email D referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -1)); + $exp{D} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "d"); + $exp{D}->set_attributes(uid => 4, cid => $exp{A}->get_attribute('cid')); + + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state, collapseThreads => JSON::true }, "R1"]]); + $state = $res->[0][1]{newQueryState}; + + $self->assert_num_equals(2, $res->[0][1]{total}); + # assert that IDA got destroyed + $self->assert_not_null(grep { $_ eq $ida } map { $_ } @{$res->[0][1]->{removed}}); + # and not recreated + $self->assert_null(grep { $_ eq $ida } map { $_->{id} } @{$res->[0][1]->{added}}); + + $talk->select("INBOX"); + $talk->store('3', "+flags", '\\Deleted'); + $talk->expunge(); + + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state, collapseThreads => JSON::true }, "R1"]]); + $state = $res->[0][1]{newQueryState}; + + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert(ref($res->[0][1]{added}) eq 'ARRAY'); + $self->assert_num_equals(0, scalar @{$res->[0][1]{added}}); + $self->assert(ref($res->[0][1]{removed}) eq 'ARRAY'); + $self->assert_num_equals(0, scalar @{$res->[0][1]{removed}}); + + $talk->store('3', "+flags", '\\Deleted'); + $talk->expunge(); + + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state, collapseThreads => JSON::true }, "R1"]]); + + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar(@{$res->[0][1]{added}})); + $self->assert_num_equals(2, scalar(@{$res->[0][1]{removed}})); + + # same thread, back to ida + $self->assert_str_equals($ida, $res->[0][1]{added}[0]{id}); + #$self->assert_str_equals($res->[0][1]{added}[0]{threadId}, $res->[0][1]{destroyed}[0]{threadId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_toomany b/cassandane/tiny-tests/JMAPEmail/email_querychanges_toomany new file mode 100644 index 0000000000..9332d0ee1c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_toomany @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_toomany + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("Email A") || die; + + xlog $self, "Get email id"; + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + + $state = $res->[0][1]->{queryState}; + + $self->make_message("Email B") || die; + + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + + my ($idb) = grep { $_ ne $ida } @{$res->[0][1]->{ids}}; + + xlog $self, "get email list updates"; + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state, maxChanges => 1 }, "R1"]]); + + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("tooManyChanges", $res->[0][1]{type}); + $self->assert_str_equals("R1", $res->[0][2]); + + xlog $self, "get email list updates with threads collapsed"; + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state, collapseThreads => JSON::true, maxChanges => 1 }, "R1"]]); + + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("tooManyChanges", $res->[0][1]{type}); + $self->assert_str_equals("R1", $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_querychanges_zerosince b/cassandane/tiny-tests/JMAPEmail/email_querychanges_zerosince new file mode 100644 index 0000000000..9c4ab417a3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_querychanges_zerosince @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_querychanges_zerosince + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $res; + my $state; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("Email A") || die; + + xlog $self, "Get email id"; + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ida = $res->[0][1]->{ids}[0]; + $self->assert_not_null($ida); + + $state = $res->[0][1]->{queryState}; + + $self->make_message("Email B") || die; + + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + + my ($idb) = grep { $_ ne $ida } @{$res->[0][1]->{ids}}; + + xlog $self, "get email list updates"; + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => $state }, "R1"]]); + + $self->assert_equals($idb, $res->[0][1]{added}[0]{id}); + + xlog $self, "get email list updates with threads collapsed"; + $res = $jmap->CallMethods([['Email/queryChanges', { sinceQueryState => "0", collapseThreads => JSON::true }, "R1"]]); + $self->assert_equals('error', $res->[0][0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_seen_shared b/cassandane/tiny-tests/JMAPEmail/email_seen_shared new file mode 100644 index 0000000000..3574e5bd84 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_seen_shared @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_seen_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Share account + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lr") or die; + + # Create mailbox A + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lrs") or die; + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + $self->make_message("Email", store => $self->{adminstore}) or die; + + # Set \Seen on message A as user cassandane + $self->{store}->set_folder('user.other.A'); + $talk->select('user.other.A'); + $talk->store('1', '+flags', '(\\Seen)'); + + # Get email and assert $seen + my $res = $jmap->CallMethods([ + ['Email/query', { + accountId => 'other', + }, 'R1'], + ['Email/get', { + accountId => 'other', + properties => ['keywords'], + '#ids' => { + resultOf => 'R1', name => 'Email/query', path => '/ids' + } + }, 'R2' ] + ]); + my $emailId = $res->[1][1]{list}[0]{id}; + my $wantKeywords = { '$seen' => JSON::true }; + $self->assert_deep_equals($wantKeywords, $res->[1][1]{list}[0]{keywords}); + + # Set $seen via JMAP on the shared mailbox + $res = $jmap->CallMethods([ + ['Email/set', { + accountId => 'other', + update => { + $emailId => { + keywords => { }, + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + + # Assert $seen got updated + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'other', + properties => ['keywords'], + ids => [$emailId], + }, 'R1' ] + ]); + $wantKeywords = { }; + $self->assert_deep_equals($wantKeywords, $res->[0][1]{list}[0]{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_seen_shared_twofolder b/cassandane/tiny-tests/JMAPEmail/email_seen_shared_twofolder new file mode 100644 index 0000000000..21c6940af1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_seen_shared_twofolder @@ -0,0 +1,78 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_seen_shared_twofolder + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Share account + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lr") or die; + + # Create mailbox A + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lrs") or die; + $admintalk->create("user.other.A.sub") or die; + $admintalk->setacl("user.other.A.sub", "cassandane", "lrs") or die; + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + $self->make_message("Email", store => $self->{adminstore}) or die; + + # Set \Seen on message A as user cassandane + $self->{store}->set_folder('user.other.A'); + $admintalk->select('user.other.A'); + $admintalk->copy('1', 'user.other.A.sub'); + $talk->select('user.other.A'); + $talk->store('1', '+flags', '(\\Seen)'); + $talk->select('user.other.A.sub'); + $talk->store('1', '+flags', '(\\Seen)'); + + # Get email and assert $seen + my $res = $jmap->CallMethods([ + ['Email/query', { + accountId => 'other', + }, 'R1'], + ['Email/get', { + accountId => 'other', + properties => ['keywords'], + '#ids' => { + resultOf => 'R1', name => 'Email/query', path => '/ids' + } + }, 'R2' ] + ]); + my $emailId = $res->[1][1]{list}[0]{id}; + my $wantKeywords = { '$seen' => JSON::true }; + $self->assert_deep_equals($wantKeywords, $res->[1][1]{list}[0]{keywords}); + + # Set $seen via JMAP on the shared mailbox + $res = $jmap->CallMethods([ + ['Email/set', { + accountId => 'other', + update => { + $emailId => { + keywords => { }, + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + + # Assert $seen got updated + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'other', + properties => ['keywords'], + ids => [$emailId], + }, 'R1' ] + ]); + $wantKeywords = { }; + $self->assert_deep_equals($wantKeywords, $res->[0][1]{list}[0]{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_seen_shared_twofolder_hidden b/cassandane/tiny-tests/JMAPEmail/email_seen_shared_twofolder_hidden new file mode 100644 index 0000000000..eae7df4489 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_seen_shared_twofolder_hidden @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_seen_shared_twofolder_hidden + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Share account + $self->{instance}->create_user("other"); + $admintalk->setacl("user.other", "cassandane", "lr") or die; + + # Create mailbox A + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lrs") or die; + # NOTE: user cassandane does NOT get permission to see this one + $admintalk->create("user.other.A.sub") or die; + $admintalk->setacl("user.other.A.sub", "cassandane", "") or die; + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + $self->make_message("Email", store => $self->{adminstore}) or die; + + # Set \Seen on message A as user cassandane + $self->{store}->set_folder('user.other.A'); + $admintalk->select('user.other.A'); + $admintalk->copy('1', 'user.other.A.sub'); + $talk->select('user.other.A'); + $talk->store('1', '+flags', '(\\Seen)'); + + # Get email and assert $seen + my $res = $jmap->CallMethods([ + ['Email/query', { + accountId => 'other', + }, 'R1'], + ['Email/get', { + accountId => 'other', + properties => ['keywords'], + '#ids' => { + resultOf => 'R1', name => 'Email/query', path => '/ids' + } + }, 'R2' ] + ]); + my $emailId = $res->[1][1]{list}[0]{id}; + my $wantKeywords = { '$seen' => JSON::true }; + $self->assert_deep_equals($wantKeywords, $res->[1][1]{list}[0]{keywords}); + + # Set $seen via JMAP on the shared mailbox + $res = $jmap->CallMethods([ + ['Email/set', { + accountId => 'other', + update => { + $emailId => { + keywords => { }, + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + + # Assert $seen got updated + $res = $jmap->CallMethods([ + ['Email/get', { + accountId => 'other', + properties => ['keywords'], + ids => [$emailId], + }, 'R1' ] + ]); + $wantKeywords = { }; + $self->assert_deep_equals($wantKeywords, $res->[0][1]{list}[0]{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_attachments b/cassandane/tiny-tests/JMAPEmail/email_set_attachments new file mode 100644 index 0000000000..6b78ad17d7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_attachments @@ -0,0 +1,157 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_attachments + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + # Generate an email to have some blob ids + xlog $self, "Generate an email in $inbox via IMAP"; + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Disposition: inline\r\n" . "\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: image/jpeg;foo=bar\r\n" + . "Content-Disposition: attachment\r\n" + . "Content-Transfer-Encoding: base64\r\n" . "\r\n" + . "beefc0de" + . "\r\n--sub\r\n" + . "Content-Type: image/png\r\n" + . "Content-Disposition: attachment\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . "f00bae==" + . "\r\n--sub--\r\n", + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { ids => $ids }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my %m = map { $_->{type} => $_ } @{$res->[0][1]{list}[0]->{attachments}}; + my $blobJpeg = $m{"image/jpeg"}->{blobId}; + my $blobPng = $m{"image/png"}->{blobId}; + $self->assert_not_null($blobJpeg); + $self->assert_not_null($blobPng); + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + my $shortfname = "test\N{GRINNING FACE}.jpg"; + my $longfname = "a_very_long_filename_thats_looking_quite_bogus_but_in_fact_is_absolutely_valid\N{GRINNING FACE}!.bin"; + + my $draft = { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo", + htmlBody => [{ partId => '1' }], + bodyValues => { + '1' => { + value => "I'm givin' ya one last chance ta surrenda! ". + "", + }, + }, + attachments => [{ + blobId => $blobJpeg, + name => $shortfname, + type => 'image/jpeg', + }, { + blobId => $blobPng, + cid => "foo\@local", + type => 'image/png', + disposition => 'inline', + }, { + blobId => $blobJpeg, + type => "application/test", + name => $longfname, + }, { + blobId => $blobPng, + type => "application/test2", + name => "simple", + }], + keywords => { '$draft' => JSON::true }, + }; + + my $wantBodyStructure = { + type => 'multipart/mixed', + name => undef, + cid => undef, + disposition => undef, + subParts => [{ + type => 'multipart/related', + name => undef, + cid => undef, + disposition => undef, + subParts => [{ + type => 'text/html', + name => undef, + cid => undef, + disposition => undef, + subParts => [], + },{ + type => 'image/png', + cid => "foo\@local", + disposition => 'inline', + name => undef, + subParts => [], + }], + },{ + type => 'image/jpeg', + name => $shortfname, + cid => undef, + disposition => 'attachment', + subParts => [], + },{ + type => 'application/test', + name => $longfname, + cid => undef, + disposition => 'attachment', + subParts => [], + },{ + type => 'application/test2', + name => 'simple', + cid => undef, + disposition => 'attachment', + subParts => [], + }] + }; + + xlog $self, "Create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { + ids => [$id], + properties => ['bodyStructure'], + bodyProperties => ['type', 'name', 'cid','disposition', 'subParts'], + }, "R1"]]); + $msg = $res->[0][1]->{list}[0]; + + $self->assert_deep_equals($wantBodyStructure, $msg->{bodyStructure}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_blobencoding b/cassandane/tiny-tests/JMAPEmail/email_set_blobencoding new file mode 100644 index 0000000000..c8c775cceb --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_blobencoding @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_blobencoding + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Upload a data blob"; + my $binary = slurp_file(abs_path('data/logo.gif')); + my $data = $jmap->Upload($binary, "image/gif"); + my $dataBlobId = $data->{blobId}; + + my $emailBlob = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $emailBlob =~ s/\r?\n/\r\n/gs; + $data = $jmap->Upload($emailBlob, "application/octet"); + my $rfc822Blobid = $data->{blobId}; + + xlog $self, "Create email with body structure"; + my $inboxid = $self->getinbox()->{id}; + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [{ name => "Test", email => q{foo@bar} }], + subject => "test", + textBody => [{ + type => 'text/plain', + partId => '1', + }], + bodyValues => { + '1' => { + value => "A text body", + }, + }, + attachments => [{ + type => 'image/gif', + blobId => $dataBlobId, + }, { + type => 'message/rfc822', + blobId => $rfc822Blobid, + }], + }; + my $res = $jmap->CallMethods([ + ['Email/set', { create => { '1' => $email } }, 'R1'], + ['Email/get', { + ids => [ '#1' ], + properties => [ 'bodyStructure' ], + bodyProperties => [ 'type', 'header:Content-Transfer-Encoding' ], + }, 'R2' ], + ]); + + my $gotPart; + $gotPart = $res->[1][1]{list}[0]{bodyStructure}{subParts}[1]; + $self->assert_str_equals('message/rfc822', $gotPart->{type}); + $self->assert_str_equals(' 7bit', $gotPart->{'header:Content-Transfer-Encoding'}); + $gotPart = $res->[1][1]{list}[0]{bodyStructure}{subParts}[2]; + $self->assert_str_equals('image/gif', $gotPart->{type}); + $self->assert_str_equals(' BASE64', uc($gotPart->{'header:Content-Transfer-Encoding'})); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_bodystructure b/cassandane/tiny-tests/JMAPEmail/email_set_bodystructure new file mode 100644 index 0000000000..631286edf4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_bodystructure @@ -0,0 +1,120 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_bodystructure + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Disposition: inline\r\n" . "\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain\r\n" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: bar\r\n" + . "From: Ava T. Nguyen \r\n" + . "Message-ID: \r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "An embedded email" + . "\r\n--sub--\r\n", + ) || die; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['attachments', 'blobId'], + }, 'R2' ], + ]); + my $emailBlobId = $res->[1][1]->{list}[0]->{blobId}; + my $embeddedEmailBlobId = $res->[1][1]->{list}[0]->{attachments}[0]{blobId}; + + xlog $self, "Upload a data blob"; + my $binary = pack "H*", "beefcode"; + my $data = $jmap->Upload($binary, "image/gif"); + my $dataBlobId = $data->{blobId}; + + xlog $self, "Upload a text blob"; + $data = $jmap->Upload("hello world", "text/plain"); + my $textBlobId = $data->{blobId}; + + $self->assert_not_null($emailBlobId); + $self->assert_not_null($embeddedEmailBlobId); + $self->assert_not_null($dataBlobId); + $self->assert_not_null($textBlobId); + + my $bodyStructure = { + type => "multipart/alternative", + subParts => [{ + type => 'text/plain', + partId => '1', + }, { + type => 'message/rfc822', + blobId => $embeddedEmailBlobId, + }, { + type => 'image/gif', + blobId => $dataBlobId, + }, { + # No type set + blobId => $textBlobId, + }, { + type => 'message/rfc822', + blobId => $emailBlobId, + }], + }; + + xlog $self, "Create email with body structure"; + my $inboxid = $self->getinbox()->{id}; + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [{ name => "Test", email => q{foo@bar} }], + subject => "test", + bodyStructure => $bodyStructure, + bodyValues => { + "1" => { + value => "A text body", + }, + }, + }; + $res = $jmap->CallMethods([ + ['Email/set', { create => { '1' => $email } }, 'R1'], + ['Email/get', { + ids => [ '#1' ], + properties => [ 'bodyStructure' ], + bodyProperties => [ 'partId', 'blobId', 'type' ], + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]); + + # Normalize server-set properties + my $gotBodyStructure = $res->[1][1]{list}[0]{bodyStructure}; + $self->assert_str_equals('multipart/alternative', $gotBodyStructure->{type}); + $self->assert_null($gotBodyStructure->{blobId}); + $self->assert_str_equals('text/plain', $gotBodyStructure->{subParts}[0]{type}); + $self->assert_not_null($gotBodyStructure->{subParts}[0]{blobId}); + $self->assert_str_equals('message/rfc822', $gotBodyStructure->{subParts}[1]{type}); + $self->assert_str_equals($embeddedEmailBlobId, $gotBodyStructure->{subParts}[1]{blobId}); + $self->assert_str_equals('image/gif', $gotBodyStructure->{subParts}[2]{type}); + $self->assert_str_equals($dataBlobId, $gotBodyStructure->{subParts}[2]{blobId}); + # Default type is text/plain if no Content-Type header is set + $self->assert_str_equals('text/plain', $gotBodyStructure->{subParts}[3]{type}); + $self->assert_str_equals($textBlobId, $gotBodyStructure->{subParts}[3]{blobId}); + $self->assert_str_equals('message/rfc822', $gotBodyStructure->{subParts}[4]{type}); + $self->assert_str_equals($emailBlobId, $gotBodyStructure->{subParts}[4]{blobId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_copymove_no_permission_shared b/cassandane/tiny-tests/JMAPEmail/email_set_copymove_no_permission_shared new file mode 100644 index 0000000000..c071860f16 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_copymove_no_permission_shared @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_copymove_no_permission_shared + :min_version_3_5 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $admin = $self->{adminstore}->get_client(); + $admin->create("user.other"); + $admin->setacl("user.other", admin => 'lrswipkxtecdan') or die; + $admin->setacl("user.other", other => 'lrswipkxtecdn') or die; + + my $service = $self->{instance}->get_service("http"); + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + ]); + + my $res = $otherJmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + }, + }, 'R1'], + ['Email/set', { + create => { + 'email' => { + mailboxIds => { + '#mboxA' => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'email', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ]); + my $mboxA = $res->[0][1]->{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[0][1]->{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + my $email = $res->[1][1]->{created}{email}{id}; + $self->assert_not_null($email); + + $admin->setacl("user.other.A", cassandane => 'lrs') or die; + $admin->setacl("user.other.B", cassandane => 'lrswitedn') or die; + + $res = $jmap->CallMethods([ + ['Email/set', { + accountId => 'other', + update => { + $email => { + 'mailboxIds/'.$mboxA => undef, + 'mailboxIds/'.$mboxB => JSON::true, + } + } + }, 'R1'], + ]); + $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$email}{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_create_encoding b/cassandane/tiny-tests/JMAPEmail/email_set_create_encoding new file mode 100644 index 0000000000..9aabdd4c36 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_create_encoding @@ -0,0 +1,755 @@ +#!perl +use Cassandane::Tiny; +use Encode qw(decode encode); +use MIME::Base64 qw(encode_base64); +use File::Temp qw(tempfile); +use Clone 'clone'; +use Data::UUID; + +sub test_email_set_create_encoding + :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/blob'); + + # Optionally persist tests in file. + my $writeEmail2MimeTests = 0; + my $uuidgen = Data::UUID->new; + + my @textTests = ({ + desc => 'text/plain without charset', + blob => "plain ascii", + bodyStructure => { + type => 'text/plain', + }, + wantEncoding => '7bit', + wantContentType => 'text/plain' + }, { + desc => 'text/plain with charset', + blob => "plain ascii", + bodyStructure => { + type => 'text/plain', + charset => 'us-ascii', + }, + wantEncoding => '7bit', + wantContentType => 'text/plain; charset=us-ascii' + }, { + desc => 'text/plain as attachment', + blob => "plain ascii", + bodyStructure => { + type => 'text/plain', + charset => 'us-ascii', + disposition => 'attachment', + }, + wantEncoding => 'base64', + wantContentType => 'text/plain; charset=us-ascii' + }, { + desc => 'text/plain UTF-8 without charset from blob', + blob => encode('utf-8', "some \N{TOMATO} utf-8"), + bodyStructure => { + type => 'text/plain', + }, + wantInvalidProperties => ['bodyStructure/charset'], + }, { + desc => 'text/plain UTF-8 without charset from EmailBodyValue', + bodyValue => "some \N{TOMATO} utf-8", + bodyStructure => { + type => 'text/plain', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain; charset=utf-8' + }, { + desc => 'text/plain UTF-8 with charset', + blob => encode('utf-8', "some \N{TOMATO} utf-8"), + bodyStructure => { + type => 'text/plain', + charset => 'utf-8', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain; charset=utf-8' + }, { + desc => 'text/xml without charset from blob', + blob => '', + bodyStructure => { + type => 'text/xml', + }, + wantEncoding => '7bit', + wantContentType => 'text/xml', + }, { + desc => 'text/xml without charset from EmailBodyValue', + bodyValue => '', + bodyStructure => { + type => 'text/xml', + }, + wantEncoding => '7bit', + wantContentType => 'text/xml', + }, { + desc => 'text/xml 7bit-safe with latin1 charset', + blob => '', + bodyStructure => { + type => 'text/xml', + charset => 'latin1', + }, + wantEncoding => '7bit', + wantContentType => 'text/xml; charset=latin1', + }, { + desc => 'text/xml non-7bit-safe without charset', + blob => encode('latin1', "\N{POUND SIGN}"), + bodyStructure => { + type => 'text/xml', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/xml', + }, { + desc => 'text/xml non-7bit-safe with latin1 charset', + blob => encode('latin1', "\N{POUND SIGN}"), + bodyStructure => { + type => 'text/xml', + charset => 'latin1', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/xml; charset=latin1', + }, { + desc => 'text/plain with ASCII control chars and no charset', + blob => "ding \N{U+0007} dong", + bodyStructure => { + type => 'text/plain', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain', + }, { + desc => 'text/plain with ASCII control chars and us-ascii charset', + blob => "ding \N{U+0007} dong", + bodyStructure => { + type => 'text/plain', + charset => 'us-ascii', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain; charset=us-ascii', + }, { + desc => 'text/plain with ASCII control chars as attachment', + blob => "ding \N{U+0007} dong", + bodyStructure => { + type => 'text/plain', + disposition => 'attachment', + }, + wantEncoding => 'base64', + wantContentType => 'text/plain', + }, { + desc => 'text/plain with ASCII control chars and utf-8 charset', + blob => "ding \N{U+0007} dong", + bodyStructure => { + type => 'text/plain', + charset => 'utf-8', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain; charset=utf-8', + }, { + desc => 'text/plain with multi-byte UTF-8 and no charset', + blob => "$ to \xc2\xa3", + bodyStructure => { + type => 'text/plain', + }, + wantInvalidProperties => ['bodyStructure/charset'], + }, { + desc => 'text/plain with multi-byte UTF-8 and us-ascii charset', + blob => "$ to \xc2\xa3", + bodyStructure => { + type => 'text/plain', + charset => 'us-ascii', + }, + wantInvalidProperties => ['bodyStructure/charset'], + }, { + desc => 'text/plain with multi-byte UTF-8 as attachment', + blob => "$ to \xc2\xa3", + bodyStructure => { + type => 'text/plain', + disposition => 'attachment', + }, + wantEncoding => 'base64', + wantContentType => 'text/plain', + }, { + desc => 'text/plain with multi-byte UTF-8 and utf-8 charset', + blob => "$ to \xc2\xa3", + bodyStructure => { + type => 'text/plain', + charset => 'utf-8', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain; charset=utf-8', + }, { + desc => 'text/plain with invalid UTF-8 and utf-8 charset', + blob => "bogus \xe2\x28\xa1 data", + bodyStructure => { + type => 'text/plain', + charset => 'utf-8', + }, + wantInvalidProperties => ['bodyStructure/charset'], + }, { + desc => 'text/plain with invalid UTF-8 as attachment', + blob => "bogus \xe2\x28\xa1 data", + bodyStructure => { + type => 'text/plain', + charset => 'utf-8', + disposition => 'attachment', + }, + wantEncoding => 'base64', + wantContentType => 'text/plain; charset=utf-8', + }, { + desc => 'text/plain with overlong MIME line', + blob => 'x' x 999, + bodyStructure => { + type => 'text/plain', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain', + }, { + desc => 'text/plain with overlong MIME line from EmailBodyValue', + bodyValue => "x\r\n" . 'x' x 999 . "\r\n" . 'x', + bodyStructure => { + type => 'text/plain', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain', + }, { + desc => 'text/plain with bare CR and LF chars as attachment', + blob => "some bare CR\r and LF\n in here", + bodyStructure => { + type => 'text/plain', + disposition => 'attachment', + }, + wantEncoding => 'base64', + wantContentType => 'text/plain', + }, { + desc => 'text/plain with bare CR and LF chars', + blob => "some bare CR\r and LF\n in here", + bodyStructure => { + type => 'text/plain', + }, + wantEncoding => '7bit', + wantContentType => 'text/plain', + wantBlob => "some bare CR\r\n and LF\r\n in here", # gets rewritten + }, { + desc => 'text/plain with bare CR and LF chars and utf-8 charset', + blob => "some bare CR\r and LF\n in here", + bodyStructure => { + type => 'text/plain', + charset => 'utf-8', + }, + wantEncoding => '7bit', + wantContentType => 'text/plain; charset=utf-8', + wantBlob => "some bare CR\r\n and LF\r\n in here", # gets rewritten + }, { + desc => 'text/plain with bare CR and LF chars and iso-8859-1 charset', + blob => "some bare CR\r and LF\n in here", + bodyStructure => { + type => 'text/plain', + charset => 'iso-8859-1', + }, + wantEncoding => '7bit', + wantContentType => 'text/plain; charset=iso-8859-1', + wantBlob => "some bare CR\r\n and LF\r\n in here", # gets rewritten + }, { + desc => 'text/plain with bare CR and LF chars and long MIME lines', + blob => "some bare CR\rand LF\n" . ("long" x 250) . "\r\nline", + bodyStructure => { + type => 'text/plain', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/plain', + wantBlob => "some bare CR\r\nand LF\r\n" . ("long" x 250) . "\r\nline", # gets rewritten + }, { + desc => 'text/html with NUL char', + blob => "
\x00
", + bodyStructure => { + type => 'text/html', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/html', + }, { + desc => 'text/html with NUL char and utf-8 charset', + blob => "
\x00
", + bodyStructure => { + type => 'text/html', + charset => 'utf-8', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/html; charset=utf-8', + }, { + desc => 'text/html with bare CR and LF chars', + blob => "

bare CR\r

bare LF\n

", + bodyStructure => { + type => 'text/html', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/html', + }, { + desc => 'text/html UTF-8 without charset from EmailBodyValue', + bodyValue => "some \N{TOMATO} utf-8", + bodyStructure => { + type => 'text/html', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'text/html; charset=utf-8' + }); + + my @rfc822Tests = ({ + desc => 'message/rfc822 with 7bit content', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/plain\r\n" . + "\r\n" . + "This is a test email.", + bodyStructure => { + type => 'message/rfc822', + }, + wantEncoding => '7bit', + wantContentType => 'message/rfc822', + }, { + desc => 'message/rfc822 with ASCII control char', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/plain\r\n" . + "\r\n" . + "ding \N{U+0007} dong", + bodyStructure => { + type => 'message/rfc822', + }, + wantEncoding => '7bit', + wantContentType => 'message/rfc822', + }, { + desc => 'message/rfc822 with NUL char', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + "a NUL\x00 char", + bodyStructure => { + type => 'message/rfc822', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/rfc822 with bare LF', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + "a LF\n char", + bodyStructure => { + type => 'message/rfc822', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/rfc822 with bare CR', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + "a CR\r char", + bodyStructure => { + type => 'message/rfc822', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/rfc822 with long MIME line', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + ("long" x 250) . "\r\nline", + bodyStructure => { + type => 'message/rfc822', + }, + wantEncoding => 'binary', + wantContentType => 'message/rfc822', + }, { + desc => 'message/rfc822 with UTF-8 in body', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + encode('utf-8', "some \N{TOMATO} utf-8"), + bodyStructure => { + type => 'message/rfc822', + }, + wantEncoding => '8bit', + wantContentType => 'message/rfc822', + }, { + desc => 'message/rfc822 with UTF-8 in header', + blob => + "From: " . encode('utf-8', "j\x{00F8}ran\@example.com") . "\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + "hello", + bodyStructure => { + type => 'message/rfc822', + }, + wantEncoding => '8bit', + wantContentType => 'message/rfc822', + }, { + desc => 'message/partial with 7bit content', + blob => "Some partial", + bodyStructure => { + 'header:content-type' => ' message/partial; number=2; total=3; id="2Yt4s@example.com"', + }, + wantEncoding => '7bit', + wantContentType => 'message/partial; number=2; total=3; id="2Yt4s@example.com"', + }, { + desc => 'message/partial with NUL char', + blob => "Some NUL\x00 partial", + bodyStructure => { + 'header:content-type' => ' message/partial; number=2; total=3; id="2Yt4s@example.com"', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/partial with bare LF char', + blob => "Some LF\n partial", + bodyStructure => { + 'header:content-type' => ' message/partial; number=2; total=3; id="2Yt4s@example.com"', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/partial with bare CR char', + blob => "Some CR\r partial", + bodyStructure => { + 'header:content-type' => ' message/partial; number=2; total=3; id="2Yt4s@example.com"', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/partial with long MIME line', + blob => ("long" x 250) . "\r\nline", + bodyStructure => { + 'header:content-type' => ' message/partial; number=2; total=3; id="2Yt4s@example.com"', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/partial with UTF-8 char', + blob => encode('utf-8', "some \N{TOMATO} utf-8"), + bodyStructure => { + 'header:content-type' => ' message/partial; number=2; total=3; id="2Yt4s@example.com"', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/global with 7bit content', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/plain\r\n" . + "\r\n" . + "This is a test email.", + bodyStructure => { + type => 'message/global', + }, + wantEncoding => '7bit', + wantContentType => 'message/global', + }, { + desc => 'message/global with ASCII control char', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/plain\r\n" . + "\r\n" . + "ding \N{U+0007} dong", + bodyStructure => { + type => 'message/global', + }, + wantEncoding => '7bit', + wantContentType => 'message/global', + }, { + desc => 'message/global with NUL char', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + "a NUL\x00 char", + bodyStructure => { + type => 'message/global', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/global with bare LF', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + "a LF\n char", + bodyStructure => { + type => 'message/global', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/global with bare CR', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + "a CR\r char", + bodyStructure => { + type => 'message/global', + }, + wantInvalidProperties => ['bodyStructure/type'], + }, { + desc => 'message/global with long MIME line', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + ("long" x 250) . "\r\nline", + bodyStructure => { + type => 'message/global', + }, + wantEncoding => 'quoted-printable', + wantContentType => 'message/global', + }, { + desc => 'message/global with UTF-8 in body', + blob => + "From: from\@local\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + encode('utf-8', "some \N{TOMATO} utf-8"), + bodyStructure => { + type => 'message/global', + }, + wantEncoding => '8bit', + wantContentType => 'message/global', + }, { + desc => 'message/global with UTF-8 in header', + blob => + "From: " . encode('utf-8', "j\x{00F8}ran\@example.com") . "\r\n" . + "To: to\@local\r\n" . + "Subject: Test subject\r\n" . + "Date: Wed, 27 Apr 2019 13:21:50 -0500\r\n" . + "MIME-Version: 1.0\r\n" . + "Content-Type: text/html\r\n" . + "\r\n" . + "hello", + bodyStructure => { + type => 'message/global', + }, + wantEncoding => '8bit', + wantContentType => 'message/global', + }, { + desc => 'message/http with NULs, LFs and all the other bogus stuff', + blob => + "A NUL\x00 char, LF\n, CR\r,\r\n" . + "bogus \xe2\x28\xa1 UTF-8, a\r\n" . + ("long" x 250) . "\r\nline", + bodyStructure => { + type => 'message/http', + }, + wantEncoding => 'base64', + wantContentType => 'message/http', + }); + + my @otherTests = ({ + desc => 'application/json with 7bit-safe content', + blob => '{"hello":"world"}', + bodyStructure => { + type => 'application/json', + }, + wantEncoding => 'base64', + wantContentType => 'application/json', + }, { + desc => 'application/json with UTF-8 content', + blob => encode('utf-8', '{"hello":"' . "\N{WORLD MAP}" . '"}'), + bodyStructure => { + type => 'application/json', + }, + wantEncoding => 'base64', + wantContentType => 'application/json', + }); + + my @tests = (@textTests, @rfc822Tests, @otherTests); + + # We optionally keep all test input and results in here. + my @email2MimeTests; + + while (my ($i, $tc) = each @tests) { + my $emailCreationId = "email" . ($i + 1); + my $email = { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => q{foo@bar} }], + subject => "$tc->{desc} ($emailCreationId)", + bodyStructure => $tc->{bodyStructure}, + }; + + if ($writeEmail2MimeTests) { + # Make this email as reproducible as possible. + $email->{'header:Date'} = 'Thu, 20 Jun 2024 13:28:51 +0200'; + $email->{messageId} = [$uuidgen->create_str() . '@local']; + } + + my @jmapMethods = ( + ['Email/set', { + create => { $emailCreationId => $email }, + }, 'createEmail'], + ['Email/get', { + ids => [ "#$emailCreationId" ], + properties => [ 'bodyStructure' ], + bodyProperties => [ + 'header:content-transfer-encoding:asText', + 'header:content-type:asText', + 'partId', + 'blobId', + ], + fetchAllBodyValues => JSON::true, + }, 'getEmail' ] + ); + + my $blobCreationId = "blob" . ($i + 1); + if ($tc->{blob}) { + unshift(@jmapMethods, + ['Blob/upload', { + create => { + $blobCreationId => { + data => [{ + 'data:asBase64' => encode_base64($tc->{blob}, "") + }], + }, + }, + }, 'uploadBlob'], + ); + $email->{bodyStructure}{blobId} = "#$blobCreationId", + } + elsif ($tc->{bodyValue}) { + $email->{bodyValues} = { 1 => { value => $tc->{bodyValue} } }; + $email->{bodyStructure}{partId} = "1"; + } + + xlog $self, "Create $emailCreationId: $tc->{desc}"; + my $res = $jmap->CallMethods(\@jmapMethods); + my %jmapResponses = map { $_->[2] => $_->[1] } @{$res}; + + xlog $self, "Assert test result"; + if ($tc->{wantInvalidProperties}) { + my $jres = $jmapResponses{createEmail}; + $self->assert_deep_equals({ + type => 'invalidProperties', + properties => $tc->{wantInvalidProperties}, + }, $jmapResponses{createEmail}{notCreated}{$emailCreationId}); + } else { + my $jres = $jmapResponses{createEmail}; + $self->assert_not_null($jmapResponses{createEmail}{ + created}{$emailCreationId}); + + xlog $self, "Assert expected encoding and content type"; + my $gotBodyPart = $jmapResponses{getEmail}{list}[0]{bodyStructure}; + $self->assert_str_equals($tc->{wantEncoding}, + $gotBodyPart->{'header:content-transfer-encoding:asText'}); + $self->assert_str_equals($tc->{wantContentType}, + $gotBodyPart->{'header:content-type:asText'}); + + xlog $self, "Assert blob contents of email body part"; + $res = $jmap->Download('cassandane', $gotBodyPart->{blobId}); + if ($tc->{wantBlob}) { + # Binary comparison + $self->assert_str_equals($tc->{wantBlob}, $res->{content}); + } elsif ($tc->{blob}) { + # Binary comparison + $self->assert_str_equals($tc->{blob}, $res->{content}); + } elsif ($tc->{bodyValue}) { + # String comparison + $self->assert_str_equals( + $tc->{bodyValue}, decode('utf-8', $res->{content})); + } + } + + if ($writeEmail2MimeTests) { + my $mimeTest = { + id => $emailCreationId, + desc => $tc->{desc}, + email => clone($email) + }; + delete $mimeTest->{email}{mailboxIds}; + if ($tc->{blob}) { + $mimeTest->{blobs} = { + $blobCreationId => encode_base64($tc->{blob}, ""), + }; + } + if ($tc->{wantInvalidProperties}) { + $mimeTest->{want} = { + invalidProperties => clone($tc->{wantInvalidProperties}), + }; + } else { + my $emailBlobId = $jmapResponses{createEmail}{ + created}{$emailCreationId}{blobId}; + my $res = $jmap->Download('cassandane', $emailBlobId); + $mimeTest->{want} = { + mimeMessage => encode_base64($res->{content}, "") + }; + } + push(@email2MimeTests, $mimeTest); + } + } + + if (@email2MimeTests) { + my $date = DateTime->now()->strftime('%Y%m%d'); + my ($outfh, $outfname) = tempfile( + TEMPLATE => "email_set_create_encoding-$date-XXXXX", + DIR => $self->{instance}->{basedir} . "/tmp", + SUFFIX => '.json'); + print $outfh JSON->new->pretty->encode(\@email2MimeTests); + close $outfh; + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_create_no_xmailer_header b/cassandane/tiny-tests/JMAPEmail/email_set_create_no_xmailer_header new file mode 100644 index 0000000000..e0949df75b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_create_no_xmailer_header @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_create_no_xmailer_header + : needs_component_jmap { + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $res = $jmap->CallMethods([ + [ + 'Email/set', + { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + to => [ { email => 'test@example.com' } ], + subject => 'test', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + }, + }, + }, + }, + }, + 'R1' + ], + [ 'Email/get', { ids => ['#email1'], properties => ['header:x-mailer'] }, 'R2' ], + ]); + + $self->assert_not_null($res->[1][1]{list}[0]{id}); + $self->assert_null($res->[1][1]{list}[0]{'header:x-mailer'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_create_snooze b/cassandane/tiny-tests/JMAPEmail/email_set_create_snooze new file mode 100644 index 0000000000..fcca2a9505 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_create_snooze @@ -0,0 +1,116 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_create_snooze + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd + :needs_component_sieve :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # snoozed property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + xlog $self, "create snooze mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "snoozed", + parentId => undef, + role => "snoozed" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $snoozedmbox = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R4"] + ]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsId = $res->[0][1]{created}{"1"}{id}; + + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => 30)); + my $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + my $draft = { + mailboxIds => { $snoozedmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + sender => [{ name => "Marvin the Martian", email => "marvin\@acme.local" }], + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + { name => "Rainer M\N{LATIN SMALL LETTER U WITH DIAERESIS}ller", email => "rainer\@de.local" }, + ], + cc => [ + { name => "Elmer Fudd", email => "elmer\@acme.local" }, + { name => "Porky Pig", email => "porky\@acme.local" }, + ], + bcc => [ + { name => "Wile E. Coyote", email => "coyote\@acme.local" }, + ], + replyTo => [ { name => undef, email => "the.other.sam\@acme.local" } ], + subject => "Memo", + textBody => [{ partId => '1' }], + htmlBody => [{ partId => '2' }], + bodyValues => { + '1' => { value => "I'm givin' ya one last chance ta surrenda!" }, + '2' => { value => "Oh!!! I hate that Rabbit." }, + }, + keywords => { '$draft' => JSON::true }, + snoozed => { "until" => "$datestr", "moveToMailboxId" => "$draftsId" }, + }; + + xlog $self, "Create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + + $self->assert_deep_equals($msg->{mailboxIds}, $draft->{mailboxIds}); + $self->assert_deep_equals($msg->{from}, $draft->{from}); + $self->assert_deep_equals($msg->{sender}, $draft->{sender}); + $self->assert_deep_equals($msg->{to}, $draft->{to}); + $self->assert_deep_equals($msg->{cc}, $draft->{cc}); + $self->assert_deep_equals($msg->{bcc}, $draft->{bcc}); + $self->assert_deep_equals($msg->{replyTo}, $draft->{replyTo}); + $self->assert_str_equals($msg->{subject}, $draft->{subject}); + $self->assert_equals(JSON::true, $msg->{keywords}->{'$draft'}); + $self->assert_num_equals(1, scalar keys %{$msg->{keywords}}); + $self->assert_str_equals($datestr, $msg->{snoozed}{'until'}); + $self->assert_str_equals($datestr, $msg->{addedDates}{"$snoozedmbox"}); + + # Now change the draft keyword, which is allowed since approx ~Q1/2018. + xlog $self, "Update a draft"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $id => { 'keywords/$draft' => undef } }, + }, "R1"] + ]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "trigger re-delivery of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 30 ); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $id ], + properties => [ 'mailboxIds', 'keywords', 'snoozed', 'addedDates' ]}, "R7" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_equals(JSON::true, $msg->{mailboxIds}{"$draftsId"}); + $self->assert_num_equals(0, scalar keys %{$msg->{keywords}}); + $self->assert_not_null($msg->{snoozed}); + $self->assert_str_equals($datestr, $msg->{snoozed}{'until'}); + $self->assert_str_equals($datestr, $msg->{addedDates}{"$draftsId"}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_date b/cassandane/tiny-tests/JMAPEmail/email_set_date new file mode 100644 index 0000000000..27cd175644 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_date @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_date + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => q{foo@bar} }], + to => [{ email => q{bar@foo} }], + sentAt => '2019-05-02T03:15:00+07:00', + subject => "test", + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "A text body", + }, + }, + } + }, + }, 'R1'], + ['Email/get', { + ids => ['#email1'], + properties => ['sentAt', 'header:Date'], + }, 'R2'], + ]); + my $email = $res->[1][1]{list}[0]; + $self->assert_str_equals('2019-05-02T03:15:00+07:00', $email->{sentAt}); + $self->assert_str_equals(' Thu, 02 May 2019 03:15:00 +0700', $email->{'header:Date'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_delayed_deleted_mailbox b/cassandane/tiny-tests/JMAPEmail/email_set_delayed_deleted_mailbox new file mode 100644 index 0000000000..c5514cdea7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_delayed_deleted_mailbox @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_delayed_deleted_mailbox + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Create mailbox A"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + } + }, 'R1'], + ]); + my $mboxA = $res->[0][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + + xlog "Create an email in Inbox"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + messageId => ['email1@local'], + subject => 'test', + keywords => { + '$seen' => JSON::true, + }, + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ]); + my $email1 = $res->[0][1]{created}{email1}{id}; + $self->assert_not_null($email1); + + xlog "Destroy mailbox A"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [ $mboxA ], + }, 'R2'], + ]); + $self->assert_deep_equals([$mboxA], $res->[0][1]{destroyed}); + + xlog "Can't move email to destroyed mailbox A"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $email1 => { + mailboxIds => { + $mboxA => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_deep_equals(['mailboxIds'], + $res->[0][1]{notUpdated}{$email1}{properties}); + + xlog "Can't create an email in destroyed mailbox A";; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email2 => { + mailboxIds => { + $mboxA => JSON::true, + }, + messageId => ['email2@local'], + subject => 'test', + keywords => { + '$seen' => JSON::true, + }, + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ]); + $self->assert_deep_equals(['mailboxIds'], + $res->[0][1]{notCreated}{email2}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_destroy b/cassandane/tiny-tests/JMAPEmail/email_set_destroy new file mode 100644 index 0000000000..e5d7927e0c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_destroy @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_destroy + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create mailboxes"; + my $res = $jmap->CallMethods( + [ + [ + 'Mailbox/set', + { + create => { + "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }, + "2" => { + name => "foo", + parentId => undef, + }, + "3" => { + name => "bar", + parentId => undef, + }, + } + }, + "R1" + ] + ] + ); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null( $res->[0][1]{created} ); + my $mailboxids = { + $res->[0][1]{created}{"1"}{id} => JSON::true, + $res->[0][1]{created}{"2"}{id} => JSON::true, + $res->[0][1]{created}{"3"}{id} => JSON::true, + }; + + xlog $self, "Create a draft"; + my $draft = { + mailboxIds => $mailboxids, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ], + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" } ], + subject => "created", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "Oh!!! I *hate* that Rabbit." }}, + keywords => { '$draft' => JSON::true }, + }; + $res = $jmap->CallMethods( + [ [ 'Email/set', { create => { "1" => $draft } }, "R1" ] ], + ); + my $id = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($id); + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods( [ [ 'Email/get', { ids => [$id] }, "R1" ] ]); + $self->assert_num_equals(3, scalar keys %{$res->[0][1]->{list}[0]{mailboxIds}}); + + xlog $self, "Destroy draft $id"; + $res = $jmap->CallMethods( + [ [ 'Email/set', { destroy => [ $id ] }, "R1" ] ], + ); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods( [ [ 'Email/get', { ids => [$id] }, "R1" ] ]); + $self->assert_str_equals($id, $res->[0][1]->{notFound}[0]); + + xlog $self, "Get emails"; + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min >= 9)) { + xlog $self, "Attempt to destroy draft $id again"; + $res = $jmap->CallMethods( + [ [ 'Email/set', { destroy => [ $id ] }, "R1" ] ], + ); + $self->assert_not_null($id, $res->[0][1]{notDestroyed}{$id}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_destroy_bulk b/cassandane/tiny-tests/JMAPEmail/email_set_destroy_bulk new file mode 100644 index 0000000000..0e27d515a1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_destroy_bulk @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_destroy_bulk + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $store = $self->{store}; + + my $talk = $self->{store}->get_client(); + + $talk->create('INBOX.A') or die; + $talk->create('INBOX.B') or die; + + # Email 1 is in both A and B mailboxes. + $store->set_folder('INBOX.A'); + $self->make_message('Email 1') || die; + $talk->copy(1, 'INBOX.B'); + + # Email 2 is in mailbox A. + $store->set_folder('INBOX.A'); + $self->make_message('Email 2') || die; + + # Email 3 is in mailbox B. + $store->set_folder('INBOX.B'); + $self->make_message('Email 3') || die; + + my $res = $jmap->CallMethods([['Email/query', { }, 'R1']]); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + my $ids = $res->[0][1]->{ids}; + + $res = $jmap->CallMethods([['Email/set', { destroy => $ids }, 'R1']]); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{destroyed}}); + +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_draft b/cassandane/tiny-tests/JMAPEmail/email_set_draft new file mode 100644 index 0000000000..a5ccbafe8a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_draft @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_draft + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $draft = { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + sender => [{ name => "Marvin the Martian", email => "marvin\@acme.local" }], + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + { name => "Rainer M\N{LATIN SMALL LETTER U WITH DIAERESIS}ller", email => "rainer\@de.local" }, + ], + cc => [ + { name => "Elmer Fudd", email => "elmer\@acme.local" }, + { name => "Porky Pig", email => "porky\@acme.local" }, + ], + bcc => [ + { name => "Wile E. Coyote", email => "coyote\@acme.local" }, + ], + replyTo => [ { name => undef, email => "the.other.sam\@acme.local" } ], + subject => "Memo", + textBody => [{ partId => '1' }], + htmlBody => [{ partId => '2' }], + bodyValues => { + '1' => { value => "I'm givin' ya one last chance ta surrenda!" }, + '2' => { value => "Oh!!! I hate that Rabbit." }, + }, + keywords => { '$draft' => JSON::true }, + }; + + xlog $self, "Create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + + $self->assert_deep_equals($msg->{mailboxIds}, $draft->{mailboxIds}); + $self->assert_deep_equals($msg->{from}, $draft->{from}); + $self->assert_deep_equals($msg->{sender}, $draft->{sender}); + $self->assert_deep_equals($msg->{to}, $draft->{to}); + $self->assert_deep_equals($msg->{cc}, $draft->{cc}); + $self->assert_deep_equals($msg->{bcc}, $draft->{bcc}); + $self->assert_deep_equals($msg->{replyTo}, $draft->{replyTo}); + $self->assert_str_equals($msg->{subject}, $draft->{subject}); + $self->assert_equals(JSON::true, $msg->{keywords}->{'$draft'}); + $self->assert_num_equals(1, scalar keys %{$msg->{keywords}}); + + # Now change the draft keyword, which is allowed since approx ~Q1/2018. + xlog $self, "Update a draft"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $id => { 'keywords/$draft' => undef } }, + }, "R1"] + ]); + $self->assert(exists $res->[0][1]{updated}{$id}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_email_duplicates_mailbox_counts b/cassandane/tiny-tests/JMAPEmail/email_set_email_duplicates_mailbox_counts new file mode 100644 index 0000000000..3fc3e5d1d6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_email_duplicates_mailbox_counts @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_email_duplicates_mailbox_counts + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $inboxid = $self->getinbox()->{id}; + + # This is the opposite of a tooManyMailboxes error. It makes + # sure that duplicate emails within a mailbox do not count + # as multiple mailbox instances. + + my $accountCapabilities = $self->get_account_capabilities(); + my $maxMailboxesPerEmail = $accountCapabilities->{'urn:ietf:params:jmap:mail'}{maxMailboxesPerEmail}; + + $self->assert($maxMailboxesPerEmail > 0); + + my $todo = $maxMailboxesPerEmail - 2; + + open(my $F, 'data/mime/simple.eml') || die $!; + for (1..$todo) { + $imap->create("INBOX.M$_") || die; + + # two copies in each folder + $imap->append("INBOX.M$_", $F) || die $@; + } + close($F); + + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['mailboxIds'] + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals($todo, scalar keys %{$res->[1][1]{list}[0]{mailboxIds}}); + + my $emailId = $res->[0][1]{ids}[0]; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId => { + 'keywords/foo' => JSON::true, + "mailboxIds/$inboxid" => JSON::true, + }, + } + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_encode_plain_text_attachment b/cassandane/tiny-tests/JMAPEmail/email_set_encode_plain_text_attachment new file mode 100644 index 0000000000..9bd145a689 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_encode_plain_text_attachment @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_encode_plain_text_attachment + :needs_component_sieve :needs_component_jmap :needs_dependency_chardet +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $text = "This line ends with a LF\nThis line does as well\n"; + + my $data = $jmap->Upload($text, "text/plain"); + my $blobId = $data->{blobId}; + + my $email = { + mailboxIds => { '$inbox' => JSON::true }, + from => [{ name => "Test", email => q{test@local} }], + subject => "test", + bodyStructure => { + type => "multipart/mixed", + subParts => [{ + type => 'text/plain', + partId => '1', + }, { + type => 'text/plain', + blobId => $blobId, + }, { + type => 'text/plain', + disposition => 'attachment', + blobId => $blobId, + }] + }, + bodyValues => { + 1 => { + value => "A plain text body", + } + } + }; + my $res = $jmap->CallMethods([ + ['Email/set', { create => { '1' => $email } }, 'R1'], + ['Email/get', { + ids => [ '#1' ], + properties => [ 'bodyStructure', 'bodyValues' ], + bodyProperties => [ + 'partId', 'blobId', 'type', 'header:Content-Transfer-Encoding', 'size' + ], + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]); + + xlog $self, "Assert that bare LF in inlined plain text gets expanded to CR LF"; + my $subPart = $res->[1][1]{list}[0]{bodyStructure}{subParts}[1]; + $self->assert_str_equals(' 7bit', $subPart->{'header:Content-Transfer-Encoding'}); + my $subPartBlob = $jmap->Download('cassandane', $subPart->{blobId}); + $self->assert_str_equals("This line ends with a LF\r\nThis line does as well\r\n", + $subPartBlob->{content}); + + xlog $self, "Assert that bare LF in attached plain text is kept as-is"; + $subPart = $res->[1][1]{list}[0]{bodyStructure}{subParts}[2]; + $self->assert_str_equals(' base64', $subPart->{'header:Content-Transfer-Encoding'}); + $subPartBlob = $jmap->Download('cassandane', $subPart->{blobId}); + $self->assert_str_equals("This line ends with a LF\nThis line does as well\n", + $subPartBlob->{content}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_filename b/cassandane/tiny-tests/JMAPEmail/email_set_filename new file mode 100644 index 0000000000..90c2b79049 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_filename @@ -0,0 +1,90 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_filename + :min_version_3_4 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Upload a data blob"; + my $binary = pack "H*", "beefcode"; + my $data = $jmap->Upload($binary, "image/gif"); + my $dataBlobId = $data->{blobId}; + + my @testcases = ({ + name => 'foo', + wantCt => " image/gif; name=\"foo\"", + wantCd => " attachment; filename=\"foo\"", + }, { + name => "I feel \N{WHITE SMILING FACE}", + wantCt => " image/gif; name=\"=?UTF-8?Q?I_feel_=E2=98=BA?=\"", + wantCd => " attachment; filename*=utf-8''I%20feel%20%E2%98%BA", + }, { + name => "foo" . ("_foo" x 20), + wantCt => " image/gif;\r\n\tname=\"=?UTF-8?Q?foo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffo?=\r\n =?UTF-8?Q?o=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo?=\"", + wantCd => " attachment;\r\n\tfilename*0=\"foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_f\";\r\n\tfilename*1=\"oo_foo_foo_foo_foo_foo\"", + }, { + name => "foo" . ("_foo" x 20) . "\N{WHITE SMILING FACE}", + wantCt => " image/gif;\r\n\tname=\"=?UTF-8?Q?foo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffo?=\r\n =?UTF-8?Q?o=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo=5Ffoo?=\r\n =?UTF-8?Q?=E2=98=BA?=\"", + wantCd => " attachment;\r\n\tfilename*0*=utf-8\'\'foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_foo_fo;\r\n\tfilename*1*=o_foo_foo_foo_foo_foo_foo_foo%E2%98%BA", + }, { + name => 'Incoming Email Flow.xml', + wantCt => " image/gif; name=\"Incoming Email Flow.xml\"", + wantCd => " attachment; filename=\"Incoming Email Flow.xml\"", + }, { + name => 'a"b\c.txt', + wantCt => " image/gif; name=\"a\\\"b\\\\c.txt\"", + wantCd => " attachment; filename=\"a\\\"b\\\\c.txt\"", + }); + + foreach my $tc (@testcases) { + xlog $self, "Checking name $tc->{name}"; + my $bodyStructure = { + type => "multipart/alternative", + subParts => [{ + type => 'text/plain', + partId => '1', + }, { + type => 'image/gif', + disposition => 'attachment', + name => $tc->{name}, + blobId => $dataBlobId, + }], + }; + + xlog $self, "Create email with body structure"; + my $inboxid = $self->getinbox()->{id}; + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [{ name => "Test", email => q{foo@bar} }], + subject => "test", + bodyStructure => $bodyStructure, + bodyValues => { + "1" => { + value => "A text body", + }, + }, + }; + my $res = $jmap->CallMethods([ + ['Email/set', { create => { '1' => $email } }, 'R1'], + ['Email/get', { + ids => [ '#1' ], + properties => [ 'bodyStructure' ], + bodyProperties => [ 'partId', 'blobId', 'type', 'name', 'disposition', 'header:Content-Type', 'header:Content-Disposition' ], + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]); + + my $gotBodyStructure = $res->[1][1]{list}[0]{bodyStructure}; + my $gotName = $gotBodyStructure->{subParts}[1]{name}; + $self->assert_str_equals($tc->{name}, $gotName); + my $gotCt = $gotBodyStructure->{subParts}[1]{'header:Content-Type'}; + $self->assert_str_equals($tc->{wantCt}, $gotCt); + my $gotCd = $gotBodyStructure->{subParts}[1]{'header:Content-Disposition'}; + $self->assert_str_equals($tc->{wantCd}, $gotCd); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_flagged b/cassandane/tiny-tests/JMAPEmail/email_set_flagged new file mode 100644 index 0000000000..224580ec40 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_flagged @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_flagged + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $drafts = $res->[0][1]{created}{"1"}{id}; + + my $draft = { + mailboxIds => { $drafts => JSON::true }, + keywords => { '$draft' => JSON::true, '$Flagged' => JSON::true }, + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "a flagged draft" }}, + }; + + xlog $self, "Create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + + $self->assert_deep_equals($msg->{mailboxIds}, $draft->{mailboxIds}); + $self->assert_equals(JSON::true, $msg->{keywords}->{'$flagged'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_getquota b/cassandane/tiny-tests/JMAPEmail/email_set_getquota new file mode 100644 index 0000000000..3b588b8868 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_getquota @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_getquota + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_quotalimits(storage => 1000); # that's 1000 * 1024 bytes + + my $jmap = $self->{jmap}; + my $service = $self->{instance}->get_service("http"); + my $inboxId = $self->getinbox()->{id}; + + # we need 'https://cyrusimap.org/ns/jmap/quota' capability for + # Quota/get method + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/quota'; + $jmap->DefaultUsing(\@using); + + my $res; + + $res = $jmap->CallMethods([ + ['Quota/get', { + accountId => 'cassandane', + ids => undef, + }, 'R1'], + ]); + + my $mailQuota = $res->[0][1]{list}[0]; + $self->assert_str_equals('mail', $mailQuota->{id}); + $self->assert_num_equals(0, $mailQuota->{used}); + $self->assert_num_equals(1000 * 1024, $mailQuota->{total}); + my $quotaState = $res->[0][1]{state}; + $self->assert_not_null($quotaState); + + xlog $self, "Create email"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + msgA1 => { + mailboxIds => { + $inboxId => JSON::true, + }, + from => [{ + email => q{test1@local}, + name => q{} + }], + to => [{ + email => q{test2@local}, + name => '', + }], + subject => 'foo', + keywords => { + '$seen' => JSON::true, + }, + }, + } + }, "R1"], + ['Quota/get', {}, 'R2'], + ]); + + $self->assert_str_equals('Quota/get', $res->[1][0]); + $mailQuota = $res->[1][1]{list}[0]; + $self->assert_str_equals('mail', $mailQuota->{id}); + $self->assert_num_not_equals(0, $mailQuota->{used}); + $self->assert_num_equals(1000 * 1024, $mailQuota->{total}); + $self->assert_str_not_equals($quotaState, $res->[1][1]{state}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_groupaddr b/cassandane/tiny-tests/JMAPEmail/email_set_groupaddr new file mode 100644 index 0000000000..4a7a8a8fb4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_groupaddr @@ -0,0 +1,205 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_groupaddr + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my @testCases = ({ + # Example from from Appendix A.1.3 of RFC 5322 + rawHeader => 'A Group:Ed Jones ,joe@where.test,John ', + wantAddresses => [{ + name => 'Ed Jones', + email => 'c@a.test', + }, { + name => undef, + email => 'joe@where.test' + }, { + name => 'John', + email => 'jdoe@one.test', + }], + wantGroupedAddresses => [{ + name => 'A Group', + addresses => [{ + name => 'Ed Jones', + email => 'c@a.test', + }, { + name => undef, + email => 'joe@where.test' + }, { + name => 'John', + email => 'jdoe@one.test', + }], + }], + }, { + # Example from JMAP mail spec, RFC 8621, Section 4.1.2.3 + rawHeader => '"James Smythe" , Friends:' + . 'jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= ' + . ';', + wantAddresses => [{ + name => 'James Smythe', + email => 'james@example.com' + }, { + name => undef, + email => 'jane@example.com' + }, { + name => "John Sm\N{U+00EE}th", + email => 'john@example.com' + }], + wantGroupedAddresses => [{ + name => undef, + addresses => [{ + name => 'James Smythe', + email => 'james@example.com' + }], + }, { + name => 'Friends', + addresses => [{ + name => undef, + email => 'jane@example.com' + }, { + name => "John Sm\N{U+00EE}th", + email => 'john@example.com' + }], + }] + }, { + # Issue https://github.com/cyrusimap/cyrus-imapd/issues/2959 + rawHeader => 'undisclosed-recipients:', + wantAddresses => [], + wantGroupedAddresses => [{ + name => 'undisclosed-recipients', + addresses => [], + }], + }, { + # Sanity check + rawHeader => 'addr1@local, addr2@local, GroupA:; addr3@local, ' + . 'GroupB:addr4@local,addr5@local;addr6@local', + wantAddresses => [{ + name => undef, + email => 'addr1@local', + }, { + name => undef, + email => 'addr2@local', + }, { + name => undef, + email => 'addr3@local', + }, { + name => undef, + email => 'addr4@local', + }, { + name => undef, + email => 'addr5@local', + }, { + name => undef, + email => 'addr6@local', + }], + wantGroupedAddresses => [{ + name => undef, + addresses => [{ + name => undef, + email => 'addr1@local', + }, { + name => undef, + email => 'addr2@local', + }], + }, { + name => 'GroupA', + addresses => [], + }, { + name => undef, + addresses => [{ + name => undef, + email => 'addr3@local', + }], + }, { + name => 'GroupB', + addresses => [{ + name => undef, + email => 'addr4@local', + }, { + name => undef, + email => 'addr5@local', + }], + }, { + name => undef, + addresses => [{ + name => undef, + email => 'addr6@local', + }], + }], + }); + + foreach my $tc (@testCases) { + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + from => [{ email => q{foo1@bar} }], + 'header:to' => $tc->{rawHeader}, + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email1 body", + }, + }, + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#email1'], + properties => [ + 'header:to:asAddresses', + 'header:to:asGroupedAddresses', + ], + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}{email1}{id}); + $self->assert_deep_equals($tc->{wantAddresses}, + $res->[1][1]{list}[0]->{'header:to:asAddresses'}); + $self->assert_deep_equals($tc->{wantGroupedAddresses}, + $res->[1][1]{list}[0]->{'header:to:asGroupedAddresses'}); + + # Now assert that group addresses loop back if set in Email/set. + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email2 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + from => [{ email => q{foo2@bar} }], + 'header:to:asGroupedAddresses' => $tc->{wantGroupedAddresses}, + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "email2 body", + }, + }, + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#email2'], + properties => [ + 'header:to:asAddresses', + 'header:to:asGroupedAddresses', + ], + }, 'R2'], + ]); + $self->assert_not_null($res->[0][1]{created}{email2}{id}); + $self->assert_deep_equals($tc->{wantAddresses}, + $res->[1][1]{list}[0]->{'header:to:asAddresses'}); + $self->assert_deep_equals($tc->{wantGroupedAddresses}, + $res->[1][1]{list}[0]->{'header:to:asGroupedAddresses'}); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_guidsearch_updated_internaldate b/cassandane/tiny-tests/JMAPEmail/email_set_guidsearch_updated_internaldate new file mode 100644 index 0000000000..fc1c571177 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_guidsearch_updated_internaldate @@ -0,0 +1,136 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_guidsearch_updated_internaldate + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/quota', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + xlog $self, "create emails"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'mA' => { + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + mailboxIds => { + '$inbox' => JSON::true, + }, + receivedAt => '2020-02-01T00:00:00Z', + subject => 'test', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + 'mB' => { + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + mailboxIds => { + '$inbox' => JSON::true, + }, + receivedAt => '2020-02-02T00:00:00Z', + subject => 'test', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ], $using); + my $emailIdA = $res->[0][1]->{created}{mA}{id}; + $self->assert_not_null($emailIdA); + my $emailBlobIdA = $res->[0][1]->{created}{mA}{blobId}; + $self->assert_not_null($emailBlobIdA); + my $emailIdB = $res->[0][1]->{created}{mB}{id}; + $self->assert_not_null($emailIdB); + + xlog "Download blob of message A"; + $res = $jmap->Download('cassandane', $emailBlobIdA); + my $emailBlobA = $res->{content}; + $self->assert_not_null($emailBlobA); + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Query sorted by internaldate, then destroy message A"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + subject => 'test', + }, + sort => [{ + property => 'receivedAt', + isAscending => JSON::true, + }] + }, 'R1'], + ['Email/set', { + destroy => [$emailIdA], + }, 'R2'], + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals([$emailIdA, $emailIdB], $res->[0][1]{ids}); + $self->assert_str_equals($emailIdA, $res->[1][1]{destroyed}[0]); + + xlog $self, "Compact search tier t1 to t2"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-z', 't2', '-t', 't1'); + + xlog "Sleep one second"; + sleep(1); + + xlog "Create dummy message"; + $self->make_message("dummy") || die; + + xlog "Append blob of message A via IMAP"; + $imap->append('INBOX', $emailBlobA) || die $@; + + xlog $self, "run incremental squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-i'); + + xlog "Query sorted by internaldate"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + subject => 'test', + }, + sort => [{ + property => 'receivedAt', + isAscending => JSON::true, + }] + }, 'R1'], + ], $using); + $self->assert_equals(JSON::true, $res->[0][1]{performance}{details}{isGuidSearch}); + $self->assert_deep_equals([$emailIdB, $emailIdA], $res->[0][1]{ids}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_header_control b/cassandane/tiny-tests/JMAPEmail/email_set_header_control new file mode 100644 index 0000000000..97f10f7a3f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_header_control @@ -0,0 +1,59 @@ +#!perl + +use Cassandane::Tiny; + +sub test_email_set_header_control + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my @testCases = ({ + name => 'header:x-header-crlf', + val => " a\r\nb", + valid => 0, + }, { + name => 'header:x-header-tab', + val => " a\tb", + valid => 0, + }, { + name => 'header:x-header-fold', + val => " a\r\n b", + valid => 1, + }); + + while (my ($i, $tc) = each @testCases) { + my $creationId = "email$i"; + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + $creationId => { + $tc->{name} => $tc->{val}, + mailboxIds => { + '$inbox' => JSON::true, + }, + subject => 'email', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'email', + } + }, + }, + }, + }, 'R1'], + ]); + + if ($tc->{valid}) { + $self->assert_not_null($res->[0][1]{created}{$creationId}); + } else { + $self->assert_deep_equals([$tc->{name}], + $res->[0][1]{notCreated}{$creationId}{properties}); + } + + } + +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_header_nfc b/cassandane/tiny-tests/JMAPEmail/email_set_header_nfc new file mode 100644 index 0000000000..e296019e17 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_header_nfc @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; +use MIME::Base64 qw(encode_base64); +use MIME::QuotedPrint qw(encode_qp); + +sub test_email_set_header_nfc + :needs_component_jmap :NoMunge8Bit :RFC2047_UTF8 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + my $jmap = $self->{jmap}; + $jmap->AddUsing('https://cyrusimap.org/ns/jmap/mail'); + + my $nonNfcEmailAddress = + "\N{U+1F71}\N{U+1F73}\N{U+1F75}" . '@' . + "\N{U+1F77}\N{U+1F79}\N{U+1F7B}.local"; + my $normalizedEmailAddress = + "\N{U+03AC}\N{U+03AD}\N{U+03AE}" . '@' . + "\N{U+03AF}\N{U+03CC}\N{U+03CD}.local"; + my $normalizedEmailAddressEncoded = + "\N{U+03AC}\N{U+03AD}\N{U+03AE}" . '@' . + "xn--kxa2dd.local"; + + my $nonNfcXHeaderValue = "0.5\N{U+212B}"; + my $normalizedXHeaderValue = "0.5\N{U+00C5}"; + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email => { + to => [{ + email => $nonNfcEmailAddress, + }], + 'header:x-my-header' => $nonNfcXHeaderValue, + 'header:x-my-header2:asText' => $nonNfcXHeaderValue, + mailboxIds => { + '$inbox' => JSON::true, + }, + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + }, + }, + subject => 'test', + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#email'], + properties => [ + 'to', + 'header:to', + 'header:x-my-header:asText', + 'header:x-my-header', + 'header:x-my-header2:asText', + 'header:x-my-header2', + ], + }, 'R2'], + ]); + + $self->assert_str_equals($normalizedEmailAddressEncoded, + $res->[1][1]{list}[0]{to}[0]{email}); + $self->assert_str_equals(" $normalizedEmailAddress", + $res->[1][1]{list}[0]{'header:to'}); + $self->assert_str_equals($normalizedXHeaderValue, + $res->[1][1]{list}[0]{'header:x-my-header:asText'}); + $self->assert_str_equals(" $nonNfcXHeaderValue", + $res->[1][1]{list}[0]{'header:x-my-header'}); + $self->assert_str_equals($normalizedXHeaderValue, + $res->[1][1]{list}[0]{'header:x-my-header2:asText'}); + $self->assert_str_equals(" =?UTF-8?Q?0.5=C3=85?=", + $res->[1][1]{list}[0]{'header:x-my-header2'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_headers b/cassandane/tiny-tests/JMAPEmail/email_set_headers new file mode 100644 index 0000000000..353cb52618 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_headers @@ -0,0 +1,166 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_headers + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $inboxid = $self->getinbox()->{id}; + + my $text = "x"; + + # Prepare test headers + my $headers = { + 'header:X-TextHeader8bit' => { + format => 'asText', + value => "I feel \N{WHITE SMILING FACE}", + wantRaw => " =?UTF-8?Q?I_feel_=E2=98=BA?=" + }, + 'header:X-TextHeaderFold' => { + format => 'asText', + value => "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus dictum facilisis feugiat.", + wantRaw => " Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus dictum\r\n facilisis feugiat.", + }, + 'header:X-TextHeaderEncodeNoWSP' => { + format => 'asText', + value => "x" x 80, + wantRaw => " =?UTF-8?Q?" . ("x" x 62) . "?=\r\n =?UTF-8?Q?" . ("x" x 18) . "?=" + }, + 'header:X-TextHeaderEncodeLongLine' => { + format => 'asText', + value => "xxx " . ("x" x 80) . " xxx", + wantRaw => " =?UTF-8?Q?xxx_" . ("x" x 58) . "?=\r\n =?UTF-8?Q?" . ("x" x 22) . "_xxx?=", + }, + 'header:X-TextHeaderShort' => { + format => 'asText', + value => "x", + wantRaw => " x" + }, + 'header:X-MsgIdsShort' => { + format => 'asMessageIds', + value => [ 'foobar@ba' ], + wantRaw => " ", + }, + 'header:X-MsgIdsLong' => { + format => 'asMessageIds', + value => [ + 'foobar@ba', + 'foobar@ba', + 'foobar@ba', + 'foobar@ba', + 'foobar@ba', + 'foobar@ba', + 'foobar@ba', + 'foobar@ba', + ], + wantRaw => (" " x 5)."\r\n".(" " x 3), + }, + 'header:X-AddrsShort' => { + format => 'asAddresses', + value => [{ 'name' => 'foo', email => 'bar@local' }], + wantRaw => ' foo ', + }, + 'header:X-AddrsQuoted' => { + format => 'asAddresses', + value => [{ 'name' => 'Foo Bar', email => 'quotbar@local' }], + wantRaw => ' "Foo Bar" ', + }, + 'header:X-Addrs8bit' => { + format => 'asAddresses', + value => [{ 'name' => "Rudi R\N{LATIN SMALL LETTER U WITH DIAERESIS}be", email => 'bar@local' }], + wantRaw => ' =?UTF-8?Q?Rudi_R=C3=BCbe?= ', + }, + 'header:X-AddrsLong' => { + format => 'asAddresses', + value => [{ + 'name' => 'foo', email => 'bar@local' + }, { + 'name' => 'foo', email => 'bar@local' + }, { + 'name' => 'foo', email => 'bar@local' + }, { + 'name' => 'foo', email => 'bar@local' + }, { + 'name' => 'foo', email => 'bar@local' + }, { + 'name' => 'foo', email => 'bar@local' + }, { + 'name' => 'foo', email => 'bar@local' + }, { + 'name' => 'foo', email => 'bar@local' + }], + wantRaw => (' foo ,' x 3)."\r\n".(' foo ,' x 4)."\r\n".' foo ', + }, + 'header:X-URLsShort' => { + format => 'asURLs', + value => [ 'foourl' ], + wantRaw => ' ', + }, + 'header:X-URLsLong' => { + format => 'asURLs', + value => [ + 'foourl', + 'foourl', + 'foourl', + 'foourl', + 'foourl', + 'foourl', + 'foourl', + 'foourl', + 'foourl', + 'foourl', + 'foourl', + ], + wantRaw => (' ,' x 6)."\r\n".(' ,' x 4).' ', + }, + }; + + # header fold/encode behaviour has changed -- discard some tests for + # older cyruses + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj < 3 || ($maj == 3 && $min < 5)) { + delete $headers->{'header:X-TextHeaderFold'}; + } + + # Prepare test email + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [ { email => q{test1@robmtest.vm}, name => q{} } ], + }; + while( my ($k, $v) = each %$headers ) { + $email->{$k.':'.$v->{format}} = $v->{value}, + } + + my @properties = keys %$headers; + while( my ($k, $v) = each %$headers ) { + push @properties, $k.':'.$v->{format}; + } + + + # Create and get mail + my $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ['Email/get', { + ids => [ "#1" ], + properties => \@properties, + }, "R2" ], + ]); + my $msg = $res->[1][1]{list}[0]; + + # Validate header values + while( my ($k, $v) = each %$headers ) { + xlog $self, "Validating $k"; + my $raw = $msg->{$k}; + my $val = $msg->{$k.':'.$v->{format}}; + # Check raw header + $self->assert_str_equals($v->{wantRaw}, $raw); + # Check formatted header + if (ref $v->{value} eq 'ARRAY') { + $self->assert_deep_equals($v->{value}, $val); + } else { + $self->assert_str_equals($v->{value}, $val); + } + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_intermediary_create b/cassandane/tiny-tests/JMAPEmail/email_set_intermediary_create new file mode 100644 index 0000000000..6e3f5d700d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_intermediary_create @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_intermediary_create + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.foo") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxId1 = $mboxByName{'i1'}->{id}; + + xlog $self, "Create email in intermediary mailbox"; + my $email = { + mailboxIds => { + $mboxId1 => JSON::true + }, + from => [{ + email => q{test1@local}, + name => q{} + }], + to => [{ + email => q{test2@local}, + name => '', + }], + subject => 'foo', + }; + + xlog $self, "create and get email"; + $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ['Email/get', { ids => [ "#1" ] }, "R2" ], + ]); + $self->assert_not_null($res->[0][1]{created}{1}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$mboxId1}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_intermediary_move b/cassandane/tiny-tests/JMAPEmail/email_set_intermediary_move new file mode 100644 index 0000000000..b315dd7f4d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_intermediary_move @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_intermediary_move + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.foo") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxId1 = $mboxByName{'i1'}->{id}; + my $mboxIdFoo = $mboxByName{'foo'}->{id}; + + xlog $self, "Create email"; + my $email = { + mailboxIds => { + $mboxIdFoo => JSON::true + }, + from => [{ + email => q{test1@local}, + name => q{} + }], + to => [{ + email => q{test2@local}, + name => '', + }], + subject => 'foo', + }; + xlog $self, "create and get email"; + $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ]); + my $emailId = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($emailId); + + xlog $self, "Move email to intermediary mailbox"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId => { + mailboxIds => { + $mboxId1 => JSON::true, + }, + }, + }, + }, 'R1'], + ['Email/get', { ids => [ $emailId ] }, "R2" ], + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + $self->assert_equals(JSON::true, $res->[1][1]{list}[0]{mailboxIds}{$mboxId1}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_issue2293 b/cassandane/tiny-tests/JMAPEmail/email_set_issue2293 new file mode 100644 index 0000000000..9158cfbe3e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_issue2293 @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_issue2293 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $inboxid = $self->getinbox()->{id}; + + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [ { email => q{test1@robmtest.vm}, name => q{} } ], + to => [ { + email => q{foo@bar.com}, + name => "asd \x{529b}\x{9928}\x{5fc5} asd \x{30ec}\x{30f1}\x{30b9}" + } ], + }; + + xlog $self, "create and get email"; + my $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ['Email/get', { ids => [ "#1" ] }, "R2" ], + ]); + my $ret = $res->[1][1]->{list}[0]; + $self->assert_str_equals($email->{to}[0]{email}, $ret->{to}[0]{email}); + $self->assert_str_equals($email->{to}[0]{name}, $ret->{to}[0]{name}); + + + xlog $self, "create and get email"; + $email->{to}[0]{name} = "asd \x{529b}\x{9928}\x{5fc5} asd \x{30ec}\x{30f1}\x{30b9} asd \x{3b1}\x{3bc}\x{3b5}\x{3c4}"; + + $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ['Email/get', { ids => [ "#1" ] }, "R2" ], + ]); + $ret = $res->[1][1]->{list}[0]; + $self->assert_str_equals($email->{to}[0]{email}, $ret->{to}[0]{email}); + $self->assert_str_equals($email->{to}[0]{name}, $ret->{to}[0]{name}); + + xlog $self, "create and get email"; + my $to = [{ + name => "abcdefghijklmnopqrstuvwxyz1", + email => q{abcdefghijklmnopqrstuvwxyz1@local}, + }, { + name => "abcdefghijklmnopqrstuvwxyz2", + email => q{abcdefghijklmnopqrstuvwxyz2@local}, + }, { + name => "abcdefghijklmnopqrstuvwxyz3", + email => q{abcdefghijklmnopqrstuvwxyz3@local}, + }, { + name => "abcdefghijklmnopqrstuvwxyz4", + email => q{abcdefghijklmnopqrstuvwxyz4@local}, + }, { + name => "abcdefghijklmnopqrstuvwxyz5", + email => q{abcdefghijklmnopqrstuvwxyz5@local}, + }, { + name => "abcdefghijklmnopqrstuvwxyz6", + email => q{abcdefghijklmnopqrstuvwxyz6@local}, + }, { + name => "abcdefghijklmnopqrstuvwxyz7", + email => q{abcdefghijklmnopqrstuvwxyz7@local}, + }, { + name => "abcdefghijklmnopqrstuvwxyz8", + email => q{abcdefghijklmnopqrstuvwxyz8@local}, + }, { + name => "abcdefghijklmnopqrstuvwxyz9", + email => q{abcdefghijklmnopqrstuvwxyz9@local}, + }]; + $email->{to} = $to; + + $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ['Email/get', { ids => [ "#1" ] }, "R2" ], + ]); + $ret = $res->[1][1]->{list}[0]; + $self->assert_deep_equals($email->{to}, $ret->{to}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_issue2500 b/cassandane/tiny-tests/JMAPEmail/email_set_issue2500 new file mode 100644 index 0000000000..3532e0dab1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_issue2500 @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_issue2500 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inboxid = $self->getinbox()->{id}; + + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [{ name => "Test", email => q{foo@bar} }], + subject => "test", + bodyStructure => { + partId => '1', + charset => 'us/ascii', + }, + bodyValues => { + "1" => { + value => "A text body", + }, + }, + }; + my $res = $jmap->CallMethods([ + ['Email/set', { create => { '1' => $email } }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notCreated}{1}{type}); + $self->assert_str_equals('bodyStructure/charset', $res->[0][1]{notCreated}{1}{properties}[0]); + + delete $email->{bodyStructure}{charset}; + $email->{bodyStructure}{'header:Content-Type'} = 'text/plain;charset=us-ascii'; + $res = $jmap->CallMethods([ + ['Email/set', { create => { '1' => $email } }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notCreated}{1}{type}); + $self->assert_str_equals('bodyStructure/header:Content-Type', $res->[0][1]{notCreated}{1}{properties}[0]); + +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_keywords b/cassandane/tiny-tests/JMAPEmail/email_set_keywords new file mode 100644 index 0000000000..157425a01f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_keywords @@ -0,0 +1,138 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_keywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create IMAP mailboxes"; + $talk->create('INBOX.A') || die; + $talk->create('INBOX.B') || die; + $talk->create('INBOX.C') || die; + + xlog $self, "Get JMAP mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', { properties => [ 'name' ]}, "R1"]]); + my %jmailboxes = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(4, scalar keys %jmailboxes); + my $jmailboxA = $jmailboxes{A}; + my $jmailboxB = $jmailboxes{B}; + my $jmailboxC = $jmailboxes{C}; + + my %mailboxA; + my %mailboxB; + my %mailboxC; + + xlog $self, "Create message in mailbox A"; + $store->set_folder('INBOX.A'); + $mailboxA{1} = $self->make_message('Message'); + $mailboxA{1}->set_attributes(id => 1, uid => 1, flags => []); + + xlog $self, "Copy message from A to B"; + $talk->copy('1:*', 'INBOX.B'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Set IMAP flag foo on message A"; + $store->set_folder('INBOX.A'); + $store->_select(); + $talk->store('1', '+flags', '(foo)'); + + xlog $self, "Get JMAP keywords"; + $res = $jmap->CallMethods([ + ['Email/query', { }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'keywords'] + }, 'R2' ] + ]); + my $jmapmsg = $res->[1][1]{list}[0]; + my $keywords = { + foo => JSON::true + }; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); + + xlog $self, "Update JMAP email keywords"; + $keywords = { + bar => JSON::true, + baz => JSON::true, + }; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $jmapmsg->{id} => { + keywords => $keywords + } + } + }, 'R1'], + ['Email/get', { + ids => [ $jmapmsg->{id} ], + properties => ['keywords'] + }, 'R2' ] + ]); + $jmapmsg = $res->[1][1]{list}[0]; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); + + xlog $self, "Set \\Seen on message in mailbox B"; + $store->set_folder('INBOX.B'); + $store->_select(); + $talk->store('1', '+flags', '(\\Seen)'); + + xlog $self, "Patch JMAP email keywords and update mailboxIds"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $jmapmsg->{id} => { + 'keywords/bar' => undef, + 'keywords/qux' => JSON::true, + mailboxIds => { + $jmailboxB->{id} => JSON::true, + $jmailboxC->{id} => JSON::true, + } + } + } + }, 'R1'], + ['Email/get', { + ids => [ $jmapmsg->{id} ], + properties => ['keywords', 'mailboxIds'] + }, 'R2' ] + ]); + $jmapmsg = $res->[1][1]{list}[0]; + $keywords = { + baz => JSON::true, + qux => JSON::true, + }; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); + + $self->assert_str_not_equals($res->[0][1]{oldState}, $res->[0][1]{newState}); + + xlog $self, 'Patch $seen on email'; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $jmapmsg->{id} => { + 'keywords/$seen' => JSON::true + } + } + }, 'R1'], + ['Email/get', { + ids => [ $jmapmsg->{id} ], + properties => ['keywords', 'mailboxIds'] + }, 'R2' ] + ]); + $jmapmsg = $res->[1][1]{list}[0]; + $keywords = { + baz => JSON::true, + qux => JSON::true, + '$seen' => JSON::true, + }; + $self->assert_deep_equals($keywords, $jmapmsg->{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_keywords_bogus_values b/cassandane/tiny-tests/JMAPEmail/email_set_keywords_bogus_values new file mode 100644 index 0000000000..a0e991533f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_keywords_bogus_values @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_keywords_bogus_values + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # See https://github.com/cyrusimap/cyrus-imapd/issues/2439 + + $self->make_message("foo") || die; + my $res = $jmap->CallMethods([['Email/query', { }, "R1"]]); + my $emailId = $res->[0][1]{ids}[0]; + $self->assert_not_null($res); + + $res = $jmap->CallMethods([['Email/set', { + 'update' => { $emailId => { + keywords => { + 'foo' => JSON::false, + }, + }}, + }, 'R1' ]]); + $self->assert_not_null($res->[0][1]{notUpdated}{$emailId}); + + $res = $jmap->CallMethods([['Email/set', { + 'update' => { $emailId => { + 'keywords/foo' => JSON::false, + }, + }, + }, 'R1' ]]); + $self->assert_not_null($res->[0][1]{notUpdated}{$emailId}); + + $res = $jmap->CallMethods([['Email/set', { + 'update' => { $emailId => { + keywords => { + 'foo' => 1, + }, + }}, + }, 'R1' ]]); + $self->assert_not_null($res->[0][1]{notUpdated}{$emailId}); + + $res = $jmap->CallMethods([['Email/set', { + 'update' => { $emailId => { + 'keywords/foo' => 1, + }, + }, + }, 'R1' ]]); + $self->assert_not_null($res->[0][1]{notUpdated}{$emailId}); + + $res = $jmap->CallMethods([['Email/set', { + 'update' => { $emailId => { + keywords => { + 'foo' => 'true', + }, + }}, + }, 'R1' ]]); + $self->assert_not_null($res->[0][1]{notUpdated}{$emailId}); + + $res = $jmap->CallMethods([['Email/set', { + 'update' => { $emailId => { + 'keywords/foo' => 'true', + }, + }, + }, 'R1' ]]); + $self->assert_not_null($res->[0][1]{notUpdated}{$emailId}); + + $res = $jmap->CallMethods([['Email/set', { + 'update' => { $emailId => { + keywords => { + 'foo' => JSON::true, + }, + }}, + }, 'R1' ]]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_language_header b/cassandane/tiny-tests/JMAPEmail/email_set_language_header new file mode 100644 index 0000000000..a4b1dfddc8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_language_header @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_language_header + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + from => [{ email => q{foo1@bar} }], + bodyStructure => { + language => ['de-DE', 'en-CA'], + partId => '1', + }, + bodyValues => { + "1" => { + value => "Das ist eine Email. This is an email.", + }, + }, + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#email1'], + properties => [ + 'bodyStructure', + ], + bodyProperties => [ + 'language', + 'header:Content-Language', + ], + }, 'R2'], + ]); + $self->assert_str_equals(' de-DE, en-CA', + $res->[1][1]{list}[0]{bodyStructure}{'header:Content-Language'}); + $self->assert_deep_equals(['de-DE', 'en-CA'], + $res->[1][1]{list}[0]{bodyStructure}{language}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_location b/cassandane/tiny-tests/JMAPEmail/email_set_location new file mode 100644 index 0000000000..699435d7b4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_location @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_location + :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true, + }, + from => [{ email => q{foo1@bar} }], + bodyStructure => { + location => "http://example.com/uri", + partId => '1', + }, + bodyValues => { + "1" => { + value => "This is an email.", + }, + }, + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#email1'], + properties => [ + 'bodyStructure', + ], + bodyProperties => [ + 'location', + 'header:Content-Location', + ], + }, 'R2'], + ]); + $self->assert_str_equals(' http://example.com/uri', + $res->[1][1]{list}[0]{bodyStructure}{'header:Content-Location'}); + $self->assert_str_equals('http://example.com/uri', + $res->[1][1]{list}[0]{bodyStructure}{location}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_mailbox_alias b/cassandane/tiny-tests/JMAPEmail/email_set_mailbox_alias new file mode 100644 index 0000000000..a236081afb --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_mailbox_alias @@ -0,0 +1,73 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_mailbox_alias + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + # Create mailboxes + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "drafts" => { + name => "Drafts", + parentId => undef, + role => "drafts" + }, + "trash" => { + name => "Trash", + parentId => undef, + role => "trash" + } + } + }, "R1"] + ]); + my $draftsMboxId = $res->[0][1]{created}{drafts}{id}; + $self->assert_not_null($draftsMboxId); + my $trashMboxId = $res->[0][1]{created}{trash}{id}; + $self->assert_not_null($trashMboxId); + + # Create email in mailbox using role as id + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + "1" => { + mailboxIds => { + '$drafts' => JSON::true + }, + from => [{ email => q{from@local}, name => q{} } ], + to => [{ email => q{to@local}, name => q{} } ], + } + }, + }, 'R1'], + ['Email/get', { + ids => [ "#1" ], + properties => ['mailboxIds'], + }, "R2" ], + ]); + $self->assert_num_equals(1, scalar keys %{$res->[1][1]{list}[0]{mailboxIds}}); + $self->assert_not_null($res->[1][1]{list}[0]{mailboxIds}{$draftsMboxId}); + my $emailId = $res->[0][1]{created}{1}{id}; + + # Move email to mailbox using role as id + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId => { + 'mailboxIds/$drafts' => undef, + 'mailboxIds/$trash' => JSON::true + } + }, + }, 'R1'], + ['Email/get', { + ids => [ $emailId ], + properties => ['mailboxIds'], + }, "R2" ], + ]); + $self->assert_num_equals(1, scalar keys %{$res->[1][1]{list}[0]{mailboxIds}}); + $self->assert_not_null($res->[1][1]{list}[0]{mailboxIds}{$trashMboxId}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_mailboxids b/cassandane/tiny-tests/JMAPEmail/email_set_mailboxids new file mode 100644 index 0000000000..46cae1436e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_mailboxids @@ -0,0 +1,56 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_mailboxids + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $inboxid = $self->getinbox()->{id}; + $self->assert_not_null($inboxid); + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { + "1" => { name => "drafts", parentId => undef, role => "drafts" }, + }}, "R1"] + ]); + my $draftsid = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftsid); + + my $msg = { + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ], + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "I'm givin' ya one last chance ta surrenda!" }}, + keywords => { '$draft' => JSON::true }, + }; + + # Not OK: at least one mailbox must be specified + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $msg }}, "R1"]]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notCreated}{"1"}{type}); + $self->assert_str_equals('mailboxIds', $res->[0][1]{notCreated}{"1"}{properties}[0]); + $msg->{mailboxIds} = {}; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $msg }}, "R1"]]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notCreated}{"1"}{type}); + $self->assert_str_equals('mailboxIds', $res->[0][1]{notCreated}{"1"}{properties}[0]); + + # OK: drafts mailbox isn't required (anymore) + $msg->{mailboxIds} = { $inboxid => JSON::true }, + $msg->{subject} = "Email 1"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $msg }}, "R1"]]); + $self->assert(exists $res->[0][1]{created}{"1"}); + + # OK: drafts mailbox is OK to create in + $msg->{mailboxIds} = { $draftsid => JSON::true }, + $msg->{subject} = "Email 2"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $msg }}, "R1"]]); + $self->assert(exists $res->[0][1]{created}{"1"}); + + # OK: drafts mailbox is OK to create in, as is for multiple mailboxes + $msg->{mailboxIds} = { $draftsid => JSON::true, $inboxid => JSON::true }, + $msg->{subject} = "Email 3"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $msg }}, "R1"]]); + $self->assert(exists $res->[0][1]{created}{"1"}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_mimeversion b/cassandane/tiny-tests/JMAPEmail/email_set_mimeversion new file mode 100644 index 0000000000..d805dd35fe --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_mimeversion @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_mimeversion + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inboxid = $self->getinbox()->{id}; + + my $email1 = { + mailboxIds => { $inboxid => JSON::true }, + from => [{ name => "Test", email => q{foo@bar} }], + subject => "test", + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "A text body", + }, + }, + }; + my $email2 = { + mailboxIds => { $inboxid => JSON::true }, + from => [{ name => "Test", email => q{foo@bar} }], + subject => "test", + 'header:Mime-Version' => '1.1', + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "A text body", + }, + }, + }; + my $res = $jmap->CallMethods([ + ['Email/set', { create => { '1' => $email1 , 2 => $email2 } }, 'R1'], + ['Email/get', { ids => ['#1', '#2'], properties => ['header:mime-version'] }, 'R2'], + ]); + $self->assert_str_equals(' 1.0', $res->[1][1]{list}[0]{'header:mime-version'}); + $self->assert_str_equals(' 1.1', $res->[1][1]{list}[1]{'header:mime-version'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_move b/cassandane/tiny-tests/JMAPEmail/email_set_move new file mode 100644 index 0000000000..57f4bedb88 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_move @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_move + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Create test mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { + "a" => { name => "a", parentId => undef }, + "b" => { name => "b", parentId => undef }, + "c" => { name => "c", parentId => undef }, + "d" => { name => "d", parentId => undef }, + }}, "R1"] + ]); + $self->assert_num_equals( 4, scalar keys %{$res->[0][1]{created}} ); + my $a = $res->[0][1]{created}{"a"}{id}; + my $b = $res->[0][1]{created}{"b"}{id}; + my $c = $res->[0][1]{created}{"c"}{id}; + my $d = $res->[0][1]{created}{"d"}{id}; + + xlog $self, "Generate an email via IMAP"; + my %exp_sub; + $exp_sub{A} = $self->make_message( + "foo", body => "an email", + ); + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $id = $res->[0][1]->{ids}[0]; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + + local *assert_move = sub { + my ($moveto) = (@_); + + xlog $self, "move email to " . Dumper($moveto); + $res = $jmap->CallMethods( + [ [ 'Email/set', { + update => { $id => { 'mailboxIds' => $moveto } }, + }, "R1" ] ] ); + $self->assert(exists $res->[0][1]{updated}{$id}); + + $res = $jmap->CallMethods( [ [ 'Email/get', { ids => [$id], properties => ['mailboxIds'] }, "R1" ] ] ); + $msg = $res->[0][1]->{list}[0]; + + $self->assert_deep_equals($moveto, $msg->{mailboxIds}); + }; + + assert_move({$a => JSON::true, $b => JSON::true}); + assert_move({$a => JSON::true, $b => JSON::true, $c => JSON::true}); + assert_move({$d => JSON::true}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_move_keywords b/cassandane/tiny-tests/JMAPEmail/email_set_move_keywords new file mode 100644 index 0000000000..df5b25124a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_move_keywords @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_move_keywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + xlog $self, "Generate an email via IMAP"; + my %exp_sub; + $exp_sub{A} = $self->make_message( + "foo", body => "an email", + ); + xlog $self, "Set flags on message"; + $store->set_folder('INBOX'); + $talk->store('1', '+flags', '($foo \\Flagged)'); + + xlog $self, "get email"; + my $res = $jmap->CallMethods([ + ['Email/query', {}, 'R1'], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids'}, + properties => [ 'keywords', 'mailboxIds' ], + }, 'R2' ] + ]); + my $msg = $res->[1][1]->{list}[0]; + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + my $msgId = $msg->{id}; + my $inboxId = (keys %{$msg->{mailboxIds}})[0]; + $self->assert_not_null($inboxId); + my $keywords = $msg->{keywords}; + + xlog $self, "create Archive mailbox"; + $res = $jmap->CallMethods([ ['Mailbox/get', {}, 'R1'], ]); + my $mboxState = $res->[0][1]{state}; + $talk->create("INBOX.Archive", "(USE (\\Archive))") || die; + $res = $jmap->CallMethods([ + ['Mailbox/changes', {sinceState => $mboxState }, 'R1'], + ]); + my $archiveId = $res->[0][1]{created}[0]; + $self->assert_not_null($archiveId); + $self->assert_deep_equals([], $res->[0][1]->{updated}); + $self->assert_deep_equals([], $res->[0][1]->{destroyed}); + + xlog $self, "move email to Archive"; + xlog $self, "update email"; + $res = $jmap->CallMethods([ + ['Email/set', { update => { + $msgId => { + mailboxIds => { $archiveId => JSON::true } + }, + }}, "R1"], + ['Email/get', { ids => [ $msgId ], properties => ['keywords'] }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$msgId}); + $self->assert_deep_equals($keywords, $res->[1][1]{list}[0]{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_move_multiuid_patch b/cassandane/tiny-tests/JMAPEmail/email_set_move_multiuid_patch new file mode 100644 index 0000000000..c9b85d5096 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_move_multiuid_patch @@ -0,0 +1,81 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_move_multiuid_patch + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog "Set up mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { + }, 'R1'], + ['Mailbox/set', { + create => { + "a" => { name => "a", parentId => undef }, + }, + }, 'R2'], + ]); + my $srcMboxId = $res->[0][1]{ids}[0]; + $self->assert_not_null($srcMboxId); + my $dstMboxId = $res->[1][1]{created}{a}{id}; + $self->assert_not_null($dstMboxId); + + + xlog "Append same message twice to inbox"; + my $rawMessage = <<"EOF"; +From: \r +To: to\@local\r +Subject: test\r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test\r +EOF + $imap->append('INBOX', $rawMessage) || die $@; + $imap->append('INBOX', $rawMessage) || die $@; + my $msgCount = $imap->message_count("INBOX"); + $self->assert_num_equals(2, $msgCount); + $res = $jmap->CallMethods([ + ['Email/query', { + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'mailboxIds' ], + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + my $emailId = $res->[0][1]{ids}[0]; + $self->assert_deep_equals( + { $srcMboxId => JSON::true }, + $res->[1][1]{list}[0]{mailboxIds} + ); + + xlog "Move email to destination mailbox with mailboxIds patch"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId => { + 'mailboxIds/' . $srcMboxId => undef, + 'mailboxIds/' . $dstMboxId => JSON::true, + }, + }, + }, 'R1'], + ['Email/get', { + ids => [$emailId], + properties => [ 'mailboxIds' ], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + $self->assert_deep_equals( + { $dstMboxId => JSON::true }, + $res->[1][1]{list}[0]{mailboxIds} + ); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_move_multiuid_set b/cassandane/tiny-tests/JMAPEmail/email_set_move_multiuid_set new file mode 100644 index 0000000000..f89e63adef --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_move_multiuid_set @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_move_multiuid_set + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog "Set up mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { + }, 'R1'], + ['Mailbox/set', { + create => { + "a" => { name => "a", parentId => undef }, + }, + }, 'R2'], + ]); + my $srcMboxId = $res->[0][1]{ids}[0]; + $self->assert_not_null($srcMboxId); + my $dstMboxId = $res->[1][1]{created}{a}{id}; + $self->assert_not_null($dstMboxId); + + + xlog "Append same message twice to inbox"; + my $rawMessage = <<"EOF"; +From: \r +To: to\@local\r +Subject: test\r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test\r +EOF + $imap->append('INBOX', $rawMessage) || die $@; + $imap->append('INBOX', $rawMessage) || die $@; + my $msgCount = $imap->message_count("INBOX"); + $self->assert_num_equals(2, $msgCount); + $res = $jmap->CallMethods([ + ['Email/query', { + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => [ 'mailboxIds' ], + }, 'R2'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + my $emailId = $res->[0][1]{ids}[0]; + $self->assert_deep_equals( + { $srcMboxId => JSON::true }, + $res->[1][1]{list}[0]{mailboxIds} + ); + + xlog "Move email to destination mailbox with mailboxIds set"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId => { + mailboxIds => { + $dstMboxId => JSON::true + } + }, + }, + }, 'R1'], + ['Email/get', { + ids => [$emailId], + properties => [ 'mailboxIds' ], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + $self->assert_deep_equals( + { $dstMboxId => JSON::true }, + $res->[1][1]{list}[0]{mailboxIds} + ); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_multipart_related b/cassandane/tiny-tests/JMAPEmail/email_set_multipart_related new file mode 100644 index 0000000000..9e8a1ede26 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_multipart_related @@ -0,0 +1,56 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_multipart_related + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $data = $jmap->Upload((pack "H*", "beefcode"), "image/gif"); + my $blobId = $data->{blobId}; + $self->assert_not_null($blobId); + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ + email => 'from@local' + }], + subject => "test", + bodyStructure => { + type => "multipart/related", + subParts => [{ + type => 'text/html', + partId => '1', + }, { + type => 'image/gif', + blobId => $blobId, + }], + }, + bodyValues => { + "1" => { + value => "test", + }, + }, + }, + }, + }, 'R1'], + ['Email/get', { + ids => [ '#email1' ], + properties => [ 'bodyStructure' ], + bodyProperties => [ 'type', 'header:Content-Type' ], + }, 'R2' ], + ]); + $self->assert_not_null($res->[0][1]{created}{email1}); + $self->assert_str_equals('multipart/related', + $res->[1][1]{list}[0]{bodyStructure}{type}); + + my $ct = $res->[1][1]{list}[0]{bodyStructure}{'header:Content-Type'}; + $ct =~ tr/ \t\r\n//ds; + $self->assert($ct =~ /;type=\"text\/html\"$/); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_multipartdigest b/cassandane/tiny-tests/JMAPEmail/email_set_multipartdigest new file mode 100644 index 0000000000..5eafd34ce1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_multipartdigest @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_multipartdigest + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate emails via IMAP"; + $self->make_message() || die; + $self->make_message() || die; + my $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, + properties => ['blobId'], + }, 'R2' ], + ]); + my $emailBlobId1 = $res->[1][1]->{list}[0]->{blobId}; + $self->assert_not_null($emailBlobId1); + my $emailBlobId2 = $res->[1][1]->{list}[1]->{blobId}; + $self->assert_not_null($emailBlobId2); + $self->assert_str_not_equals($emailBlobId1, $emailBlobId2); + + xlog $self, "Create email with multipart/digest body"; + my $inboxid = $self->getinbox()->{id}; + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [{ name => "Test", email => q{test@local} }], + subject => "test", + bodyStructure => { + type => "multipart/digest", + subParts => [{ + blobId => $emailBlobId1, + }, { + blobId => $emailBlobId2, + }], + }, + }; + $res = $jmap->CallMethods([ + ['Email/set', { create => { '1' => $email } }, 'R1'], + ['Email/get', { + ids => [ '#1' ], + properties => [ 'bodyStructure' ], + bodyProperties => [ 'partId', 'blobId', 'type', 'header:Content-Type' ], + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]); + + my $subPart = $res->[1][1]{list}[0]{bodyStructure}{subParts}[0]; + $self->assert_str_equals("message/rfc822", $subPart->{type}); + $self->assert_null($subPart->{'header:Content-Type'}); + $subPart = $res->[1][1]{list}[0]{bodyStructure}{subParts}[1]; + $self->assert_str_equals("message/rfc822", $subPart->{type}); + $self->assert_null($subPart->{'header:Content-Type'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_nullheader b/cassandane/tiny-tests/JMAPEmail/email_set_nullheader new file mode 100644 index 0000000000..10761e542d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_nullheader @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_nullheader + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $inboxid = $self->getinbox()->{id}; + + my $text = "x"; + + # Prepare test email + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [ { email => q{test1@robmtest.vm}, name => q{} } ], + 'header:foo' => undef, + 'header:foo:asMessageIds' => undef, + }; + + # Create and get mail + my $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ['Email/get', { + ids => [ "#1" ], + properties => [ 'headers', 'header:foo' ], + }, "R2" ], + ]); + my $msg = $res->[1][1]{list}[0]; + + foreach (@{$msg->{headers}}) { + xlog $self, "Checking header $_->{name}"; + $self->assert_str_not_equals('foo', $_->{name}); + } + $self->assert_null($msg->{'header:foo'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_patch b/cassandane/tiny-tests/JMAPEmail/email_set_patch new file mode 100644 index 0000000000..2d5bd03aab --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_patch @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_patch + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + my $draft = { + mailboxIds => { $inboxid => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "Whoa!" }}, + keywords => { '$draft' => JSON::true, foo => JSON::true }, + }; + + xlog $self, "Create draft email"; + $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $draft }}, "R1"], + ]); + my $id = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods([ + ['Email/get', { 'ids' => [$id] }, 'R2' ] + ]); + my $msg = $res->[0][1]->{list}[0]; + $self->assert_equals(JSON::true, $msg->{keywords}->{'$draft'}); + $self->assert_equals(JSON::true, $msg->{keywords}->{'foo'}); + $self->assert_num_equals(2, scalar keys %{$msg->{keywords}}); + $self->assert_equals(JSON::true, $msg->{mailboxIds}->{$inboxid}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + + xlog $self, "Patch email keywords"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $id => { + "keywords/foo" => undef, + "keywords/bar" => JSON::true, + } + }, + }, "R1"], + ['Email/get', { ids => [$id], properties => ['keywords'] }, 'R2'], + ]); + + $msg = $res->[1][1]->{list}[0]; + $self->assert_equals(JSON::true, $msg->{keywords}->{'$draft'}); + $self->assert_equals(JSON::true, $msg->{keywords}->{'bar'}); + $self->assert_num_equals(2, scalar keys %{$msg->{keywords}}); + + xlog $self, "create mailbox"; + $res = $jmap->CallMethods([['Mailbox/set', {create => { "1" => { name => "baz", }}}, "R1"]]); + my $mboxid = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($mboxid); + + xlog $self, "Patch email mailboxes"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $id => { + "mailboxIds/$inboxid" => undef, + "mailboxIds/$mboxid" => JSON::true, + } + }, + }, "R1"], + ['Email/get', { ids => [$id], properties => ['mailboxIds'] }, 'R2'], + ]); + $msg = $res->[1][1]->{list}[0]; + $self->assert_equals(JSON::true, $msg->{mailboxIds}->{$mboxid}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_seen b/cassandane/tiny-tests/JMAPEmail/email_set_seen new file mode 100644 index 0000000000..2eac8a57ef --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_seen @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_seen + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # See https://github.com/cyrusimap/cyrus-imapd/issues/2270 + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Add message"; + $self->make_message('Message A'); + + xlog $self, "Query email"; + my $inbox = $self->getinbox(); + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { inMailbox => $inbox->{id} } + }, 'R1'], + ['Email/get', { + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids'} + }, 'R2' ] + ]); + + my $keywords = { }; + my $msg = $res->[1][1]->{list}[0]; + $self->assert_deep_equals($keywords, $msg->{keywords}); + + $keywords->{'$seen'} = JSON::true; + $res = $jmap->CallMethods([ + ['Email/set', { update => { $msg->{id} => { 'keywords/$seen' => JSON::true } } }, 'R1'], + ['Email/get', { ids => [ $msg->{id} ] }, 'R2'], + ]); + $msg = $res->[1][1]->{list}[0]; + $self->assert_deep_equals($keywords, $msg->{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_setflags_mboxevent b/cassandane/tiny-tests/JMAPEmail/email_set_setflags_mboxevent new file mode 100644 index 0000000000..674c305896 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_setflags_mboxevent @@ -0,0 +1,159 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_setflags_mboxevent + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "create mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "A" => { + name => "A", + }, + "B" => { + name => "B", + }, + }, + }, "R1"] + ]); + my $mboxIdA = $res->[0][1]{created}{A}{id}; + $self->assert_not_null($mboxIdA); + my $mboxIdB = $res->[0][1]{created}{B}{id}; + $self->assert_not_null($mboxIdB); + + xlog $self, "Create emails"; + # Use separate requests for deterministic order of UIDs. + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + msgA1 => { + mailboxIds => { + $mboxIdA => JSON::true + }, + from => [{ + email => q{test1@local}, + name => q{} + }], + to => [{ + email => q{test2@local}, + name => '', + }], + subject => 'msgA1', + keywords => { + '$seen' => JSON::true, + }, + }, + } + }, "R1"], + ['Email/set', { + create => { + msgA2 => { + mailboxIds => { + $mboxIdA => JSON::true + }, + from => [{ + email => q{test1@local}, + name => q{} + }], + to => [{ + email => q{test2@local}, + name => '', + }], + subject => 'msgA2', + }, + } + }, "R2"], + ['Email/set', { + create => { + msgB1 => { + mailboxIds => { + $mboxIdB => JSON::true + }, + from => [{ + email => q{test1@local}, + name => q{} + }], + to => [{ + email => q{test2@local}, + name => '', + }], + keywords => { + baz => JSON::true, + }, + subject => 'msgB1', + }, + } + }, "R3"], + ]); + my $emailIdA1 = $res->[0][1]{created}{msgA1}{id}; + $self->assert_not_null($emailIdA1); + my $emailIdA2 = $res->[1][1]{created}{msgA2}{id}; + $self->assert_not_null($emailIdA2); + my $emailIdB1 = $res->[2][1]{created}{msgB1}{id}; + $self->assert_not_null($emailIdB1); + + # Clear notification cache + $self->{instance}->getnotify(); + + # Update emails + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailIdA1 => { + 'keywords/$seen' => undef, + 'keywords/foo' => JSON::true, + }, + $emailIdA2 => { + keywords => { + 'bar' => JSON::true, + }, + }, + $emailIdB1 => { + 'keywords/baz' => undef, + }, + } + }, "R1"], + ]); + $self->assert(exists $res->[0][1]{updated}{$emailIdA1}); + $self->assert(exists $res->[0][1]{updated}{$emailIdA2}); + $self->assert(exists $res->[0][1]{updated}{$emailIdB1}); + + # Gather notifications + my $data = $self->{instance}->getnotify(); + if ($self->{replica}) { + my $more = $self->{replica}->getnotify(); + push @$data, @$more; + } + + # Assert notifications + my %flagsClearEvents; + my %flagsSetEvents; + foreach (@$data) { + my $event = decode_json($_->{MESSAGE}); + if ($event->{event} eq "FlagsClear") { + $flagsClearEvents{$event->{mailboxID}} = $event; + } + elsif ($event->{event} eq "FlagsSet") { + $flagsSetEvents{$event->{mailboxID}} = $event; + } + } + + # Assert mailbox A events. + $self->assert_str_equals('1:2', $flagsSetEvents{$mboxIdA}{uidset}); + $self->assert_num_not_equals(-1, index($flagsSetEvents{$mboxIdA}{flagNames}, 'foo')); + $self->assert_num_not_equals(-1, index($flagsSetEvents{$mboxIdA}{flagNames}, 'bar')); + $self->assert_str_equals('1', $flagsClearEvents{$mboxIdA}{uidset}); + $self->assert_str_equals('\Seen', $flagsClearEvents{$mboxIdA}{flagNames}); + + # Assert mailbox B events. + $self->assert(not exists $flagsSetEvents{$mboxIdB}); + $self->assert_str_equals('1', $flagsClearEvents{$mboxIdB}{uidset}); + $self->assert_str_equals('baz', $flagsClearEvents{$mboxIdB}{flagNames}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_shared b/cassandane/tiny-tests/JMAPEmail/email_set_shared new file mode 100644 index 0000000000..222e663689 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_shared @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create user and share mailbox"; + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lrswntex") or die; + + xlog $self, "Create email in shared account via IMAP"; + $self->{adminstore}->set_folder('user.foo'); + $self->make_message("Email foo", store => $self->{adminstore}) or die; + + xlog $self, "get email"; + my $res = $jmap->CallMethods([ + ['Email/query', { accountId => 'foo' }, "R1"], + ]); + my $id = $res->[0][1]->{ids}[0]; + + xlog $self, "toggle Seen flag on email"; + $res = $jmap->CallMethods([['Email/set', { + accountId => 'foo', + update => { $id => { keywords => { '$seen' => JSON::true } } }, + }, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "Remove right to write annotations"; + $admintalk->setacl("user.foo", "cassandane", "lrtex") or die; + + xlog $self, 'Toggle \\Seen flag on email (should fail)'; + $res = $jmap->CallMethods([['Email/set', { + accountId => 'foo', + update => { $id => { keywords => { } } }, + }, "R1"]]); + $self->assert(exists $res->[0][1]{notUpdated}{$id}); + + xlog $self, "Remove right to delete email"; + $admintalk->setacl("user.foo", "cassandane", "lr") or die; + + xlog $self, 'Delete email (should fail)'; + $res = $jmap->CallMethods([['Email/set', { + accountId => 'foo', + destroy => [ $id ], + }, "R1"]]); + $self->assert(exists $res->[0][1]{notDestroyed}{$id}); + + xlog $self, "Add right to delete email"; + $admintalk->setacl("user.foo", "cassandane", "lrtex") or die; + + xlog $self, 'Delete email'; + $res = $jmap->CallMethods([['Email/set', { + accountId => 'foo', + destroy => [ $id ], + }, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_text_crlf b/cassandane/tiny-tests/JMAPEmail/email_set_text_crlf new file mode 100644 index 0000000000..9c0292ee6a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_text_crlf @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_text_crlf + :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $inboxid = $self->getinbox()->{id}; + + my $text = "ab\r\ncde\rfgh\nij"; + + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [ { email => q{test1@robmtest.vm}, name => q{} } ], + to => [ { + email => q{foo@bar.com}, + name => "foo", + } ], + textBody => [{partId => '1'}], + bodyValues => {1 => { value => $text }}, + }; + + xlog $self, "create and get email"; + my $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ['Email/get', { ids => [ "#1" ], fetchAllBodyValues => JSON::true }, "R2" ], + ]); + my $ret = $res->[1][1]->{list}[0]; + my $got = $ret->{bodyValues}{$ret->{textBody}[0]{partId}}{value}; + + my ($maj, $min) = Cassandane::Instance->get_version(); + $self->assert_str_equals("ab\ncde\nfgh\nij", $got); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_text_split b/cassandane/tiny-tests/JMAPEmail/email_set_text_split new file mode 100644 index 0000000000..ca79ef197c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_text_split @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_text_split + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $inboxid = $self->getinbox()->{id}; + + my $text = "x" x 2000; + + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [ { email => q{test1@robmtest.vm}, name => q{} } ], + to => [ { + email => q{foo@bar.com}, + name => "foo", + } ], + textBody => [{partId => '1'}], + bodyValues => {1 => { value => $text }}, + }; + + xlog $self, "create and get email"; + my $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R1"], + ['Email/get', { ids => [ "#1" ], fetchAllBodyValues => JSON::true }, "R2" ], + ]); + my $ret = $res->[1][1]->{list}[0]; + my $got = $ret->{bodyValues}{$ret->{textBody}[0]{partId}}{value}; +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update b/cassandane/tiny-tests/JMAPEmail/email_set_update new file mode 100644 index 0000000000..c3bcea8d9a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $drafts = $res->[0][1]{created}{"1"}{id}; + + my $draft = { + mailboxIds => {$drafts => JSON::true}, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ], + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" } ], + cc => [ { name => "Elmer Fudd", email => "elmer\@acme.local" } ], + subject => "created", + htmlBody => [ {partId => '1'} ], + bodyValues => { 1 => { value => "Oh!!! I hate that Rabbit." }}, + keywords => { + '$draft' => JSON::true, + } + }; + + xlog $self, "Create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + + xlog $self, "Update draft $id"; + $draft->{keywords} = { + '$draft' => JSON::true, + '$flagged' => JSON::true, + '$seen' => JSON::true, + '$answered' => JSON::true, + }; + $res = $jmap->CallMethods([['Email/set', { update => { $id => $draft }}, "R1"]]); + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + $msg = $res->[0][1]->{list}[0]; + $self->assert_deep_equals($draft->{keywords}, $msg->{keywords}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_after_attach b/cassandane/tiny-tests/JMAPEmail/email_set_update_after_attach new file mode 100644 index 0000000000..600567b818 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_after_attach @@ -0,0 +1,99 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_after_attach + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $store = $self->{store}; + + my $talk = $self->{store}->get_client(); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/debug', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + $talk->create('INBOX.A') or die; + $talk->create('INBOX.B') or die; + $talk->create('INBOX.C') or die; + + # Get mailboxes + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]], $using); + $self->assert_not_null($res); + my %mboxIdByName = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + + # Create email in mailbox A + $store->set_folder('INBOX.A'); + $self->make_message('Email1') || die; + + $res = $jmap->CallMethods([['Email/query', { + }, 'R1']], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $emailId = $res->[0][1]->{ids}[0]; + $self->assert_not_null($emailId); + + $res = $jmap->CallMethods([['Email/get', { ids => [ $emailId ], + }, 'R1']], $using); + my $blobId = $res->[0][1]->{list}[0]{blobId}; + $self->assert_not_null($blobId); + + $res = $jmap->CallMethods([['Email/set', { + create => { + 'k1' => { + mailboxIds => { + $mboxIdByName{'B'} => JSON::true, + }, + from => [{ name => "Test", email => q{test@local} }], + subject => "test", + bodyStructure => { + type => "multipart/mixed", + subParts => [{ + type => 'text/plain', + partId => 'part1', + },{ + type => 'message/rfc822', + blobId => $blobId, + }], + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1']], $using); + my $newEmailId = $res->[0][1]{created}{k1}{id}; + $self->assert_not_null($newEmailId); + + # now move the new email into folder C + $res = $jmap->CallMethods([['Email/set', { + update => { + $emailId => { + # set to exact so it picks up the copy in B if we're being buggy + mailboxIds => { $mboxIdByName{'C'} => JSON::true }, + }, + }, + }, 'R1']], $using); + $self->assert_not_null($res); + $self->assert(exists $res->[0][1]{updated}{$emailId}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods([['Email/get', { + ids => [$emailId, $newEmailId], + properties => ['mailboxIds'], + }, "R1"]], $using); + $self->assert_num_equals(0, scalar @{$res->[0][1]{notFound}}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + my %emailById = map { $_->{id} => $_ } @{$res->[0][1]{list}}; + + # now we need to test for actual location + $self->assert_deep_equals({$mboxIdByName{'C'} => JSON::true}, + $emailById{$emailId}->{mailboxIds}); + $self->assert_deep_equals({$mboxIdByName{'B'} => JSON::true}, + $emailById{$newEmailId}->{mailboxIds}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_bulk b/cassandane/tiny-tests/JMAPEmail/email_set_update_bulk new file mode 100644 index 0000000000..9eb57e03d9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_bulk @@ -0,0 +1,124 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_bulk + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $store = $self->{store}; + + my $talk = $self->{store}->get_client(); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/debug', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + + $talk->create('INBOX.A') or die; + $talk->create('INBOX.B') or die; + $talk->create('INBOX.C') or die; + $talk->create('INBOX.D') or die; + + # Get mailboxes + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]], $using); + $self->assert_not_null($res); + my %mboxIdByName = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + + # Create email in mailbox A and B + $store->set_folder('INBOX.A'); + $self->make_message('Email1') || die; + $talk->copy(1, 'INBOX.B'); + $talk->store(1, "+flags", "(\\Seen hello)"); + + # check that the flags aren't on B + $talk->select("INBOX.B"); + $res = $talk->fetch("1", "(flags)"); + my @flags = @{$res->{1}{flags}}; + $self->assert_null(grep { $_ eq 'hello' } @flags); + $self->assert_null(grep { $_ eq '\\Seen' } @flags); + + # Create email in mailboox A + $talk->select("INBOX.A"); + $self->make_message('Email2') || die; + + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => 'subject' }], + }, 'R1']], $using); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + my $emailId1 = $res->[0][1]->{ids}[0]; + my $emailId2 = $res->[0][1]->{ids}[1]; + + $res = $jmap->CallMethods([['Email/set', { + update => { + $emailId1 => { + mailboxIds => { + $mboxIdByName{'C'} => JSON::true, + }, + }, + $emailId2 => { + mailboxIds => { + $mboxIdByName{'C'} => JSON::true, + }, + } + }, + }, 'R1']], $using); + $self->make_message('Email3') || die; + + # check that the flags made it + $talk->select("INBOX.C"); + $res = $talk->fetch("1", "(flags)"); + @flags = @{$res->{1}{flags}}; + $self->assert_not_null(grep { $_ eq 'hello' } @flags); + # but \Seen shouldn't + $self->assert_null(grep { $_ eq '\\Seen' } @flags); + + $res = $jmap->CallMethods([['Email/query', { + sort => [{ property => 'subject' }], + }, 'R1']], $using); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + my @ids = @{$res->[0][1]->{ids}}; + my $emailId3 = $ids[2]; + + # now move all the ids to folder 'D' but two are not in the + # source folder any more + $res = $jmap->CallMethods([['Email/set', { + update => { + map { $_ => { + "mailboxIds/$mboxIdByName{'A'}" => undef, + "mailboxIds/$mboxIdByName{'D'}" => JSON::true, + } } @ids, + }, + }, 'R1']], $using); + + $self->assert_not_null($res); + $self->assert(exists $res->[0][1]{updated}{$emailId1}); + $self->assert(exists $res->[0][1]{updated}{$emailId2}); + $self->assert(exists $res->[0][1]{updated}{$emailId3}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods([['Email/get', { + ids => [$emailId1, $emailId2, $emailId3], + properties => ['mailboxIds'], + }, "R1"]], $using); + my %emailById = map { $_->{id} => $_ } @{$res->[0][1]{list}}; + + # now we need to test for actual location + my $wantMailboxesEmail1 = { + $mboxIdByName{'C'} => JSON::true, + $mboxIdByName{'D'} => JSON::true, + }; + my $wantMailboxesEmail2 = { + $mboxIdByName{'C'} => JSON::true, + $mboxIdByName{'D'} => JSON::true, + }; + my $wantMailboxesEmail3 = { + $mboxIdByName{'D'} => JSON::true, + }; + $self->assert_deep_equals($wantMailboxesEmail1, $emailById{$emailId1}->{mailboxIds}); + $self->assert_deep_equals($wantMailboxesEmail2, $emailById{$emailId2}->{mailboxIds}); + $self->assert_deep_equals($wantMailboxesEmail3, $emailById{$emailId3}->{mailboxIds}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_mailbox_creationid b/cassandane/tiny-tests/JMAPEmail/email_set_update_mailbox_creationid new file mode 100644 index 0000000000..7f126b46fb --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_mailbox_creationid @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_mailbox_creationid + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + # Create emails + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + "msg1" => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => q{from1@local}, name => q{} } ], + to => [{ email => q{to1@local}, name => q{} } ], + }, + "msg2" => { + mailboxIds => { + '$inbox' => JSON::true + }, + from => [{ email => q{from2@local}, name => q{} } ], + to => [{ email => q{to2@local}, name => q{} } ], + } + }, + }, 'R1'], + ['Email/get', { + ids => [ '#msg1', '#msg2' ], + properties => ['mailboxIds'], + }, "R2" ], + ]); + my $msg1Id = $res->[0][1]{created}{msg1}{id}; + $self->assert_not_null($msg1Id); + my $msg2Id = $res->[0][1]{created}{msg2}{id}; + $self->assert_not_null($msg2Id); + my $inboxId = (keys %{$res->[1][1]{list}[0]{mailboxIds}})[0]; + $self->assert_not_null($inboxId); + + # Move emails using mailbox creation id + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "mboxX" => { + name => "X", + parentId => undef, + }, + } + }, "R1"], + ['Email/set', { + update => { + $msg1Id => { + mailboxIds => { + '#mboxX' => JSON::true + } + }, + $msg2Id => { + 'mailboxIds/#mboxX' => JSON::true, + 'mailboxIds/' . $inboxId => undef, + } + }, + }, 'R2'], + ['Email/get', { + ids => [ $msg1Id, $msg2Id ], + properties => ['mailboxIds'], + }, "R3" ], + ]); + my $mboxId = $res->[0][1]{created}{mboxX}{id}; + $self->assert_not_null($mboxId); + + $self->assert(exists $res->[1][1]{updated}{$msg1Id}); + $self->assert(exists $res->[1][1]{updated}{$msg2Id}); + + my @mailboxIds = keys %{$res->[2][1]{list}[0]{mailboxIds}}; + $self->assert_num_equals(1, scalar @mailboxIds); + $self->assert_str_equals($mboxId, $mailboxIds[0]); + + @mailboxIds = keys %{$res->[2][1]{list}[1]{mailboxIds}}; + $self->assert_num_equals(1, scalar @mailboxIds); + $self->assert_str_equals($mboxId, $mailboxIds[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_mailboxids_nonempty b/cassandane/tiny-tests/JMAPEmail/email_set_update_mailboxids_nonempty new file mode 100644 index 0000000000..0955b99a60 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_mailboxids_nonempty @@ -0,0 +1,119 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_mailboxids_nonempty + :min_version_3_4 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['id'], + }, 'R1'], + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + } + }, 'R2'], + ], $using); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + my $mboxA = $res->[1][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[1][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email => { + mailboxIds => { + $mboxA => JSON::true, + $mboxB => JSON::true, + }, + subject => 'test', + from => [{ + email => 'from@local' + }] , + to => [{ + email => 'to@local' + }] , + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'email', + } + }, + }, + }, + }, 'R1'], + ['Email/get', { + ids => ['#email'], + properties => ['mailboxIds'], + }, 'R2'], + ], $using); + my $emailId = $res->[0][1]{created}{email}{id}; + $self->assert_not_null($emailId); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId => { + mailboxIds => {}, + }, + }, + }, 'R1'], + ['Email/set', { + update => { + $emailId => { + mailboxIds => undef, + }, + }, + }, 'R2'], + ['Email/set', { + update => { + $emailId => { + 'mailboxIds'.$mboxA => undef, + 'mailboxIds'.$mboxB => undef, + }, + }, + }, 'R3'], + ['Email/set', { + update => { + $emailId => { + mailboxIds => [], + }, + }, + }, 'R4'], + ['Email/get', { + ids => [$emailId], + properties => ['mailboxIds'], + }, 'R5'], + ], $using); + + $self->assert_deep_equals({ + type => 'invalidProperties', + properties => ['mailboxIds'], + }, $res->[0][1]{notUpdated}{$emailId}); + + $self->assert_str_equals($emailId, $res->[4][1]{list}[0]{id}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_mailboxids_validate_nopatch b/cassandane/tiny-tests/JMAPEmail/email_set_update_mailboxids_validate_nopatch new file mode 100644 index 0000000000..a5c490cfcb --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_mailboxids_validate_nopatch @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_mailboxids_validate_nopatch + :min_version_3_5 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + }, + }, 'R1'], + ['Email/set', { + create => { + 'emailA' => { + mailboxIds => { + '#mboxA' => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'emailA', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'test', + } + }, + }, + }, + }, 'R1'], + ]); + my $mboxA = $res->[0][1]->{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[0][1]->{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + my $emailA = $res->[1][1]->{created}{emailA}{id}; + $self->assert_not_null($emailA); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailA => { + mailboxIds => { + $mboxA => undef, + $mboxB => JSON::true, + }, + } + } + }, 'R1'], + ]); + $self->assert_not_null($res->[0][1]{notUpdated}{$emailA}); + $self->assert_deep_equals(['mailboxIds/'.$mboxA], + $res->[0][1]{notUpdated}{$emailA}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_no_id b/cassandane/tiny-tests/JMAPEmail/email_set_update_no_id new file mode 100644 index 0000000000..3f85b0be2b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_no_id @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_no_id + :min_version_3_4 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Email/set', { + create => { + email => { + mailboxIds => { + '$inbox' => JSON::true, + }, + subject => 'email', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'email', + } + }, + }, + }, + }, 'R1'], + ]); + my $emailId = $res->[0][1]{created}{email}{id}; + $self->assert_not_null($emailId); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { + $emailId => { + keywords => { + 'foo' => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_equals(undef, $res->[0][1]{updated}{$emailId}); + +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_snooze b/cassandane/tiny-tests/JMAPEmail/email_set_update_snooze new file mode 100644 index 0000000000..13bf2da8cf --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_snooze @@ -0,0 +1,233 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_snooze + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd + :needs_component_sieve :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # snoozed property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + xlog $self, "Get mailbox id of Inbox"; + my $inboxId = $self->getinbox()->{id}; + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwithCRLF\r\n") or die; + + xlog $self, "get email id"; + my $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R2" ] ] ); + my $emailId = $res->[0][1]->{ids}[0]; + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R3" ] ] ); + my $msg = $res->[0][1]->{list}[0]; + $self->assert_not_null($msg->{mailboxIds}{$inboxId}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + + xlog $self, "create snooze mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "snoozed", + parentId => undef, + role => "snoozed" + }}}, "R4"] + ]); + $self->assert_not_null($res->[0][1]{created}); + my $snoozedId = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R4"] + ]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsId = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Move message to drafts and snoozed mailbox"; + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => 30)); + my $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "mailboxIds/$inboxId" => undef, + "mailboxIds/$snoozedId" => $JSON::true, + "snoozed" => { "until" => "$datestr", + "setKeywords" => { '$seen' => $JSON::true } }, + keywords => { '$flagged' => JSON::true, '$seen' => JSON::true }, + }} + }, 'R5'] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'addedDates', 'snoozed' ]}, "R6" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_null($msg->{mailboxIds}{$inboxId}); + $self->assert_not_null($msg->{mailboxIds}{$snoozedId}); + $self->assert_null($msg->{mailboxIds}{$draftsId}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_str_equals($datestr, $msg->{snoozed}{'until'}); + $self->assert_str_equals($datestr, $msg->{addedDates}{$snoozedId}); + + xlog $self, "Adjust snooze#until"; + $maildate->add(DateTime::Duration->new(seconds => 15)); + $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "mailboxIds/$draftsId" => $JSON::true, + "snoozed/until" => "$datestr", + 'snoozed/setKeywords/$awakened' => $JSON::true, + 'snoozed/setKeywords/$seen' => $JSON::false, + }} + }, 'R5'] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'addedDates', 'snoozed' ]}, "R6" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_null($msg->{mailboxIds}{$inboxId}); + $self->assert_not_null($msg->{mailboxIds}{$snoozedId}); + $self->assert_not_null($msg->{mailboxIds}{$draftsId}); + $self->assert_num_equals(2, scalar keys %{$msg->{mailboxIds}}); + $self->assert_str_equals($datestr, $msg->{snoozed}{'until'}); + $self->assert_str_equals($datestr, $msg->{addedDates}{$snoozedId}); + # but it shouldn't be changed on the drafts folder. This is a little raceful, in that + # the snooze#until date could just happen to be now... + $self->assert_str_not_equals($datestr, $msg->{addedDates}{$draftsId}); + + xlog $self, "trigger re-delivery of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 30 ); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'addedDates', 'snoozed' ]}, "R7" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(2, scalar keys %{$msg->{mailboxIds}}); + $self->assert_not_null($msg->{snoozed}); + $self->assert_num_equals(2, scalar keys %{$msg->{keywords}}); + $self->assert_equals(JSON::true, $msg->{keywords}{'$awakened'}); + $self->assert_null($msg->{keywords}{'$seen'}); + $self->assert_str_equals($datestr, $msg->{snoozed}{'until'}); + $self->assert_str_equals($datestr, $msg->{addedDates}{$inboxId}); + # but it shouldn't be changed on the drafts folder. This is a little raceful, in that + # the snooze#until date could just happen to be now... + $self->assert_str_not_equals($datestr, $msg->{addedDates}{$draftsId}); + + xlog $self, "Re-snooze"; + $maildate->add(DateTime::Duration->new(seconds => 15)); + $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "mailboxIds/$inboxId" => undef, + "mailboxIds/$snoozedId" => $JSON::true, + 'keywords/$awakened' => undef, + "snoozed/until" => "$datestr", + }} + }, 'R8'] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R9" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(2, scalar keys %{$msg->{mailboxIds}}); + $self->assert_not_null($msg->{snoozed}); + $self->assert_num_equals(1, scalar keys %{$msg->{keywords}}); + $self->assert_null($msg->{keywords}{'$seen'}); + $self->assert_null($msg->{keywords}{'$awakened'}); + $self->assert_str_equals($datestr, $msg->{snoozed}{'until'}); + + xlog $self, "trigger re-delivery of re-snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 30 ); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'addedDates', 'snoozed' ]}, "R7" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(2, scalar keys %{$msg->{mailboxIds}}); + $self->assert_not_null($msg->{snoozed}); + $self->assert_num_equals(2, scalar keys %{$msg->{keywords}}); + $self->assert_equals(JSON::true, $msg->{keywords}{'$awakened'}); + $self->assert_null($msg->{keywords}{'$seen'}); + $self->assert_str_equals($datestr, $msg->{snoozed}{'until'}); + $self->assert_str_equals($datestr, $msg->{addedDates}{$inboxId}); + # but it shouldn't be changed on the drafts folder. This is a little raceful, in that + # the snooze#until date could just happen to be now... + $self->assert_str_not_equals($datestr, $msg->{addedDates}{$draftsId}); + + xlog $self, "Remove snoozed"; + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "mailboxIds/$inboxId" => undef, + "snoozed" => undef + }} + }, 'R8'] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R9" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_null($msg->{snoozed}); + $self->assert_num_equals(2, scalar keys %{$msg->{keywords}}); + $self->assert_equals(JSON::true, $msg->{keywords}{'$seen'}); + $self->assert_null($msg->{keywords}{'$awakened'}); + + xlog $self, "Restore snoozed"; + $maildate->add(DateTime::Duration->new(seconds => 15)); + $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "mailboxIds" => { "$inboxId" => $JSON::true }, + "snoozed" => { + "until" => "$datestr", + "setKeywords" => { '$awakened' => $JSON::true, '$seen' => $JSON::false } + }, + }} + }, 'R8'] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R9" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_not_null($msg->{snoozed}); + $self->assert_num_equals(2, scalar keys %{$msg->{keywords}}); + $self->assert_equals(JSON::true, $msg->{keywords}{'$seen'}); + $self->assert_null($msg->{keywords}{'$awakened'}); + $self->assert_str_equals($datestr, $msg->{snoozed}{'until'}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_keywords b/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_keywords new file mode 100644 index 0000000000..f040884e2a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_keywords @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_too_many_keywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $store = $self->{store}; + my $talk = $self->{store}->get_client(); + + my $inboxId = $self->getinbox()->{id}; + + # Create email in INBOX + $self->make_message('Email') || die; + + my $res = $jmap->CallMethods([['Email/query', { }, 'R1']]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $emailId = $res->[0][1]->{ids}[0]; + + my $accountCapabilities = $self->get_account_capabilities(); + my $mailCapabilities = $accountCapabilities->{'urn:ietf:params:jmap:mail'}; + my $maxKeywordsPerEmail = $mailCapabilities->{maxKeywordsPerEmail}; + $self->assert($maxKeywordsPerEmail > 0); + + # Set lots of keywords on this email + my %keywords; + for (my $i = 1; $i < $maxKeywordsPerEmail + 2; $i++) { + $keywords{"keyword$i"} = JSON::true; + } + $res = $jmap->CallMethods([['Email/set', { + update => { + $emailId => { + keywords => \%keywords, + }, + }, + }, 'R1']]); + $self->assert_str_equals('tooManyKeywords', $res->[0][1]{notUpdated}{$emailId}{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_mailboxes b/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_mailboxes new file mode 100644 index 0000000000..6c0edd5024 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_mailboxes @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_too_many_mailboxes + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $store = $self->{store}; + my $talk = $self->{store}->get_client(); + + my $inboxId = $self->getinbox()->{id}; + + # Create email in INBOX + $self->make_message('Email') || die; + + my $res = $jmap->CallMethods([['Email/query', { }, 'R1']]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $emailId = $res->[0][1]->{ids}[0]; + + my $accountCapabilities = $self->get_account_capabilities(); + my $mailCapabilities = $accountCapabilities->{'urn:ietf:params:jmap:mail'}; + my $maxMailboxesPerEmail = $mailCapabilities->{maxMailboxesPerEmail}; + $self->assert($maxMailboxesPerEmail > 0); + + # Create and get mailboxes + for (my $i = 1; $i < $maxMailboxesPerEmail + 2; $i++) { + $talk->create("INBOX.mbox$i") or die; + } + $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $self->assert_not_null($res); + my %mboxIds = map { $_->{id} => JSON::true } @{$res->[0][1]{list}}; + + # remove from INBOX + delete $mboxIds{$inboxId}; + + # Move mailbox to too many mailboxes + $res = $jmap->CallMethods([['Email/set', { + update => { + $emailId => { + mailboxIds => \%mboxIds, + }, + }, + }, 'R1']]); + $self->assert_str_equals('tooManyMailboxes', $res->[0][1]{notUpdated}{$emailId}{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_mailboxes_lowlimit b/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_mailboxes_lowlimit new file mode 100644 index 0000000000..e4e3cd6ef7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_update_too_many_mailboxes_lowlimit @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_update_too_many_mailboxes_lowlimit + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :LowEmailLimits +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $store = $self->{store}; + my $talk = $self->{store}->get_client(); + + my $inboxId = $self->getinbox()->{id}; + + # Create email in INBOX + $self->make_message('Email') || die; + + my $res = $jmap->CallMethods([['Email/query', { }, 'R1']]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $emailId = $res->[0][1]->{ids}[0]; + + my $accountCapabilities = $self->get_account_capabilities(); + my $mailCapabilities = $accountCapabilities->{'urn:ietf:params:jmap:mail'}; + my $maxMailboxesPerEmail = 5; # from the magic + $self->assert($maxMailboxesPerEmail > 0); + + # Create and get mailboxes + for (my $i = 1; $i < $maxMailboxesPerEmail + 2; $i++) { + $talk->create("INBOX.mbox$i") or die; + } + $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $self->assert_not_null($res); + my %mboxIds = map { $_->{id} => JSON::true } @{$res->[0][1]{list}}; + + # remove from INBOX + delete $mboxIds{$inboxId}; + + # Move mailbox to too many mailboxes + $res = $jmap->CallMethods([['Email/set', { + update => { + $emailId => { + mailboxIds => \%mboxIds, + }, + }, + }, 'R1']]); + $self->assert_str_equals('tooManyMailboxes', $res->[0][1]{notUpdated}{$emailId}{type}); + + $self->assert_syslog_matches($self->{instance}, + qr{IOERROR: conversations GUID limit}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_userkeywords b/cassandane/tiny-tests/JMAPEmail/email_set_userkeywords new file mode 100644 index 0000000000..581ef354e7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_set_userkeywords @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_set_userkeywords + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $draft = { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + ], + subject => "Memo", + textBody => [{ partId => '1' }], + bodyValues => { + '1' => { + value => "I'm givin' ya one last chance ta surrenda!" + } + }, + keywords => { + '$draft' => JSON::true, + 'foo' => JSON::true + }, + }; + + xlog $self, "Create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + my $msg = $res->[0][1]->{list}[0]; + + $self->assert_equals(JSON::true, $msg->{keywords}->{'$draft'}); + $self->assert_equals(JSON::true, $msg->{keywords}->{'foo'}); + $self->assert_num_equals(2, scalar keys %{$msg->{keywords}}); + + xlog $self, "Update draft"; + $res = $jmap->CallMethods([['Email/set', { + update => { + $id => { + "keywords" => { + '$draft' => JSON::true, + 'foo' => JSON::true, + 'bar' => JSON::true + } + } + } + }, "R1"]]); + + xlog $self, "Get draft $id"; + $res = $jmap->CallMethods([['Email/get', { ids => [$id] }, "R1"]]); + $msg = $res->[0][1]->{list}[0]; + $self->assert_equals(JSON::true, JSON::true, $msg->{keywords}->{'$draft'}); # case-insensitive! + $self->assert_equals(JSON::true, $msg->{keywords}->{'foo'}); + $self->assert_equals(JSON::true, $msg->{keywords}->{'bar'}); + $self->assert_num_equals(3, scalar keys %{$msg->{keywords}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_snooze_awaken_bad_mailbox b/cassandane/tiny-tests/JMAPEmail/email_snooze_awaken_bad_mailbox new file mode 100644 index 0000000000..8db8b7eea2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_snooze_awaken_bad_mailbox @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_snooze_awaken_bad_mailbox + :min_version_3_7 :needs_component_jmap :needs_component_calalarmd + :needs_component_sieve :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # snoozed property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwithCRLF\r\n") or die; + + xlog $self, "Get email id, Inbox id, and create snoozed & awaken mailboxes"; + my $res = $jmap->CallMethods([ + [ 'Email/query', {}, "R0" ], + [ 'Mailbox/query', {filter => {role => 'inbox'}}, "R1"], + [ 'Mailbox/set', { + create => { + "1" => { + name => "snoozed", + parentId => undef, + role => "snoozed" + }, + "2" => { + name => "awaken", + parentId => undef + } + }}, "R2" ] + ]); + my $emailId = $res->[0][1]->{ids}[0]; + my $inbox = $res->[1][1]->{ids}[0]; + my $snoozedmbox = $res->[2][1]{created}{"1"}{id}; + my $awakenmbox = $res->[2][1]{created}{"2"}{id}; + + xlog $self, "Snooze email and destroy awaken mailbox"; + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => 30)); + my $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + $res = $jmap->CallMethods([ + [ 'Email/set', { + update => { $emailId => { + "mailboxIds/$inbox" => undef, + "mailboxIds/$snoozedmbox" => $JSON::true, + "snoozed" => { "until" => "$datestr", + "moveToMailboxId" => "$awakenmbox" } + }} + }, 'R3' ], + [ 'Mailbox/set', { destroy => [ $awakenmbox ] }, "R4"] + ]); + + xlog $self, "Trigger awakening of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 30 ); + + xlog $self, "Verify email was awakened to Inbox"; + $res = $jmap->CallMethods([ + [ 'Email/get', + { ids => [ $emailId ], properties => [ 'mailboxIds' ] }, "R7" ] + ]); + my $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_equals(JSON::true, $msg->{mailboxIds}{"$inbox"}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/email_zero_length_text b/cassandane/tiny-tests/JMAPEmail/email_zero_length_text new file mode 100644 index 0000000000..d4f9cb35e8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/email_zero_length_text @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_email_zero_length_text + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +MIME-Version: 1.0 +From: "Example.com" +To: "Me" +Date: 25 Jun 2016 02:29:42 -0400 +Subject: Upcoming Auto-Renewal Notification for July, 2016 +Content-Type: multipart/alternative; + boundary=--boundary_34056 +Message-ID: + +----boundary_34056 +Content-Type: text/plain +Content-Transfer-Encoding: quoted-printable + + +----boundary_34056 +Content-Type: text/html +Content-Transfer-Encoding: 7bit + + +foo + + +----boundary_34056-- + +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "import and get email from blob $blobid"; + my $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$inboxid => JSON::true}, + }, + }, + }, "R1"], ["Email/get", { + ids => ["#1"], + properties => ['bodyStructure', 'bodyValues'], + fetchAllBodyValues => JSON::true, + }, "R2" ]]); + + $self->assert_str_equals("Email/import", $res->[0][0]); + $self->assert_str_equals("Email/get", $res->[1][0]); + + my $msg = $res->[1][1]{list}[0]; + my $bodyValue = $msg->{bodyValues}{1}; + $self->assert_str_equals("", $bodyValue->{value}); + $self->assert_equals(JSON::false, $bodyValue->{isEncodingProblem}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/identity_get b/cassandane/tiny-tests/JMAPEmail/identity_get new file mode 100644 index 0000000000..3feee5b21b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/identity_get @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_identity_get + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :want_smtpdaemon +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $id; + my $res; + + # Make sure it's in the correct JMAP capability, as reported in + # https://github.com/cyrusimap/cyrus-imapd/issues/2912 + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:submission', + ]; + + xlog $self, "get identities"; + $res = $jmap->CallMethods([['Identity/get', { }, "R1"]], $using); + + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{notFound}}); + + $id = $res->[0][1]->{list}[0]; + $self->assert_not_null($id->{id}); + $self->assert_not_null($id->{email}); + + xlog $self, "get unknown identities"; + $res = $jmap->CallMethods([['Identity/get', { ids => ["foo"] }, "R1"]], $using); + $self->assert_num_equals(0, scalar @{$res->[0][1]->{list}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{notFound}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/implementation_email_query b/cassandane/tiny-tests/JMAPEmail/implementation_email_query new file mode 100644 index 0000000000..f983dd3503 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/implementation_email_query @@ -0,0 +1,102 @@ +#!perl +use Cassandane::Tiny; + +sub test_implementation_email_query + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + # These assertions are implementation-specific. Breaking them + # isn't necessarly a regression, but change them with caution. + + my $now = DateTime->now(); + + xlog $self, "Generate an email in INBOX via IMAP"; + my $res = $self->make_message("foo") || die; + my $uid = $res->{attrs}->{uid}; + my $msg; + + my $inbox = $self->getinbox(); + + xlog $self, "non-filtered query can calculate changes"; + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + $self->assert($res->[0][1]{canCalculateChanges}); + + xlog $self, "inMailbox query can calculate changes"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { inMailbox => $inbox->{id} }, + sort => [ { + isAscending => $JSON::false, + property => 'receivedAt', + } ], + }, "R1"], + ]); + $self->assert_equals(JSON::true, $res->[0][1]{canCalculateChanges}); + + xlog $self, "inMailbox query can calculate changes with mutable sort"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { inMailbox => $inbox->{id} }, + sort => [ { + property => "someInThreadHaveKeyword", + keyword => "\$seen", + isAscending => $JSON::false, + }, { + property => 'receivedAt', + isAscending => $JSON::false, + } ], + }, "R1"], + ]); + $self->assert_equals(JSON::true, $res->[0][1]{canCalculateChanges}); + + xlog $self, "inMailbox query with keyword can not calculate changes"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + conditions => [ + { inMailbox => $inbox->{id} }, + { conditions => [ { allInThreadHaveKeyword => "\$seen" } ], + operator => 'NOT', + }, + ], + operator => 'AND', + }, + sort => [ { + isAscending => $JSON::false, + property => 'receivedAt', + } ], + }, "R1"], + ]); + $self->assert_equals(JSON::false, $res->[0][1]{canCalculateChanges}); + + xlog $self, "negated inMailbox query can not calculate changes"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [ + { inMailbox => $inbox->{id} }, + ], + }, + }, "R1"], + ]); + $self->assert_equals(JSON::false, $res->[0][1]{canCalculateChanges}); + + xlog $self, "inMailboxOtherThan query can not calculate changes"; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + operator => 'NOT', + conditions => [ + { inMailboxOtherThan => [$inbox->{id}] }, + ], + }, + }, "R1"], + ]); + $self->assert_equals(JSON::false, $res->[0][1]{canCalculateChanges}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/issue_2664 b/cassandane/tiny-tests/JMAPEmail/issue_2664 new file mode 100644 index 0000000000..b2ed7965a6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/issue_2664 @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_issue_2664 + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :want_smtpdaemon +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityId = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityId); + + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + 'mbox1' => { + name => 'foo', + } + } + }, 'R1'], + ['Email/set', { + create => { + email1 => { + mailboxIds => { + '#mbox1' => JSON::true + }, + from => [{ email => q{foo@bar} }], + to => [{ email => q{bar@foo} }], + subject => "test", + bodyStructure => { + partId => '1', + }, + bodyValues => { + "1" => { + value => "A text body", + }, + }, + } + }, + }, 'R2'], + ['EmailSubmission/set', { + create => { + 'emailSubmission1' => { + identityId => $identityId, + emailId => '#email1' + } + } + }, 'R3'], + ]); + $self->assert(exists $res->[0][1]{created}{mbox1}); + $self->assert(exists $res->[1][1]{created}{email1}); + $self->assert(exists $res->[2][1]{created}{emailSubmission1}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_brokenrfc822_badendline b/cassandane/tiny-tests/JMAPEmail/misc_brokenrfc822_badendline new file mode 100644 index 0000000000..c762384286 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_brokenrfc822_badendline @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_brokenrfc822_badendline + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email =~ s/\r//gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftsmbox); + + xlog $self, "import email from blob $blobid"; + $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$draftsmbox => JSON::true}, + keywords => { + '$draft' => JSON::true, + }, + }, + }, + }, "R1"]]); + my $error = $@; + $self->assert_str_equals("invalidEmail", $res->[0][1]{notCreated}{1}{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_collapsethreads_issue2024 b/cassandane/tiny-tests/JMAPEmail/misc_collapsethreads_issue2024 new file mode 100644 index 0000000000..5872402e5a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_collapsethreads_issue2024 @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_collapsethreads_issue2024 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + + my $imaptalk = $self->{store}->get_client(); + + # test that the collapseThreads property is echoed back verbatim + # see https://github.com/cyrusimap/cyrus-imapd/issues/2024 + + # check IMAP server has the XCONVERSATIONS capability + $self->assert($self->{store}->get_client()->capability()->{xconversations}); + + xlog $self, "generating email A"; + $exp{A} = $self->make_message("Email A"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A} ]); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + $res = $jmap->CallMethods([['Email/query', { collapseThreads => JSON::true }, "R1"]]); + $self->assert_equals(JSON::true, $res->[0][1]->{collapseThreads}); + + $res = $jmap->CallMethods([['Email/query', { collapseThreads => JSON::false }, "R1"]]); + $self->assert_equals(JSON::false, $res->[0][1]->{collapseThreads}); + + $res = $jmap->CallMethods([['Email/query', { collapseThreads => undef }, "R1"]]); + $self->assert_null($res->[0][1]->{collapseThreads}); + + $res = $jmap->CallMethods([['Email/query', { }, "R1"]]); + $self->assert_equals(JSON::false, $res->[0][1]->{collapseThreads}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_download b/cassandane/tiny-tests/JMAPEmail/misc_download new file mode 100644 index 0000000000..42ca3ce55c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_download @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_download + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + # Generate an email to have some blob ids + xlog $self, "Generate an email in $inbox via IMAP"; + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "sub", + body => "" + . "--sub\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "some text" + . "\r\n--sub\r\n" + . "Content-Type: image/jpeg\r\n" + . "Content-Transfer-Encoding: base64\r\n" . "\r\n" + . "beefc0de" + . "\r\n--sub\r\n" + . "Content-Type: image/png\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . "f00bae==" + . "\r\n--sub--\r\n", + ); + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + + xlog $self, "get email"; + $res = $jmap->CallMethods([['Email/get', { + ids => $ids, + properties => ['bodyStructure'], + }, "R1"]]); + my $msg = $res->[0][1]{list}[0]; + + my $blobid1 = $msg->{bodyStructure}{subParts}[1]{blobId}; + my $blobid2 = $msg->{bodyStructure}{subParts}[2]{blobId}; + $self->assert_not_null($blobid1); + $self->assert_not_null($blobid2); + + $res = $jmap->Download('cassandane', $blobid1); + $self->assert_str_equals("beefc0de", encode_base64($res->{content}, '')); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_download_shared b/cassandane/tiny-tests/JMAPEmail/misc_download_shared new file mode 100644 index 0000000000..dcf4cc8cb8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_download_shared @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_download_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + my $inbox = 'INBOX'; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "Create shared mailboxes"; + $self->{instance}->create_user("foo"); + $admintalk->create("user.foo.A") or die; + $admintalk->setacl("user.foo.A", "cassandane", "lr") or die; + $admintalk->create("user.foo.B") or die; + $admintalk->setacl("user.foo.B", "cassandane", "lr") or die; + + xlog $self, "Create email in shared mailbox"; + $self->{adminstore}->set_folder('user.foo.B'); + $self->make_message("foo", store => $self->{adminstore}) or die; + + xlog $self, "get email blobId"; + my $res = $jmap->CallMethods([ + ['Email/query', { accountId => 'foo'}, 'R1'], + ['Email/get', { + accountId => 'foo', + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + properties => ['blobId'], + }, 'R2'], + ]); + my $blobId = $res->[1][1]->{list}[0]{blobId}; + + xlog $self, "download email as blob"; + $res = $jmap->Download('foo', $blobId); + + xlog $self, "Unshare mailbox"; + $admintalk->setacl("user.foo.B", "cassandane", "") or die; + + my %Headers = ( + 'Authorization' => $jmap->auth_header(), + ); + my $httpRes = $jmap->ua->get($jmap->downloaduri('foo', $blobId), + { headers => \%Headers }); + $self->assert_str_equals('404', $httpRes->{status}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_emptyids b/cassandane/tiny-tests/JMAPEmail/misc_emptyids new file mode 100644 index 0000000000..eb7694c12e --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_emptyids @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_emptyids + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :want_smtpdaemon +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $res; + + $imaptalk->create("INBOX.foo") || die; + + $res = $jmap->CallMethods([['Mailbox/get', { ids => [] }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + + $res = $jmap->CallMethods([['Thread/get', { ids => [] }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + + $res = $jmap->CallMethods([['Email/get', { ids => [] }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + + $res = $jmap->CallMethods([['Identity/get', { ids => [] }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); + + $res = $jmap->CallMethods([['SearchSnippet/get', { emailIds => [], filter => { text => "foo" } }, "R1"]]); + $self->assert_num_equals(0, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_refobjects_extended b/cassandane/tiny-tests/JMAPEmail/misc_refobjects_extended new file mode 100644 index 0000000000..ffaf3d006a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_refobjects_extended @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_refobjects_extended + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + foreach my $i (1..10) { + $self->make_message("Email$i") || die; + } + + xlog $self, "get email properties using reference"; + my $res = $jmap->CallMethods([ + ['Email/query', { + sort => [{ property => 'receivedAt', isAscending => JSON::false }], + collapseThreads => JSON::true, + position => 0, + limit => 10, + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + properties => [ 'threadId' ], + }, 'R2'], + ['Thread/get', { + '#ids' => { + resultOf => 'R2', + name => 'Email/get', + path => '/list/*/threadId', + }, + }, 'R3'], + ]); + $self->assert_num_equals(10, scalar @{$res->[2][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_refobjects_simple b/cassandane/tiny-tests/JMAPEmail/misc_refobjects_simple new file mode 100644 index 0000000000..90fb782ad9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_refobjects_simple @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_refobjects_simple + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "get email state"; + my $res = $jmap->CallMethods([['Email/get', { ids => [] }, "R1"]]); + my $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("Email A") || die; + + xlog $self, "get email updates and email using reference"; + $res = $jmap->CallMethods([ + ['Email/changes', { + sinceState => $state, + }, 'R1'], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/changes', + path => '/created', + }, + }, 'R2'], + ]); + + # assert that the changed id equals the id of the returned email + $self->assert_str_equals($res->[0][1]{created}[0], $res->[1][1]{list}[0]{id}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_set_oldstate b/cassandane/tiny-tests/JMAPEmail/misc_set_oldstate new file mode 100644 index 0000000000..60109936b0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_set_oldstate @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_set_oldstate + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :want_smtpdaemon +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # Assert that /set returns oldState (null, or a string) + # See https://github.com/cyrusimap/cyrus-imapd/issues/2260 + + xlog $self, "create drafts mailbox and email"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }} + }, "R1"], + ]); + $self->assert(exists $res->[0][1]{oldState}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $draft = { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + ], + subject => "foo", + textBody => [{partId => '1' }], + bodyValues => { 1 => { value => "bar" }}, + keywords => { '$draft' => JSON::true }, + }; + + xlog $self, "create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + $self->assert(exists $res->[0][1]{oldState}); + my $msgid = $res->[0][1]{created}{"1"}{id}; + + $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + + xlog $self, "create email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $msgid, + } + } + }, "R1" ] ] ); + $self->assert(exists $res->[0][1]{oldState}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_upload b/cassandane/tiny-tests/JMAPEmail/misc_upload new file mode 100644 index 0000000000..8b5d2f4008 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_upload @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_upload + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $data = $jmap->Upload("a message with some text", "text/rubbish"); + $self->assert_matches(qr/^G44911b55c3b83ca05db9659d7a8e8b7b/, $data->{blobId}); + $self->assert_num_equals(24, $data->{size}); + $self->assert_str_equals("text/rubbish", $data->{type}); + + my $msgresp = $jmap->CallMethods([ + ['Email/set', { create => { "2" => { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + ], + subject => "Memo", + textBody => [{partId => '1'}], + htmlBody => [{partId => '2'}], + bodyValues => { + 1 => { + value => "I'm givin' ya one last chance ta surrenda!" + }, + 2 => { + value => "I'm givin' ya one last chance ta surrenda!" + }, + }, + attachments => [{ + blobId => $data->{blobId}, + name => "test.txt", + }], + keywords => { '$draft' => JSON::true }, + } } }, 'R2'], + ]); + + $self->assert_not_null($msgresp->[0][1]{created}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_upload_bin b/cassandane/tiny-tests/JMAPEmail/misc_upload_bin new file mode 100644 index 0000000000..ac983d48d7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_upload_bin @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_upload_bin + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $binary = slurp_file(abs_path('data/logo.gif')); + my $data = $jmap->Upload($binary, "image/gif"); + + my $msgresp = $jmap->CallMethods([ + ['Email/set', { create => { "2" => { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + ], + subject => "Memo", + textBody => [{ partId => '1' }], + bodyValues => { 1 => { value => "I'm givin' ya one last chance ta surrenda!" }}, + attachments => [{ + blobId => $data->{blobId}, + name => "logo.gif", + type => 'image/gif', + }], + keywords => { '$draft' => JSON::true }, + } } }, 'R2'], + ]); + + $self->assert_not_null($msgresp->[0][1]{created}); + + # XXX - fetch back the parts +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_upload_download822 b/cassandane/tiny-tests/JMAPEmail/misc_upload_download822 new file mode 100644 index 0000000000..b502d1eaa4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_upload_download822 @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_upload_download822 + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $email = <<'EOF'; +From: "Some Example Sender" +To: baseball@vitaead.com +Subject: test email +Date: Wed, 7 Dec 2016 00:21:50 -0500 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + + my $download = $jmap->Download('cassandane', $blobid); + + $self->assert_str_equals($email, $download->{content}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_upload_multiaccount b/cassandane/tiny-tests/JMAPEmail/misc_upload_multiaccount new file mode 100644 index 0000000000..af39adcbad --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_upload_multiaccount @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_upload_multiaccount + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Create user and share mailbox + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lrwikxd") or die; + + # Create user but don't share mailbox + $self->{instance}->create_user("bar"); + + my @res = $jmap->Upload("an email with some text", "text/rubbish", "foo"); + $self->assert_str_equals('201', $res[0]->{status}); + + @res = $jmap->Upload("an email with some text", "text/rubbish", "bar"); + $self->assert_str_equals('404', $res[0]->{status}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_upload_sametype b/cassandane/tiny-tests/JMAPEmail/misc_upload_sametype new file mode 100644 index 0000000000..3ec6b4f0ec --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_upload_sametype @@ -0,0 +1,19 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_upload_sametype + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $lazy = "the quick brown fox jumped over the lazy dog"; + + my $data = $jmap->Upload($lazy, "text/plain; charset=us-ascii"); + my $blobid = $data->{blobId}; + + $data = $jmap->Upload($lazy, "TEXT/PLAIN; charset=US-Ascii"); + my $blobid2 = $data->{blobId}; + + $self->assert_str_equals($blobid, $blobid2); +} diff --git a/cassandane/tiny-tests/JMAPEmail/misc_upload_zero b/cassandane/tiny-tests/JMAPEmail/misc_upload_zero new file mode 100644 index 0000000000..39564d5599 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/misc_upload_zero @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_misc_upload_zero + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + + my $data = $jmap->Upload("", "text/plain"); + $self->assert_matches(qr/^Gda39a3ee5e6b4b0d3255bfef95601890/, $data->{blobId}); + $self->assert_num_equals(0, $data->{size}); + $self->assert_str_equals("text/plain", $data->{type}); + + my $msgresp = $jmap->CallMethods([ + ['Email/set', { create => { "2" => { + mailboxIds => { $draftsmbox => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + ], + subject => "Memo", + textBody => [{ partId => '1' }], + bodyValues => { + '1' => { + value => "I'm givin' ya one last chance ta surrenda!" + } + }, + attachments => [{ + blobId => $data->{blobId}, + name => "emptyfile.txt", + }], + keywords => { '$draft' => JSON::true }, + } } }, 'R2'], + ]); + + $self->assert_not_null($msgresp->[0][1]{created}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/replication_email_set_update_snooze b/cassandane/tiny-tests/JMAPEmail/replication_email_set_update_snooze new file mode 100644 index 0000000000..d844415c1d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/replication_email_set_update_snooze @@ -0,0 +1,165 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication_email_set_update_snooze + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd + :needs_component_sieve :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # snoozed property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + xlog $self, "Get mailbox id of Inbox"; + my $inboxId = $self->getinbox()->{id}; + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwithCRLF\r\n") or die; + + xlog $self, "get email id"; + my $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R2" ] ] ); + my $emailId = $res->[0][1]->{ids}[0]; + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R3" ] ] ); + my $msg = $res->[0][1]->{list}[0]; + my $oldState = $res->[0][1]->{state}; + $self->assert_not_null($msg->{mailboxIds}{$inboxId}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + + xlog $self, "create snooze mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "snoozed", + parentId => undef, + role => "snoozed" + }}}, "R4"] + ]); + $self->assert_not_null($res->[0][1]{created}); + my $snoozedId = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "Move message to snooze mailbox"; + my $maildate = DateTime->now(); + $maildate->add(DateTime::Duration->new(seconds => 30)); + my $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "mailboxIds/$inboxId" => undef, + "mailboxIds/$snoozedId" => $JSON::true, + "snoozed" => { "until" => $datestr }, + keywords => { '$flagged' => JSON::true, '$seen' => JSON::true }, + }} + }, 'R5'] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods([['Email/changes', { sinceState => $oldState }, "R1"]]); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([$emailId], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $oldState = $res->[0][1]{newState}; + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R6" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_null($msg->{mailboxIds}{$inboxId}); + $self->assert_not_null($msg->{mailboxIds}{$snoozedId}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_equals($datestr, $msg->{snoozed}{'until'}); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $res = $jmap->CallMethods([['Email/changes', { sinceState => $oldState }, "R1"]]); + $self->assert_str_equals($oldState, $res->[0][1]{newState}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R6" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_null($msg->{mailboxIds}{$inboxId}); + $self->assert_not_null($msg->{mailboxIds}{$snoozedId}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_equals($datestr, $msg->{snoozed}{'until'}); + + xlog $self, "Adjust snooze#until"; + $maildate->add(DateTime::Duration->new(seconds => 15)); + $datestr = $maildate->strftime('%Y-%m-%dT%TZ'); + + $res = $jmap->CallMethods([ + ['Email/set', { + update => { $emailId => { + "snoozed" => { + "until" => $datestr, + "setKeywords" => { '$awakened' => $JSON::true } + }, + }} + }, 'R5'] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + $res = $jmap->CallMethods([['Email/changes', { sinceState => $oldState }, "R1"]]); + $self->assert_str_not_equals($oldState, $res->[0][1]{newState}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([$emailId], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $oldState = $res->[0][1]{newState}; + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R6" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_null($msg->{mailboxIds}{$inboxId}); + $self->assert_not_null($msg->{mailboxIds}{$snoozedId}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_equals($datestr, $msg->{snoozed}{'until'}); + + xlog $self, "make sure replication doesn't revert it!"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $res = $jmap->CallMethods([['Email/changes', { sinceState => $oldState }, "R1"]]); + $self->assert_str_equals($oldState, $res->[0][1]{newState}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R6" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_null($msg->{mailboxIds}{$inboxId}); + $self->assert_not_null($msg->{mailboxIds}{$snoozedId}); + $self->assert_num_equals(1, scalar keys %{$msg->{mailboxIds}}); + $self->assert_equals($datestr, $msg->{snoozed}{'until'}); + + xlog $self, "trigger re-delivery of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 30 ); + + $res = $jmap->CallMethods( [ [ 'Email/get', + { ids => [ $emailId ], + properties => [ 'mailboxIds', 'keywords', 'snoozed' ]}, "R7" ] ] ); + $msg = $res->[0][1]->{list}[0]; + $self->assert_num_equals(3, scalar keys %{$msg->{keywords}}); + $self->assert_equals(JSON::true, $msg->{keywords}{'$awakened'}); + + $res = $jmap->CallMethods([['Email/changes', { sinceState => $oldState }, "R1"]]); + $self->assert_str_not_equals($oldState, $res->[0][1]{newState}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([$emailId], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/search_sharedpart b/cassandane/tiny-tests/JMAPEmail/search_sharedpart new file mode 100644 index 0000000000..5e404457ba --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/search_sharedpart @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +sub test_search_sharedpart + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $body = "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $body .= "\r\n"; + $body .= "This is the lady plain text part."; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/html;charset=\"UTF-8\"\r\n"; + $body .= "\r\n"; + $body .= "

This is the lady html part.

"; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348--\r\n"; + + $self->make_message("lady subject", + mime_type => "multipart/alternative", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body + ) || die; + + $body = "--h8h89737fe04d3bde348\r\n"; + $body .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $body .= "\r\n"; + $body .= "This is the foobar plain text part."; + $body .= "\r\n"; + $body .= "--h8h89737fe04d3bde348\r\n"; + $body .= "Content-Type: text/html;charset=\"UTF-8\"\r\n"; + $body .= "\r\n"; + $body .= "

This is the lady html part.

"; + $body .= "\r\n"; + $body .= "--h8h89737fe04d3bde348--\r\n"; + + $self->make_message("foobar subject", + mime_type => "multipart/alternative", + mime_boundary => "h8h89737fe04d3bde348", + body => $body + ) || die; + + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => {text => "foobar"}, + findMatchingParts => JSON::true, + },"R1"], + ], $using); + my $emailIds = $res->[0][1]{ids}; + my $partIds = $res->[0][1]{partIds}; + + my $fooId = $emailIds->[0]; + + $self->assert_num_equals(1, scalar @$emailIds); + $self->assert_num_equals(1, scalar keys %$partIds); + $self->assert_num_equals(1, scalar @{$partIds->{$fooId}}); + $self->assert_str_equals("1", $partIds->{$fooId}[0]); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => {text => "lady"}, + findMatchingParts => JSON::true, + }, "R1"], + ], $using); + $emailIds = $res->[0][1]{ids}; + $partIds = $res->[0][1]{partIds}; + + my ($ladyId) = grep { $_ ne $fooId } @$emailIds; + + $self->assert_num_equals(2, scalar @$emailIds); + $self->assert_num_equals(2, scalar keys %$partIds); + $self->assert_num_equals(1, scalar @{$partIds->{$fooId}}); + $self->assert_num_equals(2, scalar @{$partIds->{$ladyId}}); + $self->assert_not_null(grep { $_ eq "2" } @{$partIds->{$fooId}}); + $self->assert_not_null(grep { $_ eq "1" } @{$partIds->{$ladyId}}); + $self->assert_not_null(grep { $_ eq "2" } @{$partIds->{$ladyId}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/searchsnippet_get b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get new file mode 100644 index 0000000000..68bdebcef5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get @@ -0,0 +1,111 @@ +#!perl +use Cassandane::Tiny; + +sub test_searchsnippet_get + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create emails"; + my %params = ( + body => "A simple message", + ); + $res = $self->make_message("Message foo", %params) || die; + + %params = ( + body => "" + . "In the context of electronic mail, messages are viewed as having an\r\n" + . "envelope and contents. The envelope contains whatever information is\r\n" + . "needed to accomplish transmission and delivery. (See [RFC5321] for a\r\n" + . "discussion of the envelope.) The contents comprise the object to be\r\n" + . "delivered to the recipient. This specification applies only to the\r\n" + . "format and some of the semantics of message contents. It contains no\r\n" + . "specification of the information in the envelope.i\r\n" + . "\r\n" + . "However, some message systems may use information from the contents\r\n" + . "to create the envelope. It is intended that this specification\r\n" + . "facilitate the acquisition of such information by programs.\r\n" + . "\r\n" + . "This specification is intended as a definition of what message\r\n" + . "content format is to be passed between systems. Though some message\r\n" + . "systems locally store messages in this format (which eliminates the\r\n" + . "need for translation between formats) and others use formats that\r\n" + . "differ from the one specified in this specification, local storage is\r\n" + . "outside of the scope of this specification.\r\n" + . "\r\n" + . "This paragraph is not part of the specification, it has been added to\r\n" + . "contain the most mentions of the word message. Messages are processed\r\n" + . "by messaging systems, which is the message of this paragraph.\r\n" + . "Don't interpret too much into this message.\r\n", + ); + $self->make_message("Message bar", %params) || die; + %params = ( + body => "This body doesn't contain any of the search terms.\r\n", + ); + $self->make_message("A subject without any matching search term", %params) || die; + + $self->make_message("Message baz", %params) || die; + %params = ( + body => "This body doesn't contain any of the search terms.\r\n", + ); + $self->make_message("A subject with message", %params) || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch email ids"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2' ], + ]); + + my %m = map { $_->{subject} => $_ } @{$res->[1][1]{list}}; + my $foo = $m{"Message foo"}->{id}; + my $bar = $m{"Message bar"}->{id}; + my $baz = $m{"Message baz"}->{id}; + $self->assert_not_null($foo); + $self->assert_not_null($bar); + $self->assert_not_null($baz); + + xlog $self, "fetch snippets"; + $res = $jmap->CallMethods([['SearchSnippet/get', { + emailIds => [ $foo, $bar ], + filter => { text => "message" }, + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{list}}); + $self->assert_null($res->[0][1]->{notFound}); + %m = map { $_->{emailId} => $_ } @{$res->[0][1]{list}}; + $self->assert_not_null($m{$foo}); + $self->assert_not_null($m{$bar}); + + %m = map { $_->{emailId} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_not_equals(-1, index($m{$foo}->{subject}, "Message foo")); + $self->assert_num_not_equals(-1, index($m{$foo}->{preview}, "A simple message")); + $self->assert_num_not_equals(-1, index($m{$bar}->{subject}, "Message bar")); + $self->assert_num_not_equals(-1, index($m{$bar}->{preview}, "" + . "Messages are processed by messaging systems," + )); + + xlog $self, "fetch snippets with one unknown id"; + $res = $jmap->CallMethods([['SearchSnippet/get', { + emailIds => [ $foo, "bam" ], + filter => { text => "message" }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{notFound}}); + + xlog $self, "fetch snippets with only a matching subject"; + $res = $jmap->CallMethods([['SearchSnippet/get', { + emailIds => [ $baz ], + filter => { text => "message" }, + }, "R1"]]); + $self->assert_not_null($res->[0][1]->{list}[0]->{subject}); + $self->assert(exists $res->[0][1]->{list}[0]->{preview}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_attachment b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_attachment new file mode 100644 index 0000000000..8f3ab20462 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_attachment @@ -0,0 +1,145 @@ +#!perl +use Cassandane::Tiny; + +sub test_searchsnippet_get_attachment + :min_version_3_3 :needs_component_jmap :needs_search_xapian + :needs_component_sieve :SearchAttachmentExtractor :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $instance = $self->{instance}; + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + + # Start a dummy extractor server. + my %seenPath; + my $handler = sub { + my ($conn, $req) = @_; + if ($req->method eq 'HEAD') { + my $res = HTTP::Response->new(204); + $res->content(""); + $conn->send_response($res); + } elsif ($seenPath{$req->uri->path}) { + my $res = HTTP::Response->new(200); + $res->header("Keep-Alive" => "timeout=1"); # Force client timeout + $res->content("dog cat bat"); + $conn->send_response($res); + } else { + $conn->send_error(404); + $seenPath{$req->uri->path} = 1; + } + }; + $instance->start_httpd($handler, $uri->port()); + + # Append an email with PDF attachment text "dog cat bat". + my $file = "data/dogcatbat.pdf.b64"; + open my $input, '<', $file or die "can't open $file: $!"; + my $body = "" + ."\r\n--boundary_1\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."text body" + ."\r\n--boundary_1\r\n" + ."Content-Type: application/pdf\r\n" + ."Content-Transfer-Encoding: BASE64\r\n" + . "\r\n"; + while (<$input>) { + chomp; + $body .= $_ . "\r\n"; + } + $body .= "\r\n--boundary_1--\r\n"; + close $input or die "can't close $file: $!"; + + $self->make_message("msg1", + mime_type => "multipart/related", + mime_boundary => "boundary_1", + body => $body + ) || die; + + # Run squatter + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + # Test 0: query attachmentbody + my $filter = { attachmentBody => "cat" }; + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => $filter, + findMatchingParts => JSON::true, + }, "R1"], + ], $using); + my $emailIds = $res->[0][1]{ids}; + $self->assert_num_equals(1, scalar @{$emailIds}); + my $partIds = $res->[0][1]{partIds}; + $self->assert_not_null($partIds); + + # Test 1: pass partIds + $res = $jmap->CallMethods([['SearchSnippet/get', { + emailIds => $emailIds, + partIds => $partIds, + filter => $filter + }, "R1"]], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + my $snippet = $res->[0][1]->{list}[0]; + $self->assert_str_equals("dog cat bat", $snippet->{preview}); + + # Test 2: pass null partids + $res = $jmap->CallMethods([['SearchSnippet/get', { + emailIds => $emailIds, + partIds => { + $emailIds->[0] => undef + }, + filter => $filter + }, "R1"]], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $snippet = $res->[0][1]->{list}[0]; + $self->assert_null($snippet->{preview}); + + # Sleep 1 sec to force Cyrus to timeout the client connection + sleep(1); + + # Test 3: pass no partids + $res = $jmap->CallMethods([['SearchSnippet/get', { + emailIds => $emailIds, + filter => $filter + }, "R1"]], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $snippet = $res->[0][1]->{list}[0]; + $self->assert_null($snippet->{preview}); + + # Test 4: test null partids for header-only match + $filter = { + text => "msg1" + }; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => $filter, + findMatchingParts => JSON::true, + }, "R1"], + ], $using); + $emailIds = $res->[0][1]{ids}; + $self->assert_num_equals(1, scalar @{$emailIds}); + $partIds = $res->[0][1]{partIds}; + my $findMatchingParts = { + $emailIds->[0] => undef + }; + $self->assert_deep_equals($findMatchingParts, $partIds); + + # Test 5: query text + $filter = { text => "cat" }; + $res = $jmap->CallMethods([ + ['Email/query', { + filter => $filter, + findMatchingParts => JSON::true, + }, "R1"], + ], $using); + $emailIds = $res->[0][1]{ids}; + $self->assert_num_equals(1, scalar @{$emailIds}); + $partIds = $res->[0][1]{partIds}; + $self->assert_not_null($partIds); +} diff --git a/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_attachments b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_attachments new file mode 100644 index 0000000000..9d7d842de3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_attachments @@ -0,0 +1,184 @@ +#!perl +use Cassandane::Tiny; + +sub test_searchsnippet_get_attachments + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :SearchAttachmentExtractor :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $instance = $self->{instance}; + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + + # Start a dummy extractor server. + my $handler = sub { + my ($conn, $req) = @_; + if ($req->method eq 'HEAD') { + my $res = HTTP::Response->new(204); + $res->content(""); + $conn->send_response($res); + } else { + my $res = HTTP::Response->new(200); + $res->header("Keep-Alive" => "timeout=1"); # Force client timeout + $res->content("attachment body"); + $conn->send_response($res); + } + }; + $instance->start_httpd($handler, $uri->port()); + + my $rawMessage = <<'EOF'; +From: +To: to@local +Reply-To: replyto@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary=6c3338934661485f87537c19b5f9d933 + +--6c3338934661485f87537c19b5f9d933 +Content-Type: text/plain + +text body + +--6c3338934661485f87537c19b5f9d933 +Content-Type: image/jpg +Content-Disposition: attachment; filename="November.jpg" +Content-Transfer-Encoding: base64 + +ZGF0YQ== + +--6c3338934661485f87537c19b5f9d933 +Content-Type: application/pdf +Content-Disposition: attachment; filename="December.pdf" +Content-Transfer-Encoding: base64 + +ZGF0YQ== + +--6c3338934661485f87537c19b5f9d933-- +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + text => 'December', + }, + findMatchingParts => JSON::true, + }, 'R1'], + ['SearchSnippet/get', { + '#emailIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + '#partIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/partIds', + }, + '#filter' => { + resultOf => 'R1', + name => 'Email/query', + path => '/filter', + }, + }, 'R2'], + ], $using); + + $self->assert_not_null($res->[1][1]{list}[0]); + $self->assert_null($res->[1][1]{list}[0]{preview}); + + my $matches = $res->[1][1]{list}[0]{attachments}; + $self->assert_num_equals(1, scalar keys %{$matches}); + $self->assert_not_null($matches->{3}{blobId}); + delete($matches->{3}{blobId}); + + $self->assert_deep_equals({ + 3 => { + name => 'December.pdf', + type => 'application/pdf', + }, + }, $matches); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + text => 'body', + }, + findMatchingParts => JSON::true, + }, 'R1'], + ['SearchSnippet/get', { + '#emailIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + '#partIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/partIds', + }, + '#filter' => { + resultOf => 'R1', + name => 'Email/query', + path => '/filter', + }, + }, 'R2'], + ], $using); + + $self->assert_not_null($res->[1][1]{list}[0]); + $self->assert_not_null($res->[1][1]{list}[0]{preview}); + + $matches = $res->[1][1]{list}[0]{attachments}; + $self->assert_num_equals(2, scalar keys %{$matches}); + $self->assert_not_null($matches->{2}{blobId}); + delete($matches->{2}{blobId}); + $self->assert_not_null($matches->{3}{blobId}); + delete($matches->{3}{blobId}); + + $self->assert_deep_equals({ + 2 => { + name => 'November.jpg', + type => 'image/jpg', + }, + 3 => { + name => 'December.pdf', + type => 'application/pdf', + }, + }, $matches); + + $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + text => 'body', + }, + findMatchingParts => JSON::false, + }, 'R1'], + ['SearchSnippet/get', { + '#emailIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + '#filter' => { + resultOf => 'R1', + name => 'Email/query', + path => '/filter', + }, + }, 'R2'], + ], $using); + $self->assert_not_null($res->[1][1]{list}[0]); + $self->assert_null($res->[1][1]{list}[0]{attachments}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_regression b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_regression new file mode 100644 index 0000000000..d24233c8df --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_regression @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_searchsnippet_get_regression + :min_version_3_1 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $body = "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $body .= "\r\n"; + $body .= "This is the lady plain text part."; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348\r\n"; + $body .= "Content-Type: text/html;charset=\"UTF-8\"\r\n"; + $body .= "\r\n"; + $body .= "

This is the lady html part.

"; + $body .= "\r\n"; + $body .= "--047d7b33dd729737fe04d3bde348--\r\n"; + $self->make_message("lady subject", + mime_type => "multipart/alternative", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body + ) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $using = [ + 'https://cyrusimap.org/ns/jmap/performance', + 'https://cyrusimap.org/ns/jmap/debug', + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + ]; + + my $res = $jmap->CallMethods([ + ['Email/query', { filter => {text => "lady"}}, "R1"], + ], $using); + my $emailIds = $res->[0][1]{ids}; + my $partIds = $res->[0][1]{partIds}; + + $res = $jmap->CallMethods([ + ['SearchSnippet/get', { + emailIds => $emailIds, + filter => { text => "lady" }, + }, 'R2'], + ], $using); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_shared b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_shared new file mode 100644 index 0000000000..059e4b4c5f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_shared @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_searchsnippet_get_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "create user and share mailboxes"; + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lr") or die; + $admintalk->create("user.foo.box1") or die; + $admintalk->setacl("user.foo.box1", "cassandane", "lr") or die; + + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => 'foo' }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + xlog $self, "create emails in shared account"; + $self->{adminstore}->set_folder('user.foo'); + my %params = ( + body => "A simple email", + ); + $res = $self->make_message("Email foo", %params, store => $self->{adminstore}) || die; + $self->{adminstore}->set_folder('user.foo.box1'); + %params = ( + body => "Another simple email", + ); + $res = $self->make_message("Email bar", %params, store => $self->{adminstore}) || die; + + xlog $self, "run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "fetch email ids"; + $res = $jmap->CallMethods([ + ['Email/query', { accountId => 'foo' }, "R1"], + ['Email/get', { + accountId => 'foo', + '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } + }, 'R2' ], + ]); + + my %m = map { $_->{subject} => $_ } @{$res->[1][1]{list}}; + my $foo = $m{"Email foo"}->{id}; + my $bar = $m{"Email bar"}->{id}; + $self->assert_not_null($foo); + $self->assert_not_null($bar); + + xlog $self, "remove read rights for mailbox containing email $bar"; + $admintalk->setacl("user.foo.box1", "cassandane", "") or die; + + xlog $self, "fetch snippets"; + $res = $jmap->CallMethods([['SearchSnippet/get', { + accountId => 'foo', + emailIds => [ $foo, $bar ], + filter => { text => "simple" }, + }, "R1"]]); + $self->assert_str_equals($foo, $res->[0][1]->{list}[0]{emailId}); + $self->assert_str_equals($bar, $res->[0][1]->{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_text_rtf b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_text_rtf new file mode 100644 index 0000000000..d0597ef84a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/searchsnippet_get_text_rtf @@ -0,0 +1,140 @@ +#!perl +use Cassandane::Tiny; + +sub test_searchsnippet_get_text_rtf + :min_version_3_4 :needs_component_jmap :JMAPExtensions + :SearchAttachmentExtractor +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $instance = $self->{instance}; + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + + # Start a dummy extractor server. + my $handler = sub { + my ($conn, $req) = @_; + if ($req->method eq 'HEAD') { + my $res = HTTP::Response->new(204); + $res->content(""); + $conn->send_response($res); + } else { + my $res = HTTP::Response->new(200); + $res->header("Keep-Alive" => "timeout=1"); # Force client timeout + $res->content("This is an RTF attachment with formatting."); + $conn->send_response($res); + } + }; + $instance->start_httpd($handler, $uri->port()); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:submission', + 'https://cyrusimap.org/ns/jmap/mail', + 'https://cyrusimap.org/ns/jmap/debug', + 'https://cyrusimap.org/ns/jmap/performance', + ]; + + my $rawMessage = <<'EOF'; +From: from@local +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary=c4683f7a320d4d20902b000486fbdf9b + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/plain + +test + +--c4683f7a320d4d20902b000486fbdf9b +Content-Disposition: attachment;filename="test.rtf" +Content-Type: text/rtf; name="test.rtf" +Content-Transfer-Encoding: BASE64 + +e1xydGYxXGFuc2lcZGVmZjNcYWRlZmxhbmcxMDI1CntcZm9udHRibHtcZjBcZnJvbWFuXGZw +cnEyXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjFcZnJvbWFuXGZwcnEyXGZjaGFy +c2V0MiBTeW1ib2w7fXtcZjJcZnN3aXNzXGZwcnEyXGZjaGFyc2V0MCBBcmlhbDt9e1xmM1xm +cm9tYW5cZnBycTJcZmNoYXJzZXQwIExpYmVyYXRpb24gU2VyaWZ7XCpcZmFsdCBUaW1lcyBO +ZXcgUm9tYW59O317XGY0XGZzd2lzc1xmcHJxMlxmY2hhcnNldDAgTGliZXJhdGlvbiBTYW5z +e1wqXGZhbHQgQXJpYWx9O317XGY1XGZuaWxcZnBycTJcZmNoYXJzZXQwIE5vdG8gU2FucyBD +SksgU0MgUmVndWxhcjt9e1xmNlxmbmlsXGZwcnEyXGZjaGFyc2V0MCBOb3RvIFNhbnMgRGV2 +YW5hZ2FyaTt9e1xmN1xmc3dpc3NcZnBycTBcZmNoYXJzZXQxMjggTm90byBTYW5zIERldmFu +YWdhcmk7fX0Ke1xjb2xvcnRibDtccmVkMFxncmVlbjBcYmx1ZTA7XHJlZDBcZ3JlZW4wXGJs +dWUyNTU7XHJlZDBcZ3JlZW4yNTVcYmx1ZTI1NTtccmVkMFxncmVlbjI1NVxibHVlMDtccmVk +MjU1XGdyZWVuMFxibHVlMjU1O1xyZWQyNTVcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4y +NTVcYmx1ZTA7XHJlZDI1NVxncmVlbjI1NVxibHVlMjU1O1xyZWQwXGdyZWVuMFxibHVlMTI4 +O1xyZWQwXGdyZWVuMTI4XGJsdWUxMjg7XHJlZDBcZ3JlZW4xMjhcYmx1ZTA7XHJlZDEyOFxn +cmVlbjBcYmx1ZTEyODtccmVkMTI4XGdyZWVuMFxibHVlMDtccmVkMTI4XGdyZWVuMTI4XGJs +dWUwO1xyZWQxMjhcZ3JlZW4xMjhcYmx1ZTEyODtccmVkMTkyXGdyZWVuMTkyXGJsdWUxOTI7 +fQp7XHN0eWxlc2hlZXR7XHMwXHNuZXh0MFx3aWRjdGxwYXJcaHlwaHBhcjBcY2YwXGtlcm5p +bmcxXGRiY2hcYWY4XGxhbmdmZTIwNTJcZGJjaFxhZjZcYWZzMjRcYWxhbmcxMDgxXGxvY2hc +ZjNcaGljaFxhZjNcZnMyNFxsYW5nMTAzMyBOb3JtYWw7fQp7XHMxNVxzYmFzZWRvbjBcc25l +eHQxNlxzYjI0MFxzYTEyMFxrZWVwblxkYmNoXGFmNVxkYmNoXGFmNlxhZnMyOFxsb2NoXGY0 +XGZzMjggSGVhZGluZzt9CntcczE2XHNiYXNlZG9uMFxzbmV4dDE2XHNsMjc2XHNsbXVsdDFc +c2IwXHNhMTQwIFRleHQgQm9keTt9CntcczE3XHNiYXNlZG9uMTZcc25leHQxN1xzbDI3Nlxz +bG11bHQxXHNiMFxzYTE0MFxkYmNoXGFmNyBMaXN0O30Ke1xzMThcc2Jhc2Vkb24wXHNuZXh0 +MThcc2IxMjBcc2ExMjBcbm9saW5lXGlcZGJjaFxhZjdcYWZzMjRcYWlcZnMyNCBDYXB0aW9u +O30Ke1xzMTlcc2Jhc2Vkb24wXHNuZXh0MTlcbm9saW5lXGRiY2hcYWY3IEluZGV4O30KfXtc +KlxnZW5lcmF0b3IgTGlicmVPZmZpY2UvNi4xLjUuMiRMaW51eF9YODZfNjQgTGlicmVPZmZp +Y2VfcHJvamVjdC8xMCRCdWlsZC0yfXtcaW5mb3tcY3JlYXRpbVx5cjIwMjFcbW8zXGR5MTFc +aHIxMVxtaW4zOX17XHJldnRpbVx5cjIwMjFcbW8zXGR5MTFcaHIxMVxtaW40MX17XHByaW50 +aW1ceXIwXG1vMFxkeTBcaHIwXG1pbjB9fXtcKlx1c2VycHJvcHN9XGRlZnRhYjcwOQpcdmll +d3NjYWxlMTUwCntcKlxwZ2RzY3RibAp7XHBnZHNjMFxwZ2RzY3VzZTQ1MVxwZ3dzeG4xMjI0 +MFxwZ2hzeG4xNTg0MFxtYXJnbHN4bjExMzRcbWFyZ3JzeG4xMTM0XG1hcmd0c3huMTEzNFxt +YXJnYnN4bjExMzRccGdkc2NueHQwIERlZmF1bHQgU3R5bGU7fX0KXGZvcm1zaGFkZVxwYXBl +cmgxNTg0MFxwYXBlcncxMjI0MFxtYXJnbDExMzRcbWFyZ3IxMTM0XG1hcmd0MTEzNFxtYXJn +YjExMzRcc2VjdGRcc2Jrbm9uZVxzZWN0dW5sb2NrZWQxXHBnbmRlY1xwZ3dzeG4xMjI0MFxw +Z2hzeG4xNTg0MFxtYXJnbHN4bjExMzRcbWFyZ3JzeG4xMTM0XG1hcmd0c3huMTEzNFxtYXJn +YnN4bjExMzRcZnRuYmpcZnRuc3RhcnQxXGZ0bnJzdGNvbnRcZnRubmFyXGFlbmRkb2NcYWZ0 +bnJzdGNvbnRcYWZ0bnN0YXJ0MVxhZnRubnJsYwp7XCpcZnRuc2VwXGNoZnRuc2VwfVxwZ25k +ZWNccGFyZFxwbGFpbiBcczBcd2lkY3RscGFyXGh5cGhwYXIwXGNmMFxrZXJuaW5nMVxkYmNo +XGFmOFxsYW5nZmUyMDUyXGRiY2hcYWY2XGFmczI0XGFsYW5nMTA4MVxsb2NoXGYzXGhpY2hc +YWYzXGZzMjRcbGFuZzEwMzN7XHJ0bGNoIFxsdHJjaFxsb2NoClRoaXMgaXMgYW4gfXtcaVxh +aVxydGxjaCBcbHRyY2hcbG9jaApSVEZ9e1xydGxjaCBcbHRyY2hcbG9jaAogfXtcYlxhYlxy +dGxjaCBcbHRyY2hcbG9jaAphdHRhY2htZW50fXtccnRsY2ggXGx0cmNoXGxvY2gKIHdpdGgg +fXtcdWxcdWxjMFxydGxjaCBcbHRyY2hcbG9jaApmb3JtYXR0aW5nfXtccnRsY2ggXGx0cmNo +XGxvY2gKLn0KXHBhciB9 + +--c4683f7a320d4d20902b000486fbdf9b-- + +test +EOF + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + text => 'formatting', + }, + findMatchingParts => JSON::true, + }, 'R1'], + ['SearchSnippet/get', { + '#emailIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids', + }, + '#filter' => { + resultOf => 'R1', + name => 'Email/query', + path => '/filter', + }, + '#partIds' => { + resultOf => 'R1', + name => 'Email/query', + path => '/partIds', + }, + }, 'R2'], + ], $using); + + $self->assert_str_equals('This is an RTF attachment with formatting.', + $res->[1][1]{list}[0]{preview}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/searchsnippet_search_maxsize b/cassandane/tiny-tests/JMAPEmail/searchsnippet_search_maxsize new file mode 100644 index 0000000000..982663d267 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/searchsnippet_search_maxsize @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_searchsnippet_search_maxsize + :min_version_3_5 :needs_component_sieve :needs_component_jmap + :JMAPExtensions :SearchMaxSize4k +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $rawMessage = <<'EOF'; +From: from@local +To: to@local +Subject: test +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +EOF + + xlog "Index overlong text"; + my $kbody = "xxx\n" x 1023; + $kbody .= "foo\n"; # last line of included text + $kbody .= "bar\n"; # first line of excluded text + $rawMessage .= $kbody; + $rawMessage =~ s/\r?\n/\r\n/gs; + $imap->append('INBOX', $rawMessage) || die $@; + + xlog "Assert indexer only processes maxsize bytes of text"; + $self->{instance}->getsyslog(); # clear syslog + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + if ($self->{instance}->{have_syslog_replacement}) { + my @lines = $self->{instance}->getsyslog(qr/Xapian: truncating/); + $self->assert_num_equals(1, scalar @lines); + } + + my $res = $jmap->CallMethods([ + ['Email/query', { + filter => { + body => 'foo', + }, + }, "R1"], + ['Email/query', { + filter => { + body => 'bar', + }, + }, "R2"], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_num_equals(0, scalar @{$res->[1][1]{ids}}); + my $emailId = $res->[0][1]{ids}[0]; + + # Note: test assumes Cyrus charset buffer to flush every 4096 bytes + + xlog "Assert snippet generator only processes maxsize bytes of text"; + $self->{instance}->getsyslog(); # clear syslog + $res = $jmap->CallMethods([ + ['SearchSnippet/get', { + emailIds => [ $emailId ], + filter => { + body => 'foo', + }, + }, 'R3'], + ]); + $self->assert_not_null($res->[0][1]{list}[0]{preview}); + if ($self->{instance}->{have_syslog_replacement}) { + my @lines = $self->{instance}->getsyslog(qr/Xapian: truncating/); + $self->assert_num_equals(1, scalar @lines); + } + + xlog "Assert snippet generator only processes maxsize bytes of text"; + $self->{instance}->getsyslog(); # clear syslog + $res = $jmap->CallMethods([ + ['SearchSnippet/get', { + emailIds => [ $emailId ], + filter => { + body => 'bar', + }, + }, 'R3'], + ]); + $self->assert_null($res->[0][1]{list}[0]{preview}); + if ($self->{instance}->{have_syslog_replacement}) { + my @lines = $self->{instance}->getsyslog(qr/Xapian: truncating/); + $self->assert_num_equals(1, scalar @lines); + } +} diff --git a/cassandane/tiny-tests/JMAPEmail/thread_changes b/cassandane/tiny-tests/JMAPEmail/thread_changes new file mode 100644 index 0000000000..5c5840a06b --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/thread_changes @@ -0,0 +1,232 @@ +#!perl +use Cassandane::Tiny; + +sub test_thread_changes + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + my %params; + my $dt; + my $draftsmbox; + my $state; + my $threadA; + my $threadB; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $draftsmbox = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftsmbox); + + xlog $self, "Generate an email in drafts via IMAP"; + $self->{store}->set_folder("INBOX.drafts"); + $self->make_message("Email A") || die; + + xlog $self, "get thread state"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' } }, 'R2' ], + ]); + $res = $jmap->CallMethods([ + ['Thread/get', { 'ids' => [ $res->[1][1]{list}[0]{threadId} ] }, 'R1'], + ]); + $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "get thread updates"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + xlog $self, "generating email A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -3)); + $exp{A} = $self->make_message("Email A", date => $dt, body => "a"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "get thread updates"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + $threadA = $res->[0][1]{created}[0]; + + xlog $self, "generating email C referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -2)); + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "c"); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "get thread updates"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($threadA, $res->[0][1]{updated}[0]); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + + xlog $self, "get thread updates (expect no changes)"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B", body => "b"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email D referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -1)); + $exp{D} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "d"); + $exp{D}->set_attributes(uid => 4, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "generating email E referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(minutes => -30)); + $exp{E} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "e"); + $exp{E}->set_attributes(uid => 5, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "get max 1 thread updates"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state, maxChanges => 1 }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::true, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_str_not_equals($threadA, $res->[0][1]{created}[0]); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + $threadB = $res->[0][1]{created}[0]; + + xlog $self, "get max 2 thread updates"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state, maxChanges => 2 }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($threadA, $res->[0][1]{updated}[0]); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + + xlog $self, "fetch emails"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]); + + # Map messages by body contents + my %m = map { $_->{bodyValues}{$_->{textBody}[0]{partId}}{value} => $_ } @{$res->[1][1]{list}}; + my $msgA = $m{"a"}; + my $msgB = $m{"b"}; + my $msgC = $m{"c"}; + my $msgD = $m{"d"}; + my $msgE = $m{"e"}; + $self->assert_not_null($msgA); + $self->assert_not_null($msgB); + $self->assert_not_null($msgC); + $self->assert_not_null($msgD); + $self->assert_not_null($msgE); + + xlog $self, "destroy email b, update email d"; + $res = $jmap->CallMethods([['Email/set', { + destroy => [ $msgB->{id} ], + update => { $msgD->{id} => { 'keywords/$foo' => JSON::true }}, + }, "R1"]]); + $self->assert_str_equals($msgB->{id}, $res->[0][1]{destroyed}[0]); + $self->assert(exists $res->[0][1]->{updated}{$msgD->{id}}); + + xlog $self, "get thread updates"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($threadA, $res->[0][1]{updated}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($threadB, $res->[0][1]{destroyed}[0]); + $state = $res->[0][1]->{newState}; + + xlog $self, "destroy emails c and e"; + $res = $jmap->CallMethods([['Email/set', { + destroy => [ $msgC->{id}, $msgE->{id} ], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get thread updates, fetch threads"; + $res = $jmap->CallMethods([ + ['Thread/changes', { sinceState => $state }, "R1"], + ['Thread/get', { '#ids' => { resultOf => 'R1', name => 'Thread/changes', path => '/updated' }}, 'R2'], + ]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($threadA, $res->[0][1]{updated}[0]); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]->{newState}; + + $self->assert_str_equals('Thread/get', $res->[1][0]); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_str_equals($threadA, $res->[1][1]{list}[0]->{id}); + + xlog $self, "destroy emails a and d"; + $res = $jmap->CallMethods([['Email/set', { + destroy => [ $msgA->{id}, $msgD->{id} ], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "get thread updates"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($threadA, $res->[0][1]{destroyed}[0]); + $state = $res->[0][1]->{newState}; + + xlog $self, "get thread updates (expect no changes)"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/thread_get b/cassandane/tiny-tests/JMAPEmail/thread_get new file mode 100644 index 0000000000..987eb58f95 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/thread_get @@ -0,0 +1,109 @@ +#!perl +use Cassandane::Tiny; + +sub test_thread_get + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + my %params; + my $dt; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + my $drafts = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($drafts); + + xlog $self, "generating email A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -3)); + $exp{A} = $self->make_message("Email A", date => $dt, body => "a"); + $exp{A}->set_attributes(uid => 1, cid => $exp{A}->make_cid()); + + xlog $self, "generating email B"; + $exp{B} = $self->make_message("Email B", body => "b"); + $exp{B}->set_attributes(uid => 2, cid => $exp{B}->make_cid()); + + xlog $self, "generating email C referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -2)); + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "c"); + $exp{C}->set_attributes(uid => 3, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "generating email D referencing A"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -1)); + $exp{D} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "d"); + $exp{D}->set_attributes(uid => 4, cid => $exp{A}->get_attribute('cid')); + + xlog $self, "fetch emails"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]); + + # Map messages by body contents + my %m = map { $_->{bodyValues}{$_->{textBody}[0]{partId}}{value} => $_ } @{$res->[1][1]{list}}; + my $msgA = $m{"a"}; + my $msgB = $m{"b"}; + my $msgC = $m{"c"}; + my $msgD = $m{"d"}; + $self->assert_not_null($msgA); + $self->assert_not_null($msgB); + $self->assert_not_null($msgC); + $self->assert_not_null($msgD); + + %m = map { $_->{threadId} => 1 } @{$res->[1][1]{list}}; + my @threadids = keys %m; + + xlog $self, "create draft replying to email A"; + $res = $jmap->CallMethods( + [[ 'Email/set', { create => { "1" => { + mailboxIds => {$drafts => JSON::true}, + inReplyTo => $msgA->{messageId}, + from => [ { name => "", email => "sam\@acme.local" } ], + to => [ { name => "", email => "bugs\@acme.local" } ], + subject => "Re: Email A", + textBody => [{ partId => '1' }], + bodyValues => { 1 => { value => "I'm givin' ya one last chance ta surrenda!" }}, + keywords => { '$draft' => JSON::true }, + }}}, "R1" ]]); + my $draftid = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftid); + + xlog $self, "get threads"; + $res = $jmap->CallMethods([['Thread/get', { ids => \@threadids }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + + %m = map { $_->{id} => $_ } @{$res->[0][1]{list}}; + my $threadA = $m{$msgA->{threadId}}; + my $threadB = $m{$msgB->{threadId}}; + + # Assert all emails are listed + $self->assert_num_equals(4, scalar @{$threadA->{emailIds}}); + $self->assert_num_equals(1, scalar @{$threadB->{emailIds}}); + + # Assert sort order by date + $self->assert_str_equals($msgA->{id}, $threadA->{emailIds}[0]); + $self->assert_str_equals($msgC->{id}, $threadA->{emailIds}[1]); + $self->assert_str_equals($msgD->{id}, $threadA->{emailIds}[2]); + $self->assert_str_equals($draftid, $threadA->{emailIds}[3]); +} diff --git a/cassandane/tiny-tests/JMAPEmail/thread_get_onemsg b/cassandane/tiny-tests/JMAPEmail/thread_get_onemsg new file mode 100644 index 0000000000..0da885051c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/thread_get_onemsg @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_thread_get_onemsg + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $jmap = $self->{jmap}; + my $res; + my $draftsmbox; + my $state; + my $threadA; + my $threadB; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $draftsmbox = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftsmbox); + + xlog $self, "get thread state"; + $res = $jmap->CallMethods([['Thread/get', { ids => [ 'no' ] }, "R1"]]); + $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + + my $email = <<'EOF'; +Return-Path: +Received: from gateway (gateway.vmtom.com [10.0.0.1]) + by ahost (ahost.vmtom.com[10.0.0.2]); Wed, 07 Dec 2016 11:43:25 +1100 +Received: from mail.gmail.com (mail.gmail.com [192.168.0.1]) + by gateway.vmtom.com (gateway.vmtom.com [10.0.0.1]); Wed, 07 Dec 2016 11:43:25 +1100 +Mime-Version: 1.0 +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: 7bit +Subject: Email A +From: Hannah V. Smith +Message-ID: +Date: Wed, 07 Dec 2016 11:43:25 +1100 +To: Test User +X-Cassandane-Unique: 294f71c341218d36d4bda75aad56599b7be3d15b + +a +EOF + $email =~ s/\r?\n/\r\n/gs; + my $data = $jmap->Upload($email, "message/rfc822"); + my $blobid = $data->{blobId}; + xlog $self, "import email from blob $blobid"; + $res = $jmap->CallMethods([['Email/import', { + emails => { + "1" => { + blobId => $blobid, + mailboxIds => {$draftsmbox => JSON::true}, + keywords => { + '$draft' => JSON::true, + }, + }, + }, + }, "R1"]]); + + xlog $self, "get thread updates"; + $res = $jmap->CallMethods([['Thread/changes', { sinceState => $state }, "R1"]]); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/thread_get_shared b/cassandane/tiny-tests/JMAPEmail/thread_get_shared new file mode 100644 index 0000000000..90610193dc --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/thread_get_shared @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_thread_get_shared + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $admintalk = $self->{adminstore}->get_client(); + + # Create user and share mailbox A but not B + xlog $self, "Create shared mailbox"; + $self->{instance}->create_user("other"); + $admintalk->create("user.other.A") or die; + $admintalk->setacl("user.other.A", "cassandane", "lr") or die; + $admintalk->create("user.other.B") or die; + + # Create message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + my $msg1 = $self->make_message("EmailA", store => $self->{adminstore}) or die; + + # move the message to mailbox B + $admintalk->select("user.other.A"); + $admintalk->move("1:*", "user.other.B"); + + # Reply-to message in mailbox A + $self->{adminstore}->set_folder('user.other.A'); + my $msg2 = $self->make_message("Re: EmailA", ( + references => [ $msg1 ], + store => $self->{adminstore}, + )) or die; + + my @fetchThreadMethods = [ + ['Email/query', { + accountId => 'other', + collapseThreads => JSON::true, + }, "R1"], + ['Email/get', { + accountId => 'other', + properties => ['threadId'], + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ['Thread/get', { + accountId => 'other', + '#ids' => { + resultOf => 'R2', + name => 'Email/get', + path => '/list/*/threadId' + }, + }, 'R3' ], + ]; + + # Fetch Thread + my $res = $jmap->CallMethods(@fetchThreadMethods); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{list}[0]{emailIds}}); + + # Now share mailbox B + $admintalk->setacl("user.other.B", "cassandane", "lr") or die; + $res = $jmap->CallMethods(@fetchThreadMethods); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_num_equals(2, scalar @{$res->[2][1]{list}[0]{emailIds}}); +} diff --git a/cassandane/tiny-tests/JMAPEmail/thread_latearrival_drafts b/cassandane/tiny-tests/JMAPEmail/thread_latearrival_drafts new file mode 100644 index 0000000000..45c9a28372 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmail/thread_latearrival_drafts @@ -0,0 +1,143 @@ +#!perl +use Cassandane::Tiny; + +sub test_thread_latearrival_drafts + :min_version_3_1 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + my %exp; + my $dt; + my $res; + my $state; + + my $jmap = $self->{jmap}; + + my $imaptalk = $self->{store}->get_client(); + + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -8)); + $exp{A} = $self->make_message("Email A", date => $dt, body => 'a') || die; + + xlog $self, "get thread state"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { '#ids' => { resultOf => 'R1', name => 'Email/query', path => '/ids' }, properties => ['threadId'] }, 'R2' ], + ['Thread/get', { '#ids' => { resultOf => 'R2', name => 'Email/get', path => '/list/*/threadId' } }, 'R3'], + ]); + $state = $res->[2][1]{state}; + $self->assert_not_null($state); + my $threadid = $res->[2][1]{list}[0]{id}; + $self->assert_not_null($threadid); + + my $inreplyheader = [['In-Reply-To' => $exp{A}->messageid()]]; + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + my $draftsmbox = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($draftsmbox); + + xlog $self, "generating email B"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -5)); + $exp{B} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "b"); + + xlog $self, "generating email C"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -2)); + $exp{C} = $self->make_message("Re: Email A", references => [ $exp{A}, $exp{B} ], date => $dt, body => "c"); + + xlog $self, "generating email D (before C)"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -3)); + $exp{D} = $self->make_message("Re: Email A", extra_headers => $inreplyheader, date => $dt, body => "d"); + + xlog $self, "Generate draft email E replying to A"; + $self->{store}->set_folder("INBOX.drafts"); + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -4)); + $exp{E} = $self->{gen}->generate(subject => "Re: Email A", extra_headers => $inreplyheader, date => $dt, body => "e"); + $self->{store}->write_begin(); + $self->{store}->write_message($exp{E}, flags => ["\\Draft"]); + $self->{store}->write_end(); + + xlog $self, "fetch emails"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]); + + # Map messages by body contents + my %m = map { $_->{bodyValues}{$_->{textBody}[0]{partId}}{value} => $_ } @{$res->[1][1]{list}}; + my $msgA = $m{"a"}; + my $msgB = $m{"b"}; + my $msgC = $m{"c"}; + my $msgD = $m{"d"}; + my $msgE = $m{"e"}; + $self->assert_not_null($msgA); + $self->assert_not_null($msgB); + $self->assert_not_null($msgC); + $self->assert_not_null($msgD); + $self->assert_not_null($msgE); + + my %map = ( + A => $msgA->{id}, + B => $msgB->{id}, + C => $msgC->{id}, + D => $msgD->{id}, + E => $msgE->{id}, + ); + + # check thread ordering + $res = $jmap->CallMethods([ + ['Thread/get', { 'ids' => [$threadid] }, 'R3'], + ]); + $self->assert_deep_equals([$map{A},$map{B},$map{E},$map{D},$map{C}], + $res->[0][1]{list}[0]{emailIds}); + + # now deliver something late that's earlier than the draft + + xlog $self, "generating email F (late arrival)"; + $dt = DateTime->now(); + $dt->add(DateTime::Duration->new(hours => -6)); + $exp{F} = $self->make_message("Re: Email A", references => [ $exp{A} ], date => $dt, body => "f"); + + xlog $self, "fetch emails"; + $res = $jmap->CallMethods([ + ['Email/query', { }, "R1"], + ['Email/get', { + '#ids' => { + resultOf => 'R1', + name => 'Email/query', + path => '/ids' + }, + fetchAllBodyValues => JSON::true, + }, 'R2' ], + ]); + + # Map messages by body contents + %m = map { $_->{bodyValues}{$_->{textBody}[0]{partId}}{value} => $_ } @{$res->[1][1]{list}}; + my $msgF = $m{"f"}; + $self->assert_not_null($msgF); + + $map{F} = $msgF->{id}; + + # check thread ordering - this message should appear after F and before B + $res = $jmap->CallMethods([ + ['Thread/get', { 'ids' => [$threadid] }, 'R3'], + ]); + $self->assert_deep_equals([$map{A},$map{F},$map{B},$map{E},$map{D},$map{C}], + $res->[0][1]{list}[0]{emailIds}); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_cancel_creation b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_cancel_creation new file mode 100644 index 0000000000..e8bc61f096 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_cancel_creation @@ -0,0 +1,142 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_cancel_creation + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityId = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityId); + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + $imap->create("INBOX.B") or die; + $res = $jmap->CallMethods([ + ['Mailbox/get', { properties => ['name'], }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $mboxByName{A}->{id}; + my $mboxIdB = $mboxByName{B}->{id}; + + xlog $self, "create, send and update email"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'm1' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'hello', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1'], + [ 'EmailSubmission/set', { + create => { + 's1' => { + identityId => $identityId, + emailId => '#m1', + envelope => { + mailFrom => { + email => 'foo@local', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [{ + email => 'bar@local', + }], + }, + }, + }, + onSuccessUpdateEmail => { + '#s1' => { + mailboxIds => { + $mboxIdB => JSON::true, + }, + }, + }, + }, 'R2' ], + [ 'Email/get', { + ids => ['#m1'], + properties => ['mailboxIds'], + }, 'R3'], + ]); + + xlog $self, "event gets added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + my $emailId = $res->[0][1]->{created}{m1}{id}; + $self->assert_not_null($emailId); + my $msgSubId = $res->[1][1]->{created}{s1}{id}; + $self->assert_not_null($msgSubId); + $self->assert(exists $res->[2][1]{updated}{$emailId}); + $self->assert_num_equals(1, scalar keys %{$res->[3][1]{list}[0]{mailboxIds}}); + $self->assert(exists $res->[3][1]{list}[0]{mailboxIds}{$mboxIdB}); + + xlog $self, "cancel the send and revert the mailbox"; + $res = $jmap->CallMethods([ + [ 'EmailSubmission/set', { + update => { + $msgSubId => { + undoStatus => 'canceled', + } + }, + onSuccessUpdateEmail => { + $msgSubId => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + }, + }, + }, 'R2' ], + [ 'Email/get', { + ids => [$emailId], + properties => ['mailboxIds'], + }, 'R3'], + ]); + + $self->assert(exists $res->[0][1]{updated}{$msgSubId}); + $self->assert(exists $res->[1][1]{updated}{$emailId}); + $self->assert_num_equals(1, scalar keys %{$res->[2][1]{list}[0]{mailboxIds}}); + $self->assert(exists $res->[2][1]{list}[0]{mailboxIds}{$mboxIdA}); + + xlog $self, "event is no longer in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); + + xlog $self, "destroy and destroy the email too"; + $res = $jmap->CallMethods([ + [ 'EmailSubmission/set', { + destroy => [$msgSubId], + onSuccessDestroyEmail => [$msgSubId], + }, 'R2' ], + [ 'Email/get', { + ids => [$emailId], + properties => ['mailboxIds'], + }, 'R3'], + ]); + + $self->assert_str_equals($msgSubId, $res->[0][1]{destroyed}[0]); + $self->assert_str_equals($emailId, $res->[1][1]{destroyed}[0]); + $self->assert_str_equals($emailId, $res->[2][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_changes b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_changes new file mode 100644 index 0000000000..84958fe36d --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_changes @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_changes + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "get current email submission state"; + $res = $jmap->CallMethods([['EmailSubmission/get', { }, "R1"]]); + my $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "get email submission updates"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/changes', { + sinceState => $state, + }, "R1" ] ] ); + $self->assert_deep_equals([], $res->[0][1]->{created}); + $self->assert_deep_equals([], $res->[0][1]->{updated}); + $self->assert_deep_equals([], $res->[0][1]->{destroyed}); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submission but don't update state"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + } + } + }, "R1" ] ] ); + my $subid = $res->[0][1]{created}{1}{id}; + $self->assert_not_null($subid); + + xlog $self, "get email submission updates"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/changes', { + sinceState => $state, + }, "R1" ] ] ); + $self->assert_deep_equals([$subid], $res->[0][1]->{created}); + $self->assert_deep_equals([], $res->[0][1]->{updated}); + $self->assert_deep_equals([], $res->[0][1]->{destroyed}); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccess_invalid_subids b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccess_invalid_subids new file mode 100644 index 0000000000..40455ae2e6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccess_invalid_subids @@ -0,0 +1,19 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_onsuccess_invalid_subids + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "set email submission with invalid submission ids"; + my $res = $jmap->CallMethods([['EmailSubmission/set', { + onSuccessUpdateEmail => { + 'foo' => { mailboxIds => { 'INBOX' => JSON::true } } }, + onSuccessDestroyEmail => [ 'bar' ] + }, "R1"]]); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("invalidProperties", $res->[0][1]{type}); + $self->assert_str_equals("R1", $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccess_not_using b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccess_not_using new file mode 100644 index 0000000000..031b810df8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccess_not_using @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_onsuccess_not_using + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwithCRLF\r\n") or die; + + xlog $self, "get identity id"; + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + } + }, + onSuccessDestroyEmail => [ '1' ], + }, "R1"]], ['urn:ietf:params:jmap:submission']); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("invalidArguments", $res->[0][1]{type}); + $self->assert_str_equals("R1", $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccessdestroy b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccessdestroy new file mode 100644 index 0000000000..61705abcf1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_onsuccessdestroy @@ -0,0 +1,90 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_onsuccessdestroy + :min_version_3_9 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submission with bad onSuccess"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + } + }, + onSuccessDestroyEmail => {} + }, "R1" ] ] ); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("invalidArguments", $res->[0][1]{type}); + $self->assert_str_equals("onSuccessDestroyEmail", + $res->[0][1]{arguments}[0]); + + xlog $self, "create email submission with bad onSuccess"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + } + }, + onSuccessDestroyEmail => "foo" + }, "R1" ] ] ); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("invalidArguments", $res->[0][1]{type}); + $self->assert_str_equals("onSuccessDestroyEmail", + $res->[0][1]{arguments}[0]); + + xlog $self, "create email submission with bad onSuccess"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + } + }, + onSuccessDestroyEmail => [ 1 ] + }, "R1" ] ] ); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("invalidArguments", $res->[0][1]{type}); + $self->assert_str_equals("onSuccessDestroyEmail[0]", + $res->[0][1]{arguments}[0]); + + xlog $self, "create email submission with no onSuccess"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + } + }, + }, "R1" ] ] ); + my $msgsubid = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($msgsubid); + + xlog $self, "create email submission with NULL onSuccess"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '2' => { + identityId => $identityid, + emailId => $emailid, + } + }, + onSuccessDestroyEmail => JSON::null + }, "R1" ] ] ); + $msgsubid = $res->[0][1]->{created}{2}{id}; + $self->assert_not_null($msgsubid); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_query b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_query new file mode 100644 index 0000000000..822ea51e39 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_query @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_query + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "get email submission list (no arguments)"; + my $res = $jmap->CallMethods([['EmailSubmission/query', { }, "R1"]]); + $self->assert_null($res->[0][1]{filter}); + $self->assert_null($res->[0][1]{sort}); + $self->assert_not_null($res->[0][1]{queryState}); + $self->assert_equals(JSON::false, $res->[0][1]{canCalculateChanges}); + $self->assert_num_equals(0, $res->[0][1]{position}); + $self->assert_num_equals(0, $res->[0][1]{total}); + $self->assert_not_null($res->[0][1]{ids}); + + xlog $self, "get email submission list (error arguments)"; + $res = $jmap->CallMethods([['EmailSubmission/query', { filter => 1 }, "R1"]]); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_query_long b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_query_long new file mode 100644 index 0000000000..ea4f102f99 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_query_long @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_query_long + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # created and onSend properties + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + + xlog $self, "Generate emails via IMAP"; + $self->make_message("foo1", body => "an email") or die; + $self->make_message("foo2", body => "an email") or die; + $self->make_message("foo3", body => "an email") or die; + + xlog $self, "get email ids"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid1 = $res->[0][1]->{ids}[0]; + my $emailid2 = $res->[0][1]->{ids}[1]; + my $emailid3 = $res->[0][1]->{ids}[2]; + + xlog $self, "create an email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid1, + } + } + }, "R1" ] ] ); + my $msgsubid1 = $res->[0][1]->{created}{1}{id}; + + sleep 1; + + my $now = DateTime->now(); + my $datestr = $now->strftime('%Y-%m-%dT%TZ'); + + xlog $self, "create 2 more email submissions"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '2' => { + identityId => $identityid, + emailId => $emailid2, + }, + '3' => { + identityId => $identityid, + emailId => $emailid3, + } + } + }, "R1" ] ] ); + my $msgsubid2 = $res->[0][1]->{created}{2}{id}; + my $msgsubid3 = $res->[0][1]->{created}{3}{id}; + + xlog $self, "filter email submission list based on created time"; + $res = $jmap->CallMethods([['EmailSubmission/query', { + filter => { + createdBefore => $datestr, + } + }, "R1"]]); + + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals($msgsubid1, $res->[0][1]{ids}[0]); + + xlog $self, "filter email submission list based on undoStatus"; + $res = $jmap->CallMethods([['EmailSubmission/query', { + filter => { + undoStatus => 'pending', + } + }, "R1"]]); + + $self->assert_num_equals(0, scalar @{$res->[0][1]->{ids}}); + + xlog $self, "sort email submission list based on created"; + $res = $jmap->CallMethods([['EmailSubmission/query', { + sort => [{ property => "created", + isAscending => JSON::false }], + }, "R1"]]); + + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_equals($msgsubid1, $res->[0][1]{ids}[2]); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_querychanges b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_querychanges new file mode 100644 index 0000000000..a279b9b7f3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_querychanges @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_querychanges + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "get current email submission state"; + my $res = $jmap->CallMethods([['EmailSubmission/query', { }, "R1"]]); + my $state = $res->[0][1]->{queryState}; + $self->assert_not_null($state); + + xlog $self, "get email submission list updates (empty filter)"; + $res = $jmap->CallMethods([['EmailSubmission/queryChanges', { + filter => {}, + sinceQueryState => $state, + }, "R1"]]); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("cannotCalculateChanges", $res->[0][1]{type}); + $self->assert_str_equals("R1", $res->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send new file mode 100644 index 0000000000..efe005264a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send @@ -0,0 +1,295 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_scheduled_send + :min_version_3_7 :needs_component_jmap :needs_component_calalarmd :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # created and onSend properties + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + xlog $self, "Create Drafts, Scheduled, and Sent mailboxes"; + my $res = $jmap->CallMethods([ + [ 'Identity/get', {}, "R0" ], + [ 'Mailbox/set', { + create => { + "1" => { + name => "Drafts", + role => "drafts" + }, + "2" => { + name => "Scheduled", + role => "scheduled" + }, + "3" => { + name => "Sent", + role => "sent" + } + } + }, "R1"], + ]); + my $identityid = $res->[0][1]->{list}[0]->{id}; + my $draftsid = $res->[1][1]{created}{"1"}{id}; + my $schedid = $res->[1][1]{created}{"2"}{id}; + my $sentid = $res->[1][1]{created}{"3"}{id}; + + xlog $self, "Verify Scheduled mailbox rights"; + my $myRights = $res->[1][1]{created}{"2"}{myRights}; + $self->assert_deep_equals({ + mayReadItems => JSON::true, + mayAddItems => JSON::false, + mayRemoveItems => JSON::false, + mayCreateChild => JSON::false, + mayDelete => JSON::false, + maySubmit => JSON::false, + maySetSeen => JSON::true, + maySetKeywords => JSON::true, + mayAdmin => JSON::false, + mayRename => JSON::false + }, $myRights); + + xlog $self, "Try to create a child of Scheduled mailbox"; + $res = $jmap->CallMethods([ + [ 'Mailbox/set', { + create => { + "1" => { + name => "foo", + parentId => "$schedid" + } + } + }, "R1"] + ]); + $self->assert_not_null($res->[0][1]->{notCreated}{1}); + + xlog $self, "Try to destroy Scheduled mailbox"; + $res = $jmap->CallMethods([ + [ 'Mailbox/set', { + destroy => [ "$schedid" ] + }, "R1"] + ]); + $self->assert_not_null($res->[0][1]->{notDestroyed}{$schedid}); + + xlog $self, "Create 2 draft emails and one in the Scheduled mailbox"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'm1' => { + mailboxIds => { + $draftsid => JSON::true, + }, + keywords => { + '$draft' => JSON::true, + }, + from => [{ + name => '', email => 'cassandane@local' + }], + to => [{ + name => '', email => 'foo@local' + }], + subject => 'foo', + }, + 'm2' => { + mailboxIds => { + $draftsid => JSON::true, + }, + keywords => { + '$draft' => JSON::true, + }, + from => [{ + name => '', email => 'cassandane@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'bar', + }, + 'm3' => { + mailboxIds => { + $schedid => JSON::true, + }, + from => [{ + name => '', email => 'cassandane@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'fail', + }, + }, + }, 'R1'], + ]); + my $emailid1 = $res->[0][1]->{created}{m1}{id}; + my $emailid2 = $res->[0][1]->{created}{m2}{id}; + $self->assert_not_null($res->[0][1]->{notCreated}{m3}); + + xlog $self, "Create 2 email submissions"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid1, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [ + { + email => 'rcpt1@localhost', + }], + }, + onSend => { + moveToMailboxId => $sentid, + setKeywords => { '$Sent' => $JSON::true }, + } + }, + '2' => { + identityId => $identityid, + emailId => $emailid2, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [ + { + email => 'rcpt2@localhost', + }], + }, + onSend => { + moveToMailboxId => $sentid, + setKeywords => { '$Sent' => $JSON::true }, + } + } + }, + onSuccessUpdateEmail => { + '#1' => { + "mailboxIds/$draftsid" => JSON::null, + "mailboxIds/$schedid" => $JSON::true, + 'keywords/$Draft' => JSON::null + }, + '#2' => { + "mailboxIds/$draftsid" => JSON::null, + "mailboxIds/$schedid" => $JSON::true, + 'keywords/$Draft' => JSON::null + } + } + }, "R1" ], + [ "Email/get", { + ids => ["$emailid1"], + properties => ["mailboxIds", "keywords"], + }, "R2"], + ] ); + + xlog $self, "Check create and onSuccessUpdateEmail results"; + my $msgsubid1 = $res->[0][1]->{created}{1}{id}; + my $msgsubid2 = $res->[0][1]->{created}{2}{id}; + $self->assert_str_equals('pending', $res->[0][1]->{created}{1}{undoStatus}); + + $self->assert_equals(JSON::null, $res->[1][1]->{updated}{emailid1}); + + $self->assert_equals(JSON::true, + $res->[2][1]->{list}[0]->{mailboxIds}{$schedid}); + $self->assert_null($res->[2][1]->{list}[0]->{mailboxIds}{$draftsid}); + + xlog $self, "Verify 2 events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(2, scalar @$alarmdata); + + xlog $self, "Try to destroy email in Scheduled mailbox"; + $res = $jmap->CallMethods([ + [ 'Email/set', { + destroy => [ "$emailid1" ] + }, "R1"] + ]); + $self->assert_not_null($res->[0][1]->{notDestroyed}{$emailid1}); + + xlog $self, "Cancel email submission 2"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/set', { + update => { + $msgsubid2 => { + undoStatus => 'canceled', + } + }, + onSuccessUpdateEmail => { + $msgsubid2 => { + mailboxIds => { + "$draftsid" => JSON::true + }, + keywords => { + '$Draft' => JSON::true + } + } + } + }, "R1" ], + [ "Email/get", { + ids => ["$emailid2"], + properties => ["mailboxIds", "keywords"], + }, "R2"], + ] ); + + xlog $self, "Check update and onSuccessUpdateEmail results"; + $self->assert_not_null($res->[0][1]->{updated}{$msgsubid2}); + + $self->assert_equals(JSON::null, $res->[1][1]->{updated}{emailid2}); + + $self->assert_equals(JSON::true, + $res->[2][1]->{list}[0]->{keywords}{'$draft'}); + $self->assert_equals(JSON::true, + $res->[2][1]->{list}[0]->{mailboxIds}{$draftsid}); + $self->assert_null($res->[2][1]->{list}[0]->{mailboxIds}{$schedid}); + + + xlog $self, "Destroy canceled email submission 2 (now in Drafts) "; + $res = $jmap->CallMethods( [ [ 'Email/set', { + destroy => [ $emailid2 ], + }, "R1" ] ] ); + $self->assert_str_equals($emailid2, $res->[0][1]->{destroyed}[0]); + + + xlog $self, "Verify an event was removed from the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + xlog $self, "Trigger delivery of email submission"; + my $now = DateTime->now(); + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60 ); + + xlog $self, "Check onSend results"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/get', { + ids => [ $msgsubid1 ] + }, "R1"], + [ "Email/get", { + ids => ["$emailid1"], + properties => ["mailboxIds", "keywords"], + }, "R2"], + ] ); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_str_equals('final', $res->[0][1]->{list}[0]->{undoStatus}); + + $self->assert_equals(JSON::true, + $res->[1][1]->{list}[0]->{mailboxIds}{$sentid}); + $self->assert_null($res->[1][1]->{list}[0]->{mailboxIds}{$schedid}); + + $self->assert_equals(JSON::true, + $res->[1][1]->{list}[0]->{keywords}{'$sent'}); + $self->assert_equals(JSON::null, + $res->[1][1]->{list}[0]->{keywords}{'$draft'}); + + xlog $self, "Verify no events left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_fail b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_fail new file mode 100644 index 0000000000..1e989c9e4c --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_fail @@ -0,0 +1,173 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_scheduled_send_fail + :min_version_3_7 :needs_component_jmap :needs_component_calalarmd :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # created and onSend properties + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "Create Drafts, Scheduled, and Sent mailboxes"; + my $res = $jmap->CallMethods([ + [ 'Identity/get', {}, "R0" ], + [ 'Mailbox/set', { + create => { + "1" => { + name => "Drafts", + role => "drafts" + }, + "2" => { + name => "Scheduled", + role => "scheduled" + }, + "3" => { + name => "Sent", + role => "sent" + } + } + }, "R1"], + ]); + my $identityid = $res->[0][1]->{list}[0]->{id}; + my $draftsid = $res->[1][1]{created}{"1"}{id}; + my $schedid = $res->[1][1]{created}{"2"}{id}; + my $sentid = $res->[1][1]{created}{"3"}{id}; + + xlog $self, "Create draft email"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'm1' => { + mailboxIds => { + $draftsid => JSON::true, + }, + keywords => { + '$draft' => JSON::true, + }, + from => [{ + name => '', email => 'cassandane@local' + }], + to => [{ + name => '', email => 'foo@local' + }], + subject => 'foo', + }, + }, + }, 'R1'], + ]); + my $emailid1 = $res->[0][1]->{created}{m1}{id}; + + xlog $self, "Create email submission"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid1, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [ + { + email => 'rcpt1@localhost', + }], + }, + onSend => { + moveToMailboxId => $sentid, + setKeywords => { '$Sent' => $JSON::true }, + } + } + }, + onSuccessUpdateEmail => { + '#1' => { + "mailboxIds/$draftsid" => JSON::null, + "mailboxIds/$schedid" => $JSON::true, + 'keywords/$Draft' => JSON::null + } + } + }, "R1" ], + [ "Email/get", { + ids => ["$emailid1"], + properties => ["mailboxIds", "keywords"], + }, "R2"], + ] ); + + xlog $self, "Check create and onSuccessUpdateEmail results"; + my $msgsubid1 = $res->[0][1]->{created}{1}{id}; + $self->assert_str_equals('pending', $res->[0][1]->{created}{1}{undoStatus}); + + $self->assert_equals(JSON::null, $res->[1][1]->{updated}{emailid1}); + + $self->assert_equals(JSON::true, + $res->[2][1]->{list}[0]->{mailboxIds}{$schedid}); + $self->assert_null($res->[2][1]->{list}[0]->{mailboxIds}{$draftsid}); + + xlog $self, "Verify 1 event was added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + xlog $self, "Set up a permanent SMTP failre"; + $self->{instance}->set_smtpd({ begin_data => ["554", "5.3.0 [jmapError:forbiddenToSend] try later"] }); + + xlog $self, "Trigger delivery of email submission"; + my $now = DateTime->now(); + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60 ); + + xlog $self, "Make sure message was moved back to Drafts"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/get', { + ids => [ $msgsubid1 ] + }, "R1"], + [ "Email/get", { + ids => ["$emailid1"], + properties => ["mailboxIds", "keywords"], + }, "R2"], + ] ); + + $self->assert_equals(JSON::true, + $res->[1][1]->{list}[0]->{mailboxIds}{$draftsid}); + $self->assert_null($res->[1][1]->{list}[0]->{mailboxIds}{$schedid}); + + $self->assert_equals(JSON::true, + $res->[1][1]->{list}[0]->{keywords}{'$draft'}); + $self->assert_equals(JSON::null, + $res->[1][1]->{list}[0]->{keywords}{'$sent'}); + + xlog $self, "Verify 1 event left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + xlog $self, "Trigger delivery of unscheduled notification"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 360 ); + + xlog $self, "Verify notification was sent"; + my $data = $self->{instance}->getnotify(); + my $unscheduled; + foreach (@$data) { + my $event = decode_json($_->{MESSAGE}); + if ($event->{event} eq "MessagesUnscheduled") { + $unscheduled = $event; + } + } + $self->assert_not_null($unscheduled); + $self->assert_str_equals("cassandane", $unscheduled->{userId}); + $self->assert_num_equals(1, $unscheduled->{count}); + + xlog $self, "Verify no events left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_no_move b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_no_move new file mode 100644 index 0000000000..6f7dcdb58f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_no_move @@ -0,0 +1,144 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_scheduled_send_no_move + :min_version_3_7 :needs_component_jmap :needs_component_calalarmd :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # created and onSend properties + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "Create Drafts, Scheduled, and Sent mailboxes"; + my $res = $jmap->CallMethods([ + [ 'Identity/get', {}, "R0" ], + [ 'Mailbox/set', { + create => { + "1" => { + name => "Drafts", + role => "drafts" + }, + "2" => { + name => "Scheduled", + role => "scheduled" + }, + "3" => { + name => "Sent", + role => "sent" + } + } + }, "R1"], + ]); + my $identityid = $res->[0][1]->{list}[0]->{id}; + my $draftsid = $res->[1][1]{created}{"1"}{id}; + my $schedid = $res->[1][1]{created}{"2"}{id}; + my $sentid = $res->[1][1]{created}{"3"}{id}; + + xlog $self, "Create draft email"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'm1' => { + mailboxIds => { + $draftsid => JSON::true, + }, + keywords => { + '$draft' => JSON::true, + }, + from => [{ + name => '', email => 'cassandane@local' + }], + to => [{ + name => '', email => 'foo@local' + }], + subject => 'foo', + }, + }, + }, 'R1'], + ]); + my $emailid1 = $res->[0][1]->{created}{m1}{id}; + + xlog $self, "Create email submission"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid1, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [ + { + email => 'rcpt1@localhost', + }], + }, + onSend => { + moveToMailboxId => JSON::null, + } + } + }, + onSuccessUpdateEmail => { + '#1' => { + "mailboxIds/$draftsid" => JSON::null, + "mailboxIds/$schedid" => $JSON::true, + 'keywords/$Draft' => JSON::null + } + } + }, "R1" ], + [ "Email/get", { + ids => ["$emailid1"], + properties => ["mailboxIds", "keywords"], + }, "R2"], + ] ); + + xlog $self, "Check create and onSuccessUpdateEmail results"; + my $msgsubid1 = $res->[0][1]->{created}{1}{id}; + $self->assert_str_equals('pending', $res->[0][1]->{created}{1}{undoStatus}); + + $self->assert_equals(JSON::null, $res->[1][1]->{updated}{emailid1}); + + $self->assert_equals(JSON::true, + $res->[2][1]->{list}[0]->{mailboxIds}{$schedid}); + $self->assert_null($res->[2][1]->{list}[0]->{mailboxIds}{$draftsid}); + + xlog $self, "Verify 1 event was added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + xlog $self, "Trigger delivery of email submission"; + my $now = DateTime->now(); + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60 ); + + xlog $self, "Check onSend results"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/get', { + ids => [ $msgsubid1 ] + }, "R1"], + [ "Email/get", { + ids => [ $emailid1 ], + properties => ["mailboxIds"], + }, "R2"], + ] ); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_str_equals('final', $res->[0][1]->{list}[0]->{undoStatus}); + + $self->assert_num_equals(1, scalar @{$res->[1][1]->{notFound}}); + $self->assert_equals($emailid1, $res->[1][1]->{notFound}[0]); + + xlog $self, "Verify no events left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_null_onsend b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_null_onsend new file mode 100644 index 0000000000..19b156404a --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_scheduled_send_null_onsend @@ -0,0 +1,135 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_scheduled_send_null_onsend + :min_version_3_7 :needs_component_jmap :needs_component_calalarmd :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # created and onSend properties + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + # clean notification cache + $self->{instance}->getnotify(); + + xlog $self, "Create Drafts, Scheduled, and Sent mailboxes"; + my $res = $jmap->CallMethods([ + [ 'Identity/get', {}, "R0" ], + [ 'Mailbox/set', { + create => { + "1" => { + name => "Drafts", + role => "drafts" + }, + "2" => { + name => "Scheduled", + role => "scheduled" + }, + "3" => { + name => "Sent", + role => "sent" + } + } + }, "R1"], + ]); + my $identityid = $res->[0][1]->{list}[0]->{id}; + my $draftsid = $res->[1][1]{created}{"1"}{id}; + my $schedid = $res->[1][1]{created}{"2"}{id}; + my $sentid = $res->[1][1]{created}{"3"}{id}; + + xlog $self, "Create draft email"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'm1' => { + mailboxIds => { + $draftsid => JSON::true, + }, + keywords => { + '$draft' => JSON::true, + }, + from => [{ + name => '', email => 'cassandane@local' + }], + to => [{ + name => '', email => 'foo@local' + }], + subject => 'foo', + }, + }, + }, 'R1'], + ]); + my $emailid1 = $res->[0][1]->{created}{m1}{id}; + + xlog $self, "Create email submission"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/set', { + create => { + '1' => { + emailId => $emailid1, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [ + { + email => 'rcpt1@localhost', + }], + }, + onSend => JSON::null + } + }, + onSuccessUpdateEmail => { + '#1' => { + "mailboxIds/$draftsid" => JSON::null, + "mailboxIds/$sentid" => $JSON::true, + 'keywords/$Draft' => JSON::null + } + } + }, "R1" ], + [ "Email/get", { + ids => ["$emailid1"], + properties => ["mailboxIds", "keywords"], + }, "R2"], + ] ); + + xlog $self, "Check create and onSuccessUpdateEmail results"; + my $msgsubid1 = $res->[0][1]->{created}{1}{id}; + $self->assert_str_equals('pending', $res->[0][1]->{created}{1}{undoStatus}); + + $self->assert_equals(JSON::null, $res->[1][1]->{updated}{emailid1}); + + $self->assert_equals(JSON::true, + $res->[2][1]->{list}[0]->{mailboxIds}{$sentid}); + $self->assert_null($res->[2][1]->{list}[0]->{mailboxIds}{$draftsid}); + $self->assert_null($res->[1][1]->{list}[0]->{mailboxIds}{$schedid}); + + xlog $self, "Verify 1 event was added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + xlog $self, "Trigger delivery of email submission"; + my $now = DateTime->now(); + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $now->epoch() + 60 ); + + xlog $self, "Check results"; + $res = $jmap->CallMethods( [ + [ 'EmailSubmission/get', { + ids => [ $msgsubid1 ] + }, "R1"], + ] ); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_str_equals('final', $res->[0][1]->{list}[0]->{undoStatus}); + + xlog $self, "Verify no events left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set new file mode 100644 index 0000000000..2f8626e197 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + } + } + }, "R1" ] ] ); + my $msgsubid = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($msgsubid); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); + + xlog $self, "get email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/get', { + ids => [ $msgsubid ], + }, "R1" ] ] ); + $self->assert_str_equals($msgsubid, $res->[0][1]->{list}[0]{id}); + + xlog $self, "update email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + update => { + $msgsubid => { + undoStatus => 'canceled', + } + } + }, "R1" ] ] ); + $self->assert_str_equals('cannotUnsend', $res->[0][1]->{notUpdated}{$msgsubid}{type}); + + xlog $self, "destroy email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + destroy => [ $msgsubid ], + }, "R1" ] ] ); + $self->assert_str_equals($msgsubid, $res->[0][1]->{destroyed}[0]); + + xlog $self, "make sure #jmapsubmission folder isn't visible via IMAP"; + my $talk = $self->{store}->get_client(); + my @list = $talk->list('', '*'); + $self->assert_num_equals(0, scalar grep { $_->[2] eq 'INBOX.#jmapsubmission' } @list); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_bad_futurerelease b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_bad_futurerelease new file mode 100644 index 0000000000..2b5a879596 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_bad_futurerelease @@ -0,0 +1,159 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_bad_futurerelease + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwithCRLF\r\n") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submissions"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => JSON::null + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + }, + '2' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "" + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + }, + '3' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => " " + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + }, + '4' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30a" + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + }, + '5' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holduntil" => undef + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + }, + '6' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holduntil" => [] + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + }, + '7' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holduntil" => "" + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + } + } + }, "R1" ] ] ); + my $errType = $res->[0][1]->{notCreated}{1}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $errType = $res->[0][1]->{notCreated}{2}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $errType = $res->[0][1]->{notCreated}{3}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $errType = $res->[0][1]->{notCreated}{4}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $errType = $res->[0][1]->{notCreated}{5}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $errType = $res->[0][1]->{notCreated}{6}{type}; + $self->assert_str_equals("invalidProperties", $errType); + $errType = $res->[0][1]->{notCreated}{7}{type}; + $self->assert_str_equals("invalidProperties", $errType); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_creationid b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_creationid new file mode 100644 index 0000000000..6c61b17b14 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_creationid @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_creationid + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityId = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityId); + + xlog $self, "create mailboxes"; + $imap->create("INBOX.A") or die; + $imap->create("INBOX.B") or die; + $res = $jmap->CallMethods([ + ['Mailbox/get', { properties => ['name'], }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdA = $mboxByName{A}->{id}; + my $mboxIdB = $mboxByName{B}->{id}; + + xlog $self, "create, send and update email"; + $res = $jmap->CallMethods([ + ['Email/set', { + create => { + 'm1' => { + mailboxIds => { + $mboxIdA => JSON::true, + }, + from => [{ + name => '', email => 'foo@local' + }], + to => [{ + name => '', email => 'bar@local' + }], + subject => 'hello', + bodyStructure => { + type => 'text/plain', + partId => 'part1', + }, + bodyValues => { + part1 => { + value => 'world', + } + }, + }, + }, + }, 'R1'], + [ 'EmailSubmission/set', { + create => { + 's1' => { + identityId => $identityId, + emailId => '#m1', + } + }, + onSuccessUpdateEmail => { + '#s1' => { + mailboxIds => { + $mboxIdB => JSON::true, + }, + }, + }, + }, 'R2' ], + [ 'Email/get', { + ids => ['#m1'], + properties => ['mailboxIds'], + }, 'R3'], + ]); + my $emailId = $res->[0][1]->{created}{m1}{id}; + $self->assert_not_null($emailId); + my $msgSubId = $res->[1][1]->{created}{s1}{id}; + $self->assert_not_null($msgSubId); + $self->assert(exists $res->[2][1]{updated}{$emailId}); + $self->assert_num_equals(1, scalar keys %{$res->[3][1]{list}[0]{mailboxIds}}); + $self->assert(exists $res->[3][1]{list}[0]{mailboxIds}{$mboxIdB}); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_fail_some_recipients b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_fail_some_recipients new file mode 100644 index 0000000000..dbc323832f --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_fail_some_recipients @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_fail_some_recipients + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwith 10 recipients\r\n") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }, { + email => 'rcpt3@fail.to.deliver', + }, { + email => 'rcpt4@localhost', + }, { + email => 'rcpt5@fail.to.deliver', + }, { + email => 'rcpt6@fail.to.deliver', + }, { + email => 'rcpt7@localhost', + }, { + email => 'rcpt8@localhost', + }, { + email => 'rcpt9@fail.to.deliver', + }, { + email => 'rcpt10@localhost', + }], + }, + } + } + }, "R1" ] ] ); + my $errType = $res->[0][1]->{notCreated}{1}{type}; + $self->assert_str_equals("invalidRecipients", $errType); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_futurerelease b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_futurerelease new file mode 100644 index 0000000000..28e0f75d80 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_futurerelease @@ -0,0 +1,158 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_futurerelease + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwithCRLF\r\n") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submissions"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + }, + '2' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + } + } + }, "R1" ] ] ); + my $msgsubid1 = $res->[0][1]->{created}{1}{id}; + my $msgsubid2 = $res->[0][1]->{created}{2}{id}; + $self->assert_not_null($msgsubid1); + $self->assert_not_null($msgsubid2); + + xlog $self, "event were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(2, scalar @$alarmdata); + + $res = $jmap->CallMethods([['EmailSubmission/get', { ids => undef }, "R2"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + $self->assert_str_equals('pending', $res->[0][1]->{list}[0]->{undoStatus}); + $self->assert_str_equals('pending', $res->[0][1]->{list}[1]->{undoStatus}); + my $state = $res->[0][1]->{state}; + + xlog $self, "cancel first email submission"; + $res = $jmap->CallMethods([ + ['EmailSubmission/set', { + update => { $msgsubid1 => { + "undoStatus" => "canceled", + }}, + }, 'R3'], + ]); + + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + xlog $self, "one event left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + $res = $jmap->CallMethods([['EmailSubmission/get', { ids => [ $msgsubid1 ] }, "R4"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + $self->assert_str_equals('canceled', $res->[0][1]->{list}[0]->{undoStatus}); + + xlog $self, "destroy first email submission"; + $res = $jmap->CallMethods([ + ['EmailSubmission/set', { + destroy => [ $msgsubid1 ] + }, 'R5'], + ]); + + $self->assert_not_null($res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{notDestroyed}); + + xlog $self, "one event left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + $res = $jmap->CallMethods([['EmailSubmission/get', { ids => undef }, "R6"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + + xlog $self, "set up a send block"; + $self->{instance}->set_smtpd({ begin_data => ["451", "4.3.0 [jmapError:forbiddenToSend] try later"] }); + + xlog $self, "attempt delivery of the second email"; + my $now = DateTime->now(); + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 60 ); + + xlog $self, "still pending"; + $res = $jmap->CallMethods([['EmailSubmission/get', { ids => [ $msgsubid2 ] }, "R7"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + $self->assert_str_equals('pending', $res->[0][1]->{list}[0]->{undoStatus}); + + xlog $self, "one event left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + xlog $self, "clear the send block"; + $self->{instance}->set_smtpd(); + + xlog $self, "trigger delivery of second email submission"; + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 600 ); + + $res = $jmap->CallMethods([['EmailSubmission/get', { ids => [ $msgsubid2 ] }, "R7"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + $self->assert_str_equals('final', $res->[0][1]->{list}[0]->{undoStatus}); + + xlog $self, "no events left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); + + xlog $self, "attempt to cancel second email submission (should fail)"; + $res = $jmap->CallMethods([ + ['EmailSubmission/set', { + update => { $msgsubid2 => { + "undoStatus" => "canceled", + }}, + }, 'R8'], + ]); + + $self->assert_null($res->[0][1]{updated}); + $self->assert_not_null($res->[0][1]{notUpdated}); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_issue2285 b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_issue2285 new file mode 100644 index 0000000000..89f2123bd7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_issue2285 @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_issue2285 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + my $inboxid = $self->getinbox()->{id}; + + xlog $self, "Create email"; + $res = $jmap->CallMethods([ + [ 'Email/set', { + create => { + 'k40' => { + 'bcc' => undef, + 'cc' => undef, + 'attachments' => undef, + 'subject' => 'zlskdjgh', + 'keywords' => { + '$Seen' => JSON::true, + '$Draft' => JSON::true + }, + textBody => [{partId => '1'}], + bodyValues => { '1' => { value => 'lsdkgjh' }}, + 'to' => [ + { + 'email' => 'foo@bar.com', + 'name' => '' + } + ], + 'from' => [ + { + 'email' => 'fooalias1@robmtest.vm', + 'name' => 'some name' + } + ], + 'receivedAt' => '2018-03-06T03:49:04Z', + 'mailboxIds' => { + $inboxid => JSON::true, + }, + } + } + }, "R1" ], + [ 'EmailSubmission/set', { + create => { + 'k41' => { + identityId => $identityid, + emailId => '#k40', + envelope => undef, + }, + }, + onSuccessDestroyEmail => [ '#k41' ], + }, "R2" ] ] ); + $self->assert_str_equals('EmailSubmission/set', $res->[1][0]); + $self->assert_not_null($res->[1][1]->{created}{'k41'}{id}); + $self->assert_str_equals('R2', $res->[1][2]); + $self->assert_str_equals('Email/set', $res->[2][0]); + $self->assert_not_null($res->[2][1]->{destroyed}[0]); + $self->assert_str_equals('R2', $res->[2][2]); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_message_too_large b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_message_too_large new file mode 100644 index 0000000000..e0e27d1c46 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_message_too_large @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_message_too_large + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + my $x = "x"; + $self->make_message("foo", body => "an email\r\nwith 10k+ octet body\r\n" . $x x 10000) or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }], + }, + } + } + }, "R1" ] ] ); + my $errType = $res->[0][1]->{notCreated}{1}{type}; + $self->assert_str_equals("tooLarge", $errType); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_smtp_rejection b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_smtp_rejection new file mode 100644 index 0000000000..041e865112 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_smtp_rejection @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_smtp_rejection + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwith 11 recipients\r\n") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + $self->{instance}->set_smtpd({ begin_data => ["554", "5.3.0 [jmapError:forbiddenToSend] bad egg"] }); + + xlog $self, "create email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }], + }, + } + } + }, "R1" ] ] ); + my $errType = $res->[0][1]->{notCreated}{1}{type}; + $self->assert_str_equals("forbiddenToSend", $errType); + $self->assert_str_equals("bad egg", $res->[0][1]->{notCreated}{1}{description}); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_too_many_recipients b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_too_many_recipients new file mode 100644 index 0000000000..db83a06c78 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_too_many_recipients @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_too_many_recipients + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwith 11 recipients\r\n") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }, { + email => 'rcpt3@localhost', + }, { + email => 'rcpt4@localhost', + }, { + email => 'rcpt5@localhost', + }, { + email => 'rcpt6@localhost', + }, { + email => 'rcpt7@localhost', + }, { + email => 'rcpt8@localhost', + }, { + email => 'rcpt9@localhost', + }, { + email => 'rcpt10@localhost', + }, { + email => 'rcpt11@localhost', + }], + }, + } + } + }, "R1" ] ] ); + my $errType = $res->[0][1]->{notCreated}{1}{type}; + $self->assert_str_equals("tooManyRecipients", $errType); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_with_envelope b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_with_envelope new file mode 100644 index 0000000000..d7634e2f06 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/emailsubmission_set_with_envelope @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_emailsubmission_set_with_envelope + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwithCRLF\r\n") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submission"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + } + } + }, "R1" ] ] ); + my $msgsubid = $res->[0][1]->{created}{1}{id}; + $self->assert_not_null($msgsubid); + + xlog $self, "no events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); +} diff --git a/cassandane/tiny-tests/JMAPEmailSubmission/replication_emailsubmission_set_futurerelease b/cassandane/tiny-tests/JMAPEmailSubmission/replication_emailsubmission_set_futurerelease new file mode 100644 index 0000000000..97801b02a7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPEmailSubmission/replication_emailsubmission_set_futurerelease @@ -0,0 +1,146 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication_emailsubmission_set_futurerelease + :min_version_3_1 :needs_component_jmap :needs_component_calalarmd +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods( [ [ 'Identity/get', {}, "R1" ] ] ); + my $identityid = $res->[0][1]->{list}[0]->{id}; + $self->assert_not_null($identityid); + + xlog $self, "Generate an email via IMAP"; + $self->make_message("foo", body => "an email\r\nwithCRLF\r\n") or die; + + xlog $self, "get email id"; + $res = $jmap->CallMethods( [ [ 'Email/query', {}, "R1" ] ] ); + my $emailid = $res->[0][1]->{ids}[0]; + + xlog $self, "create email submissions"; + $res = $jmap->CallMethods( [ [ 'EmailSubmission/set', { + create => { + '1' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + }, + '2' => { + identityId => $identityid, + emailId => $emailid, + envelope => { + mailFrom => { + email => 'from@localhost', + parameters => { + "holdfor" => "30", + } + }, + rcptTo => [{ + email => 'rcpt1@localhost', + }, { + email => 'rcpt2@localhost', + }], + }, + } + } + }, "R1" ] ] ); + my $msgsubid1 = $res->[0][1]->{created}{1}{id}; + my $msgsubid2 = $res->[0][1]->{created}{2}{id}; + $self->assert_not_null($msgsubid1); + $self->assert_not_null($msgsubid2); + + xlog $self, "events were added to the alarmdb"; + my $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(2, scalar @$alarmdata); + + xlog $self, "events aren't in replica alarmdb yet"; + my $replicadata = $self->{replica}->getalarmdb(); + $self->assert_num_equals(0, scalar @$replicadata); + + $self->run_replication(); + + xlog $self, "events are still in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(2, scalar @$alarmdata); + + xlog $self, "events are now in replica alarmdb"; + $replicadata = $self->{replica}->getalarmdb(); + $self->assert_num_equals(2, scalar @$replicadata); + + xlog $self, "cancel first email submission"; + $res = $jmap->CallMethods([ + ['EmailSubmission/set', { + update => { $msgsubid1 => { + "undoStatus" => "canceled", + }}, + }, 'R3'], + ]); + + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + + xlog $self, "one event left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + $self->run_replication(); + + xlog $self, "one event left in the alarmdb"; + $replicadata = $self->{replica}->getalarmdb(); + $self->assert_num_equals(1, scalar @$replicadata); + + $res = $jmap->CallMethods([['EmailSubmission/get', { ids => [ $msgsubid1 ] }, "R4"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + $self->assert_str_equals('canceled', $res->[0][1]->{list}[0]->{undoStatus}); + + xlog $self, "destroy first email submission"; + $res = $jmap->CallMethods([ + ['EmailSubmission/set', { + destroy => [ $msgsubid1 ] + }, 'R5'], + ]); + + $self->assert_not_null($res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{notDestroyed}); + + xlog $self, "one event left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(1, scalar @$alarmdata); + + $res = $jmap->CallMethods([['EmailSubmission/get', { ids => undef }, "R6"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + + xlog $self, "trigger delivery of second email submission"; + my $now = DateTime->now(); + $self->{instance}->run_command({ cyrus => 1 }, 'calalarmd', '-t' => $now->epoch() + 120 ); + + $res = $jmap->CallMethods([['EmailSubmission/get', { ids => [ $msgsubid2 ] }, "R7"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{list}}); + $self->assert_deep_equals([], $res->[0][1]->{notFound}); + $self->assert_str_equals('final', $res->[0][1]->{list}[0]->{undoStatus}); + + xlog $self, "no events left in the alarmdb"; + $alarmdata = $self->{instance}->getalarmdb(); + $self->assert_num_equals(0, scalar @$alarmdata); + + $self->run_replication(); + + xlog $self, "no replica events left in the alarmdb"; + $replicadata = $self->{replica}->getalarmdb(); + $self->assert_num_equals(0, scalar @$replicadata); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/cyr_237 b/cassandane/tiny-tests/JMAPMailbox/cyr_237 new file mode 100644 index 0000000000..e8988e8789 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/cyr_237 @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_cyr_237 + :min_version_3_3 :needs_component_jmap :NoAltNameSpace :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + xlog $self, "Create \\Scheduled mailbox"; + my $res = $jmap->CallMethods([ + [ 'Mailbox/set', { + create => { + "1" => { + name => "Scheduled", + role => "scheduled" + } + } + }, "R1"], + ]); + + xlog $self, "Upload something (to create #jmap)"; + my $data = $jmap->Upload("some text", "text/plain"); + + my $acl = $admin->getacl("user.cassandane.#jmap"); + my %map = @$acl; + $self->assert_str_equals('lrswipkxtecdan', $map{cassandane}); + $self->assert_null($map{sharee}); + $self->assert_null($map{'-anyone'}); + + my $inboxId = $self->getinbox()->{id}; + $self->assert_not_null($inboxId); + + xlog $self, "Share INBOX"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $inboxId => { + shareWith => { + sharee => { + mayRead => JSON::true, + mayWrite => JSON::true, + }, + }, + }, + }, + }, 'R2'] + ], $using); + + $acl = $admin->getacl("user.cassandane.#jmap"); + %map = @$acl; + $self->assert_str_equals('lrswipkxtecdan', $map{cassandane}); + $self->assert_str_equals('lrswitedn', $map{sharee}); + $self->assert_null($map{'-anyone'}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_changes b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes new file mode 100644 index 0000000000..5f80d0477f --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes @@ -0,0 +1,155 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_changes + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $state; + my $res; + my %m; + my $inbox; + my $foo; + my $drafts; + + xlog $self, "get mailbox list"; + $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $inbox = $m{"Inbox"}->{id}; + $self->assert_not_null($inbox); + + xlog $self, "get mailbox updates (expect error)"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => 0 }, "R1"]]); + $self->assert_str_equals("cannotCalculateChanges", $res->[0][1]->{type}); + + xlog $self, "get mailbox updates (expect no changes)"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{updatedProperties}); + + xlog $self, "create mailbox via IMAP"; + $imaptalk->create("INBOX.foo") + or die "Cannot create mailbox INBOX.foo: $@"; + + xlog $self, "get mailbox list"; + $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $foo = $m{"foo"}->{id}; + $self->assert_not_null($foo); + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals($foo, $res->[0][1]{created}[0]); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{updatedProperties}); + $state = $res->[0][1]->{newState}; + + xlog $self, "create drafts mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $drafts = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($drafts); + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + $self->assert_str_equals($drafts, $res->[0][1]{created}[0]); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{updatedProperties}); + $state = $res->[0][1]->{newState}; + + xlog $self, "rename mailbox foo to bar"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { update => { $foo => { + name => "bar", + sortOrder => 20 + }}}, "R1"] + ]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{updated}}); + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($foo, $res->[0][1]{updated}[0]); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{updatedProperties}); + $state = $res->[0][1]->{newState}; + + xlog $self, "delete mailbox bar"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [ $foo ], + }, "R1"] + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "rename mailbox drafts to stfard"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { $drafts => { name => "stfard" } }, + }, "R1"] + ]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{updated}}); + + xlog $self, "get mailbox updates, limit to 1"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state, maxChanges => 1 }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::true, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_str_equals($foo, $res->[0][1]{destroyed}[0]); + $self->assert_null($res->[0][1]{updatedProperties}); + $state = $res->[0][1]->{newState}; + + xlog $self, "get mailbox updates, limit to 1"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state, maxChanges => 1 }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_str_equals($drafts, $res->[0][1]{updated}[0]); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{updatedProperties}); + $state = $res->[0][1]->{newState}; + + xlog $self, "get mailbox updates (expect no changes)"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{updatedProperties}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_counts b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_counts new file mode 100644 index 0000000000..246ac4cc4d --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_counts @@ -0,0 +1,128 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_changes_counts + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog $self, "create drafts mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "drafts", + parentId => undef, + role => "drafts" + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_not_null($res->[0][1]{created}); + my $mboxid = $res->[0][1]{created}{"1"}{id}; + my $state = $res->[0][1]{newState}; + + my $draft = { + mailboxIds => { $mboxid => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + ], + subject => "Memo", + textBody => [{partId=>'1'}], + bodyValues => { 1 => { value => "foo" }}, + keywords => { + '$draft' => JSON::true, + }, + }; + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $state = $res->[0][1]{newState}; + + xlog $self, "Create a draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + my $msgid = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "update email"; + $res = $jmap->CallMethods([['Email/set', { + update => { $msgid => { + keywords => { + '$draft' => JSON::true, + '$seen' => JSON::true + } + } + } + }, "R1"]]); + $self->assert(exists $res->[0][1]->{updated}{$msgid}); + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updatedProperties}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_not_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]{newState}; + + xlog $self, "update mailbox"; + $res = $jmap->CallMethods([['Mailbox/set', { update => { $mboxid => { name => "bar" }}}, "R1"]]); + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_null($res->[0][1]{updatedProperties}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_not_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]{newState}; + + xlog $self, "update email"; + $res = $jmap->CallMethods([['Email/set', { update => { $msgid => { 'keywords/$flagged' => JSON::true }} + }, "R1"]]); + $self->assert(exists $res->[0][1]->{updated}{$msgid}); + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updatedProperties}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_not_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]{newState}; + + xlog $self, "update mailbox"; + $res = $jmap->CallMethods([['Mailbox/set', { update => { $mboxid => { name => "baz" }}}, "R1"]]); + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_null($res->[0][1]{updatedProperties}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_not_equals(0, scalar @{$res->[0][1]{updated}}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]{newState}; + + xlog $self, "get mailbox updates (expect no changes)"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]{newState}); + $self->assert_null($res->[0][1]{updatedProperties}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]{newState}; + + $draft->{subject} = "memo2"; + + xlog $self, "Create another draft"; + $res = $jmap->CallMethods([['Email/set', { create => { "1" => $draft }}, "R1"]]); + $msgid = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{updatedProperties}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_num_not_equals(0, scalar $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $state = $res->[0][1]{newState}; +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_notes b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_notes new file mode 100644 index 0000000000..881f3fc155 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_notes @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_changes_notes + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $state; + my $res; + my %m; + my $inbox; + + xlog $self, "get mailbox list"; + $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $inbox = $m{"Inbox"}->{id}; + $self->assert_not_null($inbox); + + # we need 'https://cyrusimap.org/ns/jmap/notes' capability + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/notes'; + $jmap->DefaultUsing(\@using); + + # force creation of notes mailbox prior to creating notes + $res = $jmap->CallMethods([ + ['Note/set', { + }, "R0"] + ]); + + xlog "create note"; + $res = $jmap->CallMethods([['Note/set', + { create => { "1" => {title => "foo"}, } }, + "R1"]]); + $self->assert_not_null($res); + + xlog $self, "get mailbox updates (expect no changes)"; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_equals(JSON::false, $res->[0][1]->{hasMoreChanges}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]{updatedProperties}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_on_thread_counts b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_on_thread_counts new file mode 100644 index 0000000000..d8af89e114 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_on_thread_counts @@ -0,0 +1,108 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_changes_on_thread_counts + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + $imap->uid(1); + + xlog "Set up mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { }, 'R1'], + ['Mailbox/set', { + create => { + "a" => { name => "a", parentId => undef }, + "b" => { name => "b", parentId => undef }, + }, + }, 'R2'], + ]); + my %ids = map { $_ => $res->[1][1]{created}{$_}{id} } + keys %{$res->[1][1]{created}}; + + xlog "Set up messages"; + my %raw = ( + A => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test A\r +EOF + B => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test B\r +EOF + C => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test C\r +EOF + D => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + ); + + # threads: + # T1: A B C + # T2: D + + xlog $self, "Set up all the emails in all the folders"; + $imap->append('INBOX.a', "(\\Seen)", $raw{A}) || die $@; + $imap->append('INBOX.a', "()", $raw{B}) || die $@; + $imap->append('INBOX.b', "(\\Seen)", $raw{C}) || die $@; + $imap->append('INBOX.a', "()", $raw{D}) || die $@; + + # expectation: + # A (a:1, seen) + # B (a:2, unseen) + # C (b:1, seen) + # D (a:3 unseen) + + my $predata = $jmap->CallMethods([ + ['Mailbox/get', { }, 'R1'], + ]); + + xlog $self, "mark thread seen"; + $imap->select("INBOX.a"); + $imap->store(2, "+flags", "\\Seen"); + + my $postdata = $jmap->CallMethods([ + ['Mailbox/changes', { sinceState => $predata->[0][1]{state} }, 'R1'], + ]); + + my %changed = map { $_ => 1 } @{$postdata->[0][1]{updated}}; + $self->assert_not_null($changed{$ids{a}}); + $self->assert_not_null($changed{$ids{b}}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_rename b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_rename new file mode 100644 index 0000000000..6faa2d3e2c --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_rename @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_changes_rename + :min_version_3_5 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + + $imap->create('INBOX.foo'); + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { }, 'R1'], + ]); + my $fooId; + if ($res->[0][1]{list}[0]{name} eq 'foo') { + $fooId = $res->[0][1]{list}[0]{id}; + } + else { + $fooId = $res->[0][1]{list}[1]{id}; + } + $self->assert_not_null($fooId); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + + $imap->create('INBOX.bar'); + + $res = $jmap->CallMethods([ + ['Mailbox/changes', { + sinceState => $state, + }, 'R1'], + ]); + my $barId = $res->[0][1]{created}[0]; + $self->assert_not_null($barId); + $state = $res->[0][1]{newState}; + $self->assert_not_null($state); + + + $imap->rename('INBOX.foo', 'INBOX.bar.foo'); + + $res = $jmap->CallMethods([ + ['Mailbox/changes', { + sinceState => $state, + }, 'R1'], + ]); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([$fooId], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_shared b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_shared new file mode 100644 index 0000000000..e562dd96e5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_changes_shared @@ -0,0 +1,118 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_changes_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Create user and share mailbox + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lrwkxd") or die; + + xlog $self, "get mailbox list"; + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => 'foo' }, "R1"]]); + my $state = $res->[0][1]->{state}; + $self->assert_not_null($state); + + xlog $self, "get mailbox updates (expect no changes)"; + $res = $jmap->CallMethods([['Mailbox/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_equals($state, $res->[0][1]->{newState}); + $self->assert_null($res->[0][1]->{updatedProperties}); + + xlog $self, "create mailbox box1 via IMAP"; + $admintalk->create("user.foo.box1") or die; + $admintalk->setacl("user.foo.box1", "cassandane", "lrwkxd") or die; + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{created}}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]->{updatedProperties}); + $state = $res->[0][1]->{newState}; + my $box1 = $res->[0][1]->{created}[0]; + + xlog $self, "destroy mailbox via JMAP"; + $res = $jmap->CallMethods([['Mailbox/set', { accountId => "foo", destroy => [ $box1 ] }, 'R1' ]]); + $self->assert_str_equals($box1, $res->[0][1]{destroyed}[0]); + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{destroyed}}); + $self->assert_str_equals($box1, $res->[0][1]->{destroyed}[0]); + $self->assert_null($res->[0][1]->{updatedProperties}); + $state = $res->[0][1]->{newState}; + + xlog $self, "create mailbox box2 via IMAP"; + $admintalk->create("user.foo.box2") or die; + $admintalk->setacl("user.foo.box2", "cassandane", "lrwkxinepd") or die; + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{created}}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_null($res->[0][1]->{updatedProperties}); + $state = $res->[0][1]->{newState}; + + my $box2 = $res->[0][1]->{created}[0]; + + xlog $self, "Create a draft"; + my $draft = { + mailboxIds => { $box2 => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ + { name => "Bugs Bunny", email => "bugs\@acme.local" }, + ], + subject => "Memo", + textBody => [{partId=>'1'}], + bodyValues => { 1 => { value => "foo" }}, + keywords => { + '$draft' => JSON::true, + }, + }; + $res = $jmap->CallMethods([['Email/set', { + accountId => 'foo', + create => { "1" => $draft } + }, "R1"]]); + my $msgid = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([$box2], $res->[0][1]{updated}); + $self->assert_deep_equals([], $res->[0][1]{destroyed}); + $self->assert_not_null($res->[0][1]->{updatedProperties}); + $state = $res->[0][1]->{newState}; + + xlog $self, "Remove lookup rights on box2"; + $admintalk->setacl("user.foo.box2", "cassandane", "") or die; + + xlog $self, "get mailbox updates"; + $res = $jmap->CallMethods([['Mailbox/changes', { accountId => 'foo', sinceState => $state }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{oldState}); + $self->assert_str_not_equals($state, $res->[0][1]->{newState}); + $self->assert_deep_equals([], $res->[0][1]{created}); + $self->assert_deep_equals([], $res->[0][1]{updated}); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{destroyed}}); + $self->assert_str_equals($box2, $res->[0][1]->{destroyed}[0]); + $self->assert_null($res->[0][1]->{updatedProperties}); + $state = $res->[0][1]->{newState}; + +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_counts b/cassandane/tiny-tests/JMAPMailbox/mailbox_counts new file mode 100644 index 0000000000..5b1cf2a83c --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_counts @@ -0,0 +1,270 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_counts + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + $imap->uid(1); + my ($maj, $min) = Cassandane::Instance->get_version(); + + xlog "Set up mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { }, 'R1'], + ['Mailbox/set', { + create => { + "a" => { name => "a", parentId => undef }, + "b" => { name => "b", parentId => undef }, + "trash" => { + name => "Trash", + parentId => undef, + role => "trash" + } + }, + }, 'R2'], + ]); + my %ids = map { $_ => $res->[1][1]{created}{$_}{id} } + keys %{$res->[1][1]{created}}; + + xlog "Append same message twice to inbox"; + my %raw = ( + A => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test A\r +EOF + B => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test B\r +EOF + C => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test C\r +EOF + D => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + E => <<"EOF", +From: \r +To: to\@local\r +Subject: test3\r +Message-Id: \r +In-Reply-To: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test E\r +EOF + F => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test F\r +EOF + G => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +In-Reply-To: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + ); + + # threads: + # T1: A B C + # T2: D + # T3: E + # T4: F G (in-reply-to E, but different subject) + + xlog $self, "Set up all the emails in all the folders"; + $imap->append('INBOX.a', "(\\Seen)", $raw{A}) || die $@; + $imap->append('INBOX.a', "()", $raw{A}) || die $@; + $imap->append('INBOX.a', "(\\Seen)", $raw{C}) || die $@; + $imap->append('INBOX.a', "(\\Seen)", $raw{D}) || die $@; + $imap->append('INBOX.a', "()", $raw{E}) || die $@; + $imap->append('INBOX.a', "(\\Seen)", $raw{F}) || die $@; + $imap->append('INBOX.b', "()", $raw{B}) || die $@; + $imap->append('INBOX.b', "(\\Seen)", $raw{C}) || die $@; + $imap->append('INBOX.b', "(\\Seen)", $raw{E}) || die $@; + $imap->append('INBOX.Trash', "(\\Seen)", $raw{G}) || die $@; + + # expectation: + # A (a:1, seen - a:2, unseen) == unseen + # B (b:1, unseen) + # C (a:3, seen - b:2, seen) + # D (a:4, seen) + # E (a:5, unseen - b:3, seen) == unseen + # F (a:6, seen) + # G (trash:1, seen) + + # T1 in (a,b) unseen + # T2 in a, seen + # T3 in (a,b) unseen + # T4 in (a,trash) seen + + if ($maj > 3 || ($maj == 3 && $min >= 6)) { + $self->_check_counts('Initial Test', + a => [ 5, 2, 4, 2 ], + b => [ 3, 2, 2, 2 ], + trash => [ 1, 0, 1, 0 ], + ); + } else { + $self->_check_counts('Initial Test', + a => [ 5, 2, 4, 2 ], + b => [ 3, 1, 2, 2 ], + trash => [ 1, 0, 1, 0 ], + ); + } + + xlog $self, "Move half an email to Trash"; + $imap->select("INBOX.a"); + $imap->move("2", "INBOX.Trash"); + + # expectation: + # A (a:1, seen - trash:2, unseen) == unseen in trash, seen in inbox + # B (b:1, unseen) + # C (a:3, seen - b:2, seen) + # D (a:4, seen) + # E (a:5, unseen - b:3, seen) == unseen + # F (a:6, seen) + # G (trash:1, seen) + + if ($maj > 3 || ($maj == 3 && $min >= 6)) { + $self->_check_counts('After first move', + a => [ 5, 1, 4, 2 ], + b => [ 3, 2, 2, 2 ], + trash => [ 2, 1, 2, 1 ], + ); + } else { + $self->_check_counts('After first move', + a => [ 5, 1, 4, 2 ], + b => [ 3, 1, 2, 2 ], + trash => [ 2, 1, 2, 1 ], + ); + } + + xlog $self, "Mark the bits of the thread OUTSIDE Trash all seen"; + $imap->select("INBOX.b"); + $imap->store("1", "+flags", "(\\Seen)"); + + # expectation: + # A (a:1, seen - trash:2, unseen) == unseen in trash, seen in inbox + # B (b:1, seen) + # C (a:3, seen - b:2, seen) + # D (a:4, seen) + # E (a:5, unseen - b:3, seen) == unseen + # F (a:6, seen) + # G (trash:1, seen) + + if ($maj > 3 || ($maj == 3 && $min >= 6)) { + $self->_check_counts('Second change', + a => [ 5, 1, 4, 1 ], + b => [ 3, 1, 2, 1 ], + trash => [ 2, 1, 2, 1 ], + ); + } else { + $self->_check_counts('Second change', + a => [ 5, 1, 4, 1 ], + b => [ 3, 0, 2, 1 ], + trash => [ 2, 1, 2, 1 ], + ); + } + + xlog $self, "Delete a message we don't care about"; + $imap->select("INBOX.b"); + $imap->store("1", "+flags", "(\\Deleted)"); + $imap->expunge(); + + # expectation: + # A (a:1, seen - trash:2, unseen) == unseen in trash, seen in inbox + # C (a:3, seen - b:2, seen) + # D (a:4, seen) + # E (a:5, unseen - b:3, seen) == unseen + # F (a:6, seen) + # G (trash:1, seen) + + if ($maj > 3 || ($maj == 3 && $min >= 6)) { + $self->_check_counts('Third change', + a => [ 5, 1, 4, 1 ], + b => [ 2, 1, 2, 1 ], + trash => [ 2, 1, 2, 1 ], + ); + } else { + $self->_check_counts('Third change', + a => [ 5, 1, 4, 1 ], + b => [ 2, 0, 2, 1 ], + trash => [ 2, 1, 2, 1 ], + ); + } + + xlog $self, "Delete some more"; + $imap->select("INBOX.a"); + $imap->store("1,3,6", "+flags", "(\\Deleted)"); + $imap->expunge(); + + # expectation: + # A (trash:2, unseen) == unseen in trash + # C (b:2, seen) + # D (a:4, seen) + # E (a:5, unseen - b:3, seen) == unseen + # G (trash:1, seen) + + if ($maj > 3 || ($maj == 3 && $min >= 6)) { + $self->_check_counts('Forth change', + a => [ 2, 1, 2, 1 ], + b => [ 2, 1, 2, 1 ], + trash => [ 2, 1, 2, 1 ], + ); + } else { + $self->_check_counts('Forth change', + a => [ 2, 1, 2, 1 ], + b => [ 2, 0, 2, 1 ], + trash => [ 2, 1, 2, 1 ], + ); + } +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_counts_add_remove b/cassandane/tiny-tests/JMAPMailbox/mailbox_counts_add_remove new file mode 100644 index 0000000000..811bb3f79e --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_counts_add_remove @@ -0,0 +1,170 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_counts_add_remove + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + $imap->uid(1); + + xlog "Set up mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { }, 'R1'], + ['Mailbox/set', { + create => { + "a" => { name => "a", parentId => undef }, + "b" => { name => "b", parentId => undef }, + }, + }, 'R2'], + ]); + my %ids = map { $_ => $res->[1][1]{created}{$_}{id} } + keys %{$res->[1][1]{created}}; + + xlog "Set up messages"; + my %raw = ( + A => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test A\r +EOF + B => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test B\r +EOF + C => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test C\r +EOF + D => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + ); + + # threads: + # T1: A B C + # T2: D + + xlog $self, "Set up all the emails in all the folders"; + $imap->append('INBOX.a', "(\\Seen)", $raw{A}) || die $@; + $imap->append('INBOX.a', "()", $raw{B}) || die $@; + $imap->append('INBOX.a', "(\\Seen)", $raw{C}) || die $@; + $imap->append('INBOX.a', "()", $raw{D}) || die $@; + + # expectation: + # A (a:1, seen) + # B (a:2, unseen) + # C (a:3, seen) + # D (a:4 unseen) + + $self->_check_counts('Initial Test', + a => [ 4, 2, 2, 2 ], + b => [ 0, 0, 0, 0 ], + ); + + xlog $self, "Move email to b"; + $imap->select("INBOX.a"); + $imap->move("3", "INBOX.b"); + + # expectation: + # A (a:1, seen) + # B (a:2, unseen) + # C (b:1, seen) + # D (a:4 unseen) + + $self->_check_counts('After first move', + a => [ 3, 2, 2, 2 ], + b => [ 1, 0, 1, 1 ], + ); + + xlog $self, "mark seen"; + $imap->store(2, "+flags", "\\Seen"); + + # expectation: + # A (a:1, seen) + # B (a:2, seen) + # C (b:1, seen) + # D (a:4 unseen) + + $self->_check_counts('After mark seen', + a => [ 3, 1, 2, 1 ], + b => [ 1, 0, 1, 0 ], + ); + + xlog $self, "move other"; + $imap->move("4", "INBOX.b"); + + # expectation: + # A (a:1, seen) + # B (a:2, seen) + # C (b:1, seen) + # D (b:2 unseen) + + $self->_check_counts('After move other', + a => [ 2, 0, 1, 0 ], + b => [ 2, 1, 2, 1 ], + ); + + xlog $self, "move first back"; + $imap->select("INBOX.b"); + $imap->move("1", "INBOX.a"); + + # expectation: + # A (a:1, seen) + # B (a:2, seen) + # C (a:5, seen) + # D (b:2 unseen) + + $self->_check_counts('After move first back', + a => [ 3, 0, 1, 0 ], + b => [ 1, 1, 1, 1 ], + ); + + xlog $self, "mark unseen again (different email)"; + $imap->select("INBOX.a"); + $imap->store(1, "-flags", "\\Seen"); + + # expectation: + # A (a:1, unseen) + # B (a:2, seen) + # C (a:5, seen) + # D (b:2 unseen) + + $self->_check_counts('After mark unseen again', + a => [ 3, 1, 1, 1 ], + b => [ 1, 1, 1, 1 ], + ); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get b/cassandane/tiny-tests/JMAPMailbox/mailbox_get new file mode 100644 index 0000000000..194b7d3a0d --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.foo") + or die "Cannot create mailbox INBOX.foo: $@"; + + $imaptalk->create("INBOX.foo.bar") + or die "Cannot create mailbox INBOX.foo.bar: $@"; + + xlog $self, "get existing mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(3, scalar keys %m); + my $inbox = $m{"Inbox"}; + my $foo = $m{"foo"}; + my $bar = $m{"bar"}; + + # INBOX + $self->assert_str_equals("Inbox", $inbox->{name}); + $self->assert_null($inbox->{parentId}); + $self->assert_str_equals("inbox", $inbox->{role}); + $self->assert_num_equals(1, $inbox->{sortOrder}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::false, $inbox->{myRights}->{mayRename}); + $self->assert_equals(JSON::false, $inbox->{myRights}->{mayDelete}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{maySetSeen}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{maySetKeywords}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{maySubmit}); + $self->assert_num_equals(0, $inbox->{totalEmails}); + $self->assert_num_equals(0, $inbox->{unreadEmails}); + $self->assert_num_equals(0, $inbox->{totalThreads}); + $self->assert_num_equals(0, $inbox->{unreadThreads}); + + # INBOX.foo + $self->assert_str_equals("foo", $foo->{name}); + $self->assert_null($foo->{parentId}); + $self->assert_null($foo->{role}); + $self->assert_num_equals(10, $foo->{sortOrder}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayRename}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayDelete}); + $self->assert_num_equals(0, $foo->{totalEmails}); + $self->assert_num_equals(0, $foo->{unreadEmails}); + $self->assert_num_equals(0, $foo->{totalThreads}); + $self->assert_num_equals(0, $foo->{unreadThreads}); + + # INBOX.foo.bar + $self->assert_str_equals("bar", $bar->{name}); + $self->assert_str_equals($foo->{id}, $bar->{parentId}); + $self->assert_null($bar->{role}); + $self->assert_num_equals(10, $bar->{sortOrder}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayRename}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayDelete}); + $self->assert_num_equals(0, $bar->{totalEmails}); + $self->assert_num_equals(0, $bar->{unreadEmails}); + $self->assert_num_equals(0, $bar->{totalThreads}); + $self->assert_num_equals(0, $bar->{unreadThreads}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_ids b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_ids new file mode 100644 index 0000000000..daa30bd934 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_ids @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_ids + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.foo") || die; + + xlog $self, "get all mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $inbox = $m{"Inbox"}; + my $foo = $m{"foo"}; + $self->assert_not_null($inbox); + $self->assert_not_null($foo); + + xlog $self, "get foo and unknown mailbox"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$foo->{id}, "nope"] }, "R1"]]); + $self->assert_str_equals($foo->{id}, $res->[0][1]{list}[0]->{id}); + $self->assert_str_equals("nope", $res->[0][1]{notFound}[0]); + + xlog $self, "get mailbox with erroneous id"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [123]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + my $err = $res->[0][1]; + $self->assert_str_equals('invalidArguments', $err->{type}); + $self->assert_str_equals('ids[0]', $err->{arguments}[0]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_inbox_sub b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_inbox_sub new file mode 100644 index 0000000000..08416518d5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_inbox_sub @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_inbox_sub + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.INBOX.foo") + or die "Cannot create mailbox INBOX.INBOX.foo: $@"; + + $imaptalk->create("INBOX.INBOX.foo.bar") + or die "Cannot create mailbox INBOX.INBOX.foo.bar: $@"; + + xlog $self, "get existing mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(3, scalar keys %m); + my $inbox = $m{"Inbox"}; + my $foo = $m{"foo"}; + my $bar = $m{"bar"}; + + # INBOX + $self->assert_str_equals("Inbox", $inbox->{name}); + $self->assert_null($inbox->{parentId}); + $self->assert_str_equals("inbox", $inbox->{role}); + $self->assert_num_equals(1, $inbox->{sortOrder}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::false, $inbox->{myRights}->{mayRename}); + $self->assert_equals(JSON::false, $inbox->{myRights}->{mayDelete}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{maySetSeen}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{maySetKeywords}); + $self->assert_equals(JSON::true, $inbox->{myRights}->{maySubmit}); + $self->assert_num_equals(0, $inbox->{totalEmails}); + $self->assert_num_equals(0, $inbox->{unreadEmails}); + $self->assert_num_equals(0, $inbox->{totalThreads}); + $self->assert_num_equals(0, $inbox->{unreadThreads}); + + # INBOX.INBOX.foo + $self->assert_str_equals("foo", $foo->{name}); + $self->assert_str_equals($inbox->{id}, $foo->{parentId}); + $self->assert_null($foo->{role}); + $self->assert_num_equals(10, $foo->{sortOrder}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayRename}); + $self->assert_equals(JSON::true, $foo->{myRights}->{mayDelete}); + $self->assert_num_equals(0, $foo->{totalEmails}); + $self->assert_num_equals(0, $foo->{unreadEmails}); + $self->assert_num_equals(0, $foo->{totalThreads}); + $self->assert_num_equals(0, $foo->{unreadThreads}); + + # INBOX.INBOX.foo.bar + $self->assert_str_equals("bar", $bar->{name}); + $self->assert_str_equals($foo->{id}, $bar->{parentId}); + $self->assert_null($bar->{role}); + $self->assert_num_equals(10, $bar->{sortOrder}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayRename}); + $self->assert_equals(JSON::true, $bar->{myRights}->{mayDelete}); + $self->assert_num_equals(0, $bar->{totalEmails}); + $self->assert_num_equals(0, $bar->{unreadEmails}); + $self->assert_num_equals(0, $bar->{totalThreads}); + $self->assert_num_equals(0, $bar->{unreadThreads}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_inboxsub b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_inboxsub new file mode 100644 index 0000000000..5979f26899 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_inboxsub @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_inboxsub + :min_version_3_1 :needs_component_jmap :JMAPExtensions :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # isSeenShared property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + xlog $self, "Create INBOX subfolder via IMAP"; + $imap->create("INBOX.INBOX.foo") or die; + + xlog $self, "Get mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxfoo = $mboxByName{"foo"}; + my $inbox = $mboxByName{"Inbox"}; + + $self->assert_str_equals('foo', $mboxfoo->{name}); + $self->assert_str_equals($inbox->{id}, $mboxfoo->{parentId}); + $self->assert_null($mboxfoo->{role}); + $self->assert_num_equals(10, $mboxfoo->{sortOrder}); + $self->assert_equals(JSON::true, $mboxfoo->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $mboxfoo->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $mboxfoo->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $mboxfoo->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::true, $mboxfoo->{myRights}->{mayRename}); + $self->assert_equals(JSON::true, $mboxfoo->{myRights}->{mayDelete}); + $self->assert_num_equals(0, $mboxfoo->{totalEmails}); + $self->assert_num_equals(0, $mboxfoo->{unreadEmails}); + $self->assert_num_equals(0, $mboxfoo->{totalThreads}); + $self->assert_num_equals(0, $mboxfoo->{unreadThreads}); + $self->assert_num_equals(JSON::false, $mboxfoo->{isSeenShared}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_intermediate b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_intermediate new file mode 100644 index 0000000000..7208d90561 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_intermediate @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_intermediate + :min_version_3_1 :max_version_3_4 :needs_component_jmap :JMAPExtensions :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # isSeenShared property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + xlog $self, "Create intermediate mailbox via IMAP"; + $imap->create("INBOX.A.Z") or die; + + xlog $self, "Get mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $self->assert_num_equals(3, scalar @{$res->[0][1]{list}}); + + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxA = $mboxByName{"A"}; + + $self->assert_str_equals('A', $mboxA->{name}); + $self->assert_null($mboxA->{parentId}); + $self->assert_null($mboxA->{role}); + $self->assert_num_equals(0, $mboxA->{sortOrder}, 0); + $self->assert_equals(JSON::true, $mboxA->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $mboxA->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $mboxA->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $mboxA->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::true, $mboxA->{myRights}->{mayRename}); + $self->assert_equals(JSON::true, $mboxA->{myRights}->{mayDelete}); + $self->assert_num_equals(0, $mboxA->{totalEmails}); + $self->assert_num_equals(0, $mboxA->{unreadEmails}); + $self->assert_num_equals(0, $mboxA->{totalThreads}); + $self->assert_num_equals(0, $mboxA->{unreadThreads}); + $self->assert_num_equals(JSON::false, $mboxA->{isSeenShared}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_nocalendars b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_nocalendars new file mode 100644 index 0000000000..84963b24ab --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_nocalendars @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_nocalendars + :min_version_3_1 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + # asserts that changes on special mailboxes such as calendars + # aren't listed as regular mailboxes + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:calendars', + 'https://cyrusimap.org/ns/jmap/calendars', + ]; + + xlog $self, "get existing mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]], $using); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + my $mboxes = $res->[0][1]{list}; + + xlog $self, "create calendar"; + $res = $jmap->CallMethods([ + ['Calendar/set', { create => { "1" => { + name => "foo", + color => "coral", + sortOrder => 2, + isVisible => \1 + }}}, "R1"] + ], $using); + $self->assert_not_null($res->[0][1]{created}); + + xlog $self, "get updated mailboxes"; + $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]], $using); + $self->assert_not_null($res); + $self->assert_num_equals(scalar @{$mboxes}, scalar @{$res->[0][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_properties b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_properties new file mode 100644 index 0000000000..fe0b23b168 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_properties @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_properties + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "get mailboxes with name property"; + my $res = $jmap->CallMethods([['Mailbox/get', { properties => ["name"]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + my $inbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("Inbox", $inbox->{name}); + $self->assert_num_equals(2, scalar keys %{$inbox}); # id and name + + xlog $self, "get mailboxes with erroneous property"; + $res = $jmap->CallMethods([['Mailbox/get', { properties => ["name", 123]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + my $err = $res->[0][1]; + $self->assert_str_equals("invalidArguments", $err->{type}); + $self->assert_str_equals("properties[1]", $err->{arguments}[0]); + + xlog $self, "get mailboxes with unknown property"; + $res = $jmap->CallMethods([['Mailbox/get', { properties => ["name", "123"]}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + $err = $res->[0][1]; + $self->assert_str_equals("invalidArguments", $err->{type}); + $self->assert_str_equals("properties[1:123]", $err->{arguments}[0]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared new file mode 100644 index 0000000000..4b51f5af4a --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_shared + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Create user and share mailbox + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lr") or die; + $admintalk->create("user.foo.box1") or die; + $admintalk->setacl("user.foo.box1", "cassandane", "lr") or die; + + $self->{instance}->create_user("foobar"); + $admintalk->setacl("user.foobar", "cassandane", "lr") or die; + $admintalk->create("user.foobar.box2") or die; + $admintalk->setacl("user.foobar.box2", "cassandane", "lr") or die; + + # Create user but do not share mailbox + $self->{instance}->create_user("bar"); + + # Get our own Inbox id + my $inbox = $self->getinbox(); + + my $foostore = Cassandane::IMAPMessageStore->new( + host => $self->{store}->{host}, + port => $self->{store}->{port}, + username => 'foo', + password => 'testpw', + verbose => $self->{store}->{verbose}, + ); + my $footalk = $foostore->get_client(); + + $footalk->setmetadata("INBOX.box1", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + xlog $self, "get mailboxes for foo account"; + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => "foo" }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + + my %m = map { lc($_->{name}) => $_ } @{$res->[0][1]{list}}; + my $fooInbox = $m{'inbox'}; + $self->assert_str_not_equals($inbox->{id}, $fooInbox->{id}); + $self->assert_str_equals('inbox', $fooInbox->{role}); + my $box1 = $m{'box1'}; + $self->assert_str_equals('trash', $box1->{role}); + + xlog $self, "get mailboxes for inaccessible bar account"; + $res = $jmap->CallMethods([['Mailbox/get', { accountId => "bar" }, "R1"]]); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("accountNotFound", $res->[0][1]{type}); + + xlog $self, "get mailboxes for inexistent account"; + $res = $jmap->CallMethods([['Mailbox/get', { accountId => "baz" }, "R1"]]); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("accountNotFound", $res->[0][1]{type}); + + xlog $self, "get mailboxes for visible account"; + $res = $jmap->CallMethods([['Mailbox/get', { accountId => "foobar" }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + %m = map { lc($_->{name}) => $_ } @{$res->[0][1]{list}}; + $self->assert_not_null($m{inbox}); + $self->assert_not_null($m{box2}); + $self->assert_null($m{inbox}{parentId}); + $self->assert_null($m{box2}{parentId}); + + $self->assert_equals(JSON::true, $m{inbox}{myRights}{mayReadItems}); + $self->assert_equals(JSON::true, $m{box2}{myRights}{mayReadItems}); + $self->assert_equals(JSON::false, $m{inbox}{myRights}{mayAddItems}); + $self->assert_equals(JSON::false, $m{box2}{myRights}{mayAddItems}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared_inbox b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared_inbox new file mode 100644 index 0000000000..a112b2e8af --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared_inbox @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_shared_inbox + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Create user and share mailbox + $self->{instance}->create_user("foo"); + $admintalk->setacl("user.foo", "cassandane", "lr") or die; + $admintalk->create("user.foo.box1") or die; + $admintalk->setacl("user.foo.box1", "cassandane", "lr") or die; + + $self->{instance}->create_user("foobar"); + $admintalk->create("user.foobar.INBOX.box2") or die; + $admintalk->setacl("user.foobar.INBOX.box2", "cassandane", "lr") or die; + + # Create user but do not share mailbox + $self->{instance}->create_user("bar"); + + # Get our own Inbox id + my $inbox = $self->getinbox(); + + my $foostore = Cassandane::IMAPMessageStore->new( + host => $self->{store}->{host}, + port => $self->{store}->{port}, + username => 'foo', + password => 'testpw', + verbose => $self->{store}->{verbose}, + ); + my $footalk = $foostore->get_client(); + + $footalk->setmetadata("INBOX.box1", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + xlog $self, "get mailboxes for foo account"; + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => "foo" }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + + my %m = map { lc($_->{name}) => $_ } @{$res->[0][1]{list}}; + my $fooInbox = $m{'inbox'}; + $self->assert_str_not_equals($inbox->{id}, $fooInbox->{id}); + $self->assert_str_equals('inbox', $fooInbox->{role}); + my $box1 = $m{'box1'}; + $self->assert_str_equals('trash', $box1->{role}); + + xlog $self, "get mailboxes for inaccessible bar account"; + $res = $jmap->CallMethods([['Mailbox/get', { accountId => "bar" }, "R1"]]); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("accountNotFound", $res->[0][1]{type}); + + xlog $self, "get mailboxes for inexistent account"; + $res = $jmap->CallMethods([['Mailbox/get', { accountId => "baz" }, "R1"]]); + $self->assert_str_equals("error", $res->[0][0]); + $self->assert_str_equals("accountNotFound", $res->[0][1]{type}); + + xlog $self, "get mailboxes for visible account"; + $res = $jmap->CallMethods([['Mailbox/get', { accountId => "foobar" }, "R1"]]); + %m = map { lc($_->{name}) => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + $self->assert_not_null($m{inbox}); + $self->assert_not_null($m{box2}); + $self->assert_equals(JSON::false, $m{inbox}{myRights}{mayReadItems}); + $self->assert_equals(JSON::true, $m{box2}{myRights}{mayReadItems}); + $self->assert_equals(JSON::false, $m{inbox}{myRights}{mayAddItems}); + $self->assert_equals(JSON::false, $m{box2}{myRights}{mayAddItems}); + $self->assert_null($m{inbox}{parentId}); + $self->assert_str_equals($m{inbox}{id}, $m{box2}{parentId}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared_parents b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared_parents new file mode 100644 index 0000000000..eb51abf323 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_shared_parents @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_shared_parents + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Create shared account and mailboxes + $self->{instance}->create_user("foo"); + $admintalk->create("user.foo.box1") or die; + $admintalk->create("user.foo.box1.box11") or die; + $admintalk->create("user.foo.box1.box11.box111") or die; + $admintalk->create("user.foo.box1.box12") or die; + $admintalk->create("user.foo.box2") or die; + $admintalk->create("user.foo.box3") or die; + $admintalk->create("user.foo.box3.box31") or die; + $admintalk->create("user.foo.box3.box32") or die; + + # Share mailboxes + $admintalk->setacl("user.foo.box1.box11", "cassandane", "lr") or die; + $admintalk->setacl("user.foo.box3.box32", "cassandane", "lr") or die; + + xlog $self, "get mailboxes for foo account"; + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => "foo" }, "R1"]]); + $self->assert_num_equals(4, scalar @{$res->[0][1]{list}}); + + # Assert rights + my %m = map { lc($_->{name}) => $_ } @{$res->[0][1]{list}}; + $self->assert_equals(JSON::false, $m{box1}->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $m{box11}->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::false, $m{box3}->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $m{box32}->{myRights}->{mayReadItems}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_get_specialuse b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_specialuse new file mode 100644 index 0000000000..d22b28a271 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_get_specialuse @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_get_specialuse + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Archive", "(USE (\\Archive))") || die; + $imaptalk->create("INBOX.Drafts", "(USE (\\Drafts))") || die; + $imaptalk->create("INBOX.Spam", "(USE (\\Junk))") || die; + $imaptalk->create("INBOX.Sent", "(USE (\\Sent))") || die; + $imaptalk->create("INBOX.Trash", "(USE (\\Trash))") || die; + + xlog $self, "get mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $inbox = $m{"Inbox"}; + my $archive = $m{"Archive"}; + my $drafts = $m{"Drafts"}; + my $junk = $m{"Spam"}; + my $sent = $m{"Sent"}; + my $trash = $m{"Trash"}; + + $self->assert_str_equals("Archive", $archive->{name}); + $self->assert_str_equals("archive", $archive->{role}); + + $self->assert_str_equals("Drafts", $drafts->{name}); + $self->assert_null($drafts->{parentId}); + $self->assert_str_equals("drafts", $drafts->{role}); + + $self->assert_str_equals("Spam", $junk->{name}); + $self->assert_null($junk->{parentId}); + $self->assert_str_equals("junk", $junk->{role}); + + $self->assert_str_equals("Sent", $sent->{name}); + $self->assert_null($sent->{parentId}); + $self->assert_str_equals("sent", $sent->{role}); + + $self->assert_str_equals("Trash", $trash->{name}); + $self->assert_null($trash->{parentId}); + $self->assert_str_equals("trash", $trash->{role}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_ignore_notes_subfolders b/cassandane/tiny-tests/JMAPMailbox/mailbox_ignore_notes_subfolders new file mode 100644 index 0000000000..8c3c591b1a --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_ignore_notes_subfolders @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_ignore_notes_subfolders + :min_version_3_7 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog 'Fetch inbox id'; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { }, 'R1'] + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + my $inboxId = $res->[0][1]{ids}[0]; + + xlog 'Create Notes mailbox'; + $imap->create("Notes", "(USE (\\XNotes))") or die "$!"; + + xlog 'Assert Notes folder is invisible'; + $res = $jmap->CallMethods([ + ['Mailbox/query', { }, 'R1'], + ['Mailbox/get', { }, 'R2'] + ]); + $self->assert_deep_equals([$inboxId], $res->[0][1]{ids}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_str_equals($inboxId, $res->[1][1]{list}[0]{id}); + + xlog 'Create subfolder in Notes folder'; + $imap->create("Notes.Sub") or die "$!"; + + xlog 'Assert Notes folders are invisible'; + $res = $jmap->CallMethods([ + ['Mailbox/query', { }, 'R1'], + ['Mailbox/get', { }, 'R2'] + ]); + $self->assert_deep_equals([$inboxId], $res->[0][1]{ids}); + $self->assert_num_equals(1, scalar @{$res->[1][1]{list}}); + $self->assert_str_equals($inboxId, $res->[1][1]{list}[0]{id}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_intermediary_imaprename_preservetree b/cassandane/tiny-tests/JMAPMailbox/mailbox_intermediary_imaprename_preservetree new file mode 100644 index 0000000000..256fc2f9e0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_intermediary_imaprename_preservetree @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_intermediary_imaprename_preservetree + :min_version_3_1 :max_version_3_4 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.i2.i3.foo") or die; + $imap->create("INBOX.i1.i2.bar") or die; + my $res = $jmap->CallMethods([['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"]]); + + xlog $self, "Assert mailbox tree"; + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(6, scalar keys %mboxByName); + $self->assert_not_null($mboxByName{'Inbox'}); + $self->assert_not_null($mboxByName{'i1'}); + $self->assert_not_null($mboxByName{'i2'}); + $self->assert_not_null($mboxByName{'i3'}); + $self->assert_not_null($mboxByName{'foo'}); + $self->assert_not_null($mboxByName{'bar'}); + $self->assert_null($mboxByName{i1}->{parentId}); + $self->assert_str_equals($mboxByName{i1}->{id}, $mboxByName{i2}->{parentId}); + $self->assert_str_equals($mboxByName{i2}->{id}, $mboxByName{i3}->{parentId}); + $self->assert_str_equals($mboxByName{i3}->{id}, $mboxByName{foo}->{parentId}); + $self->assert_str_equals($mboxByName{i2}->{id}, $mboxByName{bar}->{parentId}); + + xlog $self, "Rename mailbox"; + $imap->rename("INBOX.i1.i2.i3.foo", "INBOX.i1.i4.baz") or die; + + xlog $self, "Assert mailbox tree"; + $res = $jmap->CallMethods([['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"]]); + %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(6, scalar keys %mboxByName); + $self->assert_not_null($mboxByName{'Inbox'}); + $self->assert_not_null($mboxByName{'i1'}); + $self->assert_not_null($mboxByName{'i2'}); + $self->assert_not_null($mboxByName{'i4'}); + $self->assert_not_null($mboxByName{'bar'}); + $self->assert_not_null($mboxByName{'baz'}); + $self->assert_null($mboxByName{i1}->{parentId}); + $self->assert_str_equals($mboxByName{i1}->{id}, $mboxByName{i2}->{parentId}); + $self->assert_str_equals($mboxByName{i1}->{id}, $mboxByName{i4}->{parentId}); + $self->assert_str_equals($mboxByName{i2}->{id}, $mboxByName{bar}->{parentId}); + $self->assert_str_equals($mboxByName{i4}->{id}, $mboxByName{baz}->{parentId}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_intermediate_no_emails b/cassandane/tiny-tests/JMAPMailbox/mailbox_intermediate_no_emails new file mode 100644 index 0000000000..0de3d4f0ed --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_intermediate_no_emails @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +# This is to test for a bug where a query against an intermediate mailbox was returning all emails! +sub test_mailbox_intermediate_no_emails + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate emails in INBOX via IMAP"; + $self->make_message("Email A") || die; + $self->make_message("Email B") || die; + $self->make_message("Email C") || die; + + xlog $self, "Create a deep folder"; + $talk->create("INBOX.Inter.Mediate"); + + xlog $self, "Generate one email in the deep mailbox via IMAP"; + $store->set_folder("INBOX.Inter.Mediate"); + $self->make_message("Email D") || die; + + xlog $self, "get mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my %byname = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + + xlog $self, "three emails in the Inbox"; + $res = $jmap->CallMethods([['Email/query', + { filter => { inMailbox => $byname{Inbox} }, + calculateTotal => JSON::true }, "R1"]]); + $self->assert_num_equals(3, $res->[0][1]{total}); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + + xlog $self, "no emails in the Intermediate mailbox"; + $res = $jmap->CallMethods([['Email/query', + { filter => { inMailbox => $byname{Inter} }, + calculateTotal => JSON::true }, "R1"]]); + $self->assert_num_equals(0, $res->[0][1]{total}); + $self->assert_num_equals(0, scalar @{$res->[0][1]{ids}}); + + xlog $self, "one email in the deep mailbox"; + $res = $jmap->CallMethods([['Email/query', + { filter => { inMailbox => $byname{Mediate} }, + calculateTotal => JSON::true }, "R1"]]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_move_to_deleted_parent b/cassandane/tiny-tests/JMAPMailbox/mailbox_move_to_deleted_parent new file mode 100644 index 0000000000..f819e3603f --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_move_to_deleted_parent @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_move_to_deleted_parent + :min_version_3_6 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Assert mailboxes are created in the right order. + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + }, + content => '{ + "using" : ["urn:ietf:params:jmap:mail"], + "methodCalls" : [["Mailbox/set", { + "create" : { + "C" : { + "name" : "C", "parentId" : "#B", "role" : null + }, + "B" : { + "name" : "B", "parentId" : null, "role" : null + }, + "A" : { + "name" : "A", "parentId" : null, "role" : null + } + } + }, "R1"]] + }', + }; + my $RawResponse = $jmap->ua->post($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert($RawResponse->{success}); + + my $res = eval { decode_json($RawResponse->{content}) }; + $res = $res->{methodResponses}; + $self->assert_not_null($res->[0][1]{created}{A}); + $self->assert_not_null($res->[0][1]{created}{B}); + $self->assert_not_null($res->[0][1]{created}{C}); + my $idA = $res->[0][1]{created}{A}{id}; + my $idB = $res->[0][1]{created}{B}{id}; + my $idC = $res->[0][1]{created}{C}{id}; + + # Destroy "A" + $res = $jmap->CallMethods([['Mailbox/set', { + destroy => [ $idA ], + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); + $self->assert_null($res->[0][1]{notDestroyed}); + + # Try to move "B" under a non-existant mailbox + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $idB => { parentId => "nosuchid" }, + } + }, "R1"]]); + $self->assert_null($res->[0][1]{updated}); + $self->assert_not_null($res->[0][1]{notUpdated}); + + # Try to move "B" under "A" + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $idB => { parentId => $idA }, + } + }, "R1"]]); + $self->assert_null($res->[0][1]{updated}); + $self->assert_not_null($res->[0][1]{notUpdated}); + +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_query b/cassandane/tiny-tests/JMAPMailbox/mailbox_query new file mode 100644 index 0000000000..a15689e80f --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_query @@ -0,0 +1,143 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "list mailboxes without filter"; + my $res = $jmap->CallMethods([['Mailbox/query', {}, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals('Mailbox/query', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + xlog $self, "create mailboxes"; + $imaptalk->create("INBOX.A") || die; + $imaptalk->create("INBOX.B") || die; + + xlog $self, "fetch mailboxes"; + $res = $jmap->CallMethods([['Mailbox/get', { }, 'R1' ]]); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + + xlog $self, "list mailboxes without filter and sort by name ascending"; + $res = $jmap->CallMethods([['Mailbox/query', { + sort => [{ property => "name" }]}, + "R1"]]); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'A'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'B'}, $res->[0][1]{ids}[1]); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[2]); + + xlog $self, "list mailboxes without filter and sort by name descending"; + $res = $jmap->CallMethods([['Mailbox/query', { + sort => [{ property => "name", isAscending => JSON::false}], + }, "R1"]]); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'B'}, $res->[0][1]{ids}[1]); + $self->assert_str_equals($mboxids{'A'}, $res->[0][1]{ids}[2]); + + xlog $self, "filter mailboxes by hasAnyRole == true"; + $res = $jmap->CallMethods([['Mailbox/query', {filter => {hasAnyRole => JSON::true}}, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[0]); + + xlog $self, "filter mailboxes by hasAnyRole == false"; + $res = $jmap->CallMethods([['Mailbox/query', { + filter => {hasAnyRole => JSON::false}, + sort => [{ property => "name"}], + }, "R1"]]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'A'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'B'}, $res->[0][1]{ids}[1]); + + xlog $self, "create mailbox underneath A"; + $imaptalk->create("INBOX.A.AA") || die; + + xlog $self, "(re)fetch mailboxes"; + $res = $jmap->CallMethods([['Mailbox/get', { }, 'R1' ]]); + %mboxids = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + + xlog $self, "filter mailboxes by parentId"; + $res = $jmap->CallMethods([['Mailbox/query', {filter => {parentId => $mboxids{'A'}}}, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'AA'}, $res->[0][1]{ids}[0]); + + # Without windowing the name-sorted results are: A, AA, B, Inbox + + xlog $self, "list mailboxes (with limit)"; + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sort => [{ property => "name" }], + limit => 1, + }, "R1"] + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'A'}, $res->[0][1]{ids}[0]); + $self->assert_num_equals(0, $res->[0][1]->{position}); + + xlog $self, "list mailboxes (with anchor and limit)"; + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sort => [{ property => "name" }], + anchor => $mboxids{'B'}, + limit => 2, + }, "R1"] + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'B'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[1]); + $self->assert_num_equals(2, $res->[0][1]->{position}); + + xlog $self, "list mailboxes (with positive anchor offset)"; + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sort => [{ property => "name" }], + anchor => $mboxids{'AA'}, + anchorOffset => 1, + }, "R1"] + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'B'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[1]); + $self->assert_num_equals(2, $res->[0][1]->{position}); + + xlog $self, "list mailboxes (with negative anchor offset)"; + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sort => [{ property => "name" }], + anchor => $mboxids{'B'}, + anchorOffset => -1, + }, "R1"] + ]); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'AA'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'B'}, $res->[0][1]{ids}[1]); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[2]); + $self->assert_num_equals(1, $res->[0][1]->{position}); + + xlog $self, "list mailboxes (with position)"; + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sort => [{ property => "name" }], + position => 3, + }, "R1"] + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[0]); + + xlog $self, "list mailboxes (with negative position)"; + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sort => [{ property => "name" }], + position => -2, + }, "R1"] + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'B'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[1]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_query_filterastree b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_filterastree new file mode 100644 index 0000000000..357fa15253 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_filterastree @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query_filterastree + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.A") || die; + $imaptalk->create("INBOX.A.A1") || die; + $imaptalk->create("INBOX.B") || die; + $imaptalk->create("INBOX.B.X") || die; + $imaptalk->create("INBOX.C") || die; + $imaptalk->create("INBOX.C.C1") || die; + + my $res = $jmap->CallMethods([['Mailbox/get', { properties => ["name"] }, 'R1' ]]); + $self->assert_num_equals(7, scalar @{$res->[0][1]{list}}); + my %mboxIds = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + + $res = $jmap->CallMethods([ + ['Mailbox/query', { + filter => { + operator => 'NOT', + conditions => [{ + name => 'B' + }] + }, + filterAsTree => JSON::true, + sort => [{ property => 'name' }], + sortAsTree => JSON::true, + }, "R1"] + ]); + + my $wantMboxIds = [ + $mboxIds{'A'}, $mboxIds{'A1'}, $mboxIds{'C'}, $mboxIds{'C1'}, + ]; + $self->assert_deep_equals($wantMboxIds, $res->[0][1]->{ids}); + + $res = $jmap->CallMethods([ + ['Mailbox/query', { + filter => { + name => '1', + }, + filterAsTree => JSON::true, + sort => [{ property => 'name' }], + sortAsTree => JSON::true, + }, "R1"] + ]); + + $wantMboxIds = [ ]; # Can't match anything because top-level is missing + $self->assert_deep_equals($wantMboxIds, $res->[0][1]->{ids}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_query_filteroperator b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_filteroperator new file mode 100644 index 0000000000..4f6b9b9155 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_filteroperator @@ -0,0 +1,129 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query_filteroperator + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailbox tree"; + $imaptalk->create("INBOX.Ham") || die; + $imaptalk->create("INBOX.Spam", "(USE (\\Junk))") || die; + $imaptalk->create("INBOX.Ham.Zonk") || die; + $imaptalk->create("INBOX.Ham.Bonk") || die; + + xlog $self, "(re)fetch mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', { properties => ["name"] }, 'R1' ]]); + $self->assert_num_equals(5, scalar @{$res->[0][1]{list}}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + $self->assert(exists $mboxids{'Inbox'}); + + xlog $self, "Subscribe mailbox Ham"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxids{'Ham'} => { + isSubscribed => JSON::true, + }, + }, + }, 'R1'] + ]); + $self->assert(exists $res->[0][1]{updated}{$mboxids{'Ham'}}); + + xlog $self, "make sure subscribing changed state"; + $self->assert_not_equals($res->[0][1]{oldState}, $res->[0][1]{newState}); + + my $state = $res->[0][1]{oldState}; + $res = $jmap->CallMethods([['Mailbox/changes', { sinceState => $state }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{updated}}); + $self->assert_equals($res->[0][1]{updated}[0], $mboxids{'Ham'}); + $self->assert_null($res->[0][1]{updatedProperties}); + + xlog $self, "list mailboxes filtered by parentId OR role"; + $res = $jmap->CallMethods([['Mailbox/query', { + filter => { + operator => "OR", + conditions => [{ + parentId => $mboxids{'Ham'}, + }, { + hasAnyRole => JSON::true, + }], + }, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Bonk'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[1]); + $self->assert_str_equals($mboxids{'Spam'}, $res->[0][1]{ids}[2]); + $self->assert_str_equals($mboxids{'Zonk'}, $res->[0][1]{ids}[3]); + + xlog $self, "list mailboxes filtered by name"; + $res = $jmap->CallMethods([['Mailbox/query', { + filter => { + name => 'Zonk', + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Zonk'}, $res->[0][1]{ids}[0]); + + xlog $self, "list mailboxes filtered by isSubscribed"; + $res = $jmap->CallMethods([['Mailbox/query', { + filter => { + isSubscribed => JSON::true, + }, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Ham'}, $res->[0][1]{ids}[0]); + + xlog $self, "list mailboxes filtered by isSubscribed is false"; + $res = $jmap->CallMethods([['Mailbox/query', { + filter => { + isSubscribed => JSON::false, + }, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_num_equals(4, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Bonk'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[1]); + $self->assert_str_equals($mboxids{'Spam'}, $res->[0][1]{ids}[2]); + $self->assert_str_equals($mboxids{'Zonk'}, $res->[0][1]{ids}[3]); + + xlog $self, "list mailboxes filtered by parentId AND hasAnyRole false"; + $res = $jmap->CallMethods([['Mailbox/query', { + filter => { + operator => "AND", + conditions => [{ + parentId => JSON::null, + }, { + hasAnyRole => JSON::false, + }], + }, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Ham'}, $res->[0][1]{ids}[0]); + + xlog $self, "list mailboxes filtered by NOT (parentId AND role)"; + $res = $jmap->CallMethods([['Mailbox/query', { + filter => { + operator => "NOT", + conditions => [{ + operator => "AND", + conditions => [{ + parentId => JSON::null, + }, { + hasAnyRole => JSON::true, + }], + }], + }, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Bonk'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'Ham'}, $res->[0][1]{ids}[1]); + $self->assert_str_equals($mboxids{'Zonk'}, $res->[0][1]{ids}[2]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_query_issue2286 b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_issue2286 new file mode 100644 index 0000000000..9b83b62d53 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_issue2286 @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query_issue2286 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "list mailboxes without filter"; + my $res = $jmap->CallMethods([['Mailbox/query', { limit => -5 }, "R1"]]); + $self->assert_str_equals('error', $res->[0][0]); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_query_limit_zero b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_limit_zero new file mode 100644 index 0000000000..5a52e9d512 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_limit_zero @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query_limit_zero + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "list mailboxes with limit 0"; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { limit => 0 }, "R1"] + ]); + $self->assert_deep_equals([], $res->[0][1]->{ids}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_query_name b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_name new file mode 100644 index 0000000000..d8704ddbc8 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_name @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query_name + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.Ham") || die; + $imaptalk->create("INBOX.Spam", "(USE (\\Junk))") || die; + $imaptalk->create("INBOX.Ham.Zonk") || die; + $imaptalk->create("INBOX.Ham.Bonk") || die; + + my $res = $jmap->CallMethods([['Mailbox/get', { properties => ["name"] }, 'R1' ]]); + $self->assert_num_equals(5, scalar @{$res->[0][1]{list}}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + $self->assert(exists $mboxids{'Inbox'}); + + $res = $jmap->CallMethods([ + ['Mailbox/query', { + filter => { name => 'onk' }, + sort => [{ property => "name" }], + }, "R1"] + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Bonk'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'Zonk'}, $res->[0][1]{ids}[1]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_query_parentid_null b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_parentid_null new file mode 100644 index 0000000000..792832b86e --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_parentid_null @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query_parentid_null + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailbox tree"; + $imaptalk->create("INBOX.Ham") || die; + $imaptalk->create("INBOX.Spam", "(USE (\\Junk))") || die; + $imaptalk->create("INBOX.Ham.Zonk") || die; + $imaptalk->create("INBOX.Ham.Bonk") || die; + + xlog $self, "(re)fetch mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', { properties => ["name"] }, 'R1' ]]); + $self->assert_num_equals(5, scalar @{$res->[0][1]{list}}); + my %mboxids = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + $self->assert(exists $mboxids{'Inbox'}); + + xlog $self, "list mailboxes, filtered by parentId null"; + $res = $jmap->CallMethods([ + ['Mailbox/query', { + filter => { parentId => undef }, + sort => [{ property => "name" }], + }, "R1"] + ]); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{ids}}); + $self->assert_str_equals($mboxids{'Ham'}, $res->[0][1]{ids}[0]); + $self->assert_str_equals($mboxids{'Inbox'}, $res->[0][1]{ids}[1]); + $self->assert_str_equals($mboxids{'Spam'}, $res->[0][1]{ids}[2]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_query_sortastree b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_sortastree new file mode 100644 index 0000000000..69834812bf --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_query_sortastree @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_query_sortastree + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.A") || die; + $imaptalk->create("INBOX.A.A1") || die; + $imaptalk->create("INBOX.A.A2") || die; + $imaptalk->create("INBOX.A.A2.A2A") || die; + $imaptalk->create("INBOX.B") || die; + $imaptalk->create("INBOX.C") || die; + $imaptalk->create("INBOX.C.C1") || die; + $imaptalk->create("INBOX.C.C1.C1A") || die; + $imaptalk->create("INBOX.C.C2") || die; + $imaptalk->create("INBOX.D") || die; + + my $res = $jmap->CallMethods([['Mailbox/get', { properties => ["name"] }, 'R1' ]]); + $self->assert_num_equals(11, scalar @{$res->[0][1]{list}}); + my %mboxIds = map { $_->{name} => $_->{id} } @{$res->[0][1]{list}}; + + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sortAsTree => JSON::true, + sort => [{ property => 'name' }] + }, "R1"] + ]); + + my $wantMboxIds = [ + $mboxIds{'A'}, $mboxIds{'A1'}, $mboxIds{'A2'}, $mboxIds{'A2A'}, + $mboxIds{'B'}, + $mboxIds{'C'}, $mboxIds{'C1'}, $mboxIds{'C1A'}, $mboxIds{'C2'}, + $mboxIds{'D'}, + $mboxIds{'Inbox'}, + ]; + $self->assert_deep_equals($wantMboxIds, $res->[0][1]->{ids}); + + $res = $jmap->CallMethods([ + ['Mailbox/query', { + sortAsTree => JSON::true, + sort => [{ property => 'name', isAscending => JSON::false }] + }, "R1"] + ]); + $wantMboxIds = [ + $mboxIds{'Inbox'}, + $mboxIds{'D'}, + $mboxIds{'C'}, $mboxIds{'C2'}, $mboxIds{'C1'}, $mboxIds{'C1A'}, + $mboxIds{'B'}, + $mboxIds{'A'}, $mboxIds{'A2'}, $mboxIds{'A2A'}, $mboxIds{'A1'}, + ]; + $self->assert_deep_equals($wantMboxIds, $res->[0][1]->{ids}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_intermediary_added b/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_intermediary_added new file mode 100644 index 0000000000..13f51d97a5 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_intermediary_added @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_querychanges_intermediary_added + :min_version_3_1 :max_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Fetch initial mailbox state"; + my $res = $jmap->CallMethods([['Mailbox/query', { + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_equals(JSON::true, $res->[0][1]->{canCalculateChanges}); + my $state = $res->[0][1]->{queryState}; + $self->assert_not_null($state); + + xlog $self, "Create intermediate mailboxes via IMAP"; + $imap->create("INBOX.A.B.Z") or die; + + xlog $self, "Fetch updated mailbox state"; + $res = $jmap->CallMethods([['Mailbox/queryChanges', { + sinceQueryState => $state, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]->{newQueryState}); + my @ids = map { $_->{id} } @{$res->[0][1]->{added}}; + $self->assert_num_equals(3, scalar @ids); + + xlog $self, "Make sure intermediate mailboxes got reported"; + $res = $jmap->CallMethods([ + ['Mailbox/get', { + ids => \@ids, properties => ['name'], + }, "R1"] + ]); + $self->assert_not_null('A', $res->[0][1]{list}[0]{name}); + $self->assert_not_null('B', $res->[0][1]{list}[1]{name}); + $self->assert_not_null('Z', $res->[0][1]{list}[2]{name}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_intermediary_removed b/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_intermediary_removed new file mode 100644 index 0000000000..daf0a45be4 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_intermediary_removed @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_querychanges_intermediary_removed + :min_version_3_1 :max_version_3_4 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create intermediate mailboxes via IMAP"; + $imap->create("INBOX.A.B.Z") or die; + + xlog $self, "Fetch initial mailbox state"; + my $res = $jmap->CallMethods([['Mailbox/query', { + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + $self->assert_equals(JSON::true, $res->[0][1]->{canCalculateChanges}); + my $state = $res->[0][1]->{queryState}; + $self->assert_not_null($state); + + xlog $self, "Delete intermediate mailboxes via IMAP"; + $imap->delete("INBOX.A.B.Z") or die; + + xlog $self, "Fetch updated mailbox state"; + $res = $jmap->CallMethods([['Mailbox/queryChanges', { + sinceQueryState => $state, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]->{newQueryState}); + $self->assert_num_equals(3, scalar @{$res->[0][1]->{removed}}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_name b/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_name new file mode 100644 index 0000000000..e86975ef78 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_name @@ -0,0 +1,117 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_querychanges_name + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $inboxId = $self->getinbox()->{id}; + + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + 1 => { + parentId => $inboxId, + name => 'A', + }, + 2 => { + parentId => $inboxId, + name => 'B', + }, + 3 => { + parentId => $inboxId, + name => 'C', + }, + }, + }, "R1"]]); + my $mboxId1 = $res->[0][1]{created}{1}{id}; + my $mboxId2 = $res->[0][1]{created}{2}{id}; + my $mboxId3 = $res->[0][1]{created}{3}{id}; + $self->assert_not_null($mboxId1); + $self->assert_not_null($mboxId2); + $self->assert_not_null($mboxId3); + + $res = $jmap->CallMethods([['Mailbox/query', { + filter => { parentId => $inboxId }, + sort => [{ property => "name" }], + }, "R1"], + [ + 'Mailbox/get', { '#ids' => { + resultOf => 'R1', + name => 'Mailbox/query', + path => '/ids' + }, + }, 'R2' + ]]); + my $state = $res->[0][1]->{queryState}; + $self->assert_not_null($state); + $self->assert_equals(JSON::true, $res->[0][1]->{canCalculateChanges}); + + $res = $jmap->CallMethods([['Mailbox/queryChanges', { + sinceQueryState => $state, + filter => { parentId => $inboxId }, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{newQueryState}); + + # Move mailbox 1 to end of the list + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $mboxId1 => { + name => 'Z', + }, + }, + }, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$mboxId1}); + + $res = $jmap->CallMethods([['Mailbox/queryChanges', { + sinceQueryState => $state, + filter => { parentId => $inboxId }, + sort => [{ property => "name" }], + }, "R1"]]); + $self->assert_str_not_equals($state, $res->[0][1]->{newQueryState}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{removed}}); + $self->assert_str_equals($mboxId1, $res->[0][1]{removed}[0]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{added}}); + $self->assert_str_equals($mboxId1, $res->[0][1]{added}[0]{id}); + + # position 0 -> the tombstone from 'A' + # position 1 -> keep 'B' + # position 2 -> keep 'Z' + # position 3 -> new mailbox name 'Z' + $self->assert_num_equals(3, $res->[0][1]{added}[0]{index}); + $state = $res->[0][1]->{newQueryState}; + + # Keep mailbox 2 at start of the list and remove mailbox 3 + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $mboxId2 => { + name => 'Y', + }, + }, + destroy => [$mboxId3], + }, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$mboxId2}); + $self->assert_str_equals($mboxId3, $res->[0][1]{destroyed}[0]); + + $res = $jmap->CallMethods([['Mailbox/queryChanges', { + sinceQueryState => $state, + filter => { parentId => $inboxId }, + sort => [{ property => "name" }], + }, "R1"]]); + + $self->assert_str_not_equals($state, $res->[0][1]->{newQueryState}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{removed}}); + my %removed = map { $_ => 1 } @{$res->[0][1]{removed}}; + $self->assert(exists $removed{$mboxId2}); + $self->assert(exists $removed{$mboxId3}); + + # position 0 -> null + # position 1 -> tombstone from 'B' + # position 2 -> deleted 'C' + # position 3 -> splice in 'Y' + # position 4 -> new position of 'Z' + $self->assert_num_equals(1, scalar @{$res->[0][1]{added}}); + $self->assert_str_equals($mboxId2, $res->[0][1]{added}[0]{id}); + $self->assert_num_equals(3, $res->[0][1]{added}[0]{index}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_role b/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_role new file mode 100644 index 0000000000..af8b90c0d9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_querychanges_role @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_querychanges_role + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $inboxId = $self->getinbox()->{id}; + + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + 1 => { + parentId => $inboxId, + name => 'A', + }, + 2 => { + parentId => $inboxId, + name => 'B', + role => 'xspecialuse', + }, + 3 => { + parentId => $inboxId, + name => 'C', + role => 'junk', + }, + }, + }, "R1"]]); + my $mboxId1 = $res->[0][1]{created}{1}{id}; + my $mboxId2 = $res->[0][1]{created}{2}{id}; + my $mboxId3 = $res->[0][1]{created}{3}{id}; + $self->assert_not_null($mboxId1); + $self->assert_not_null($mboxId2); + $self->assert_not_null($mboxId3); + + my $filter = { hasAnyRole => JSON::true, }; + my $sort = [{ property => "name" }]; + + $res = $jmap->CallMethods([['Mailbox/query', { + filter => $filter, sort => $sort, + }, "R1"]]); + my $state = $res->[0][1]->{queryState}; + $self->assert_not_null($state); + $self->assert_equals(JSON::true, $res->[0][1]->{canCalculateChanges}); + + $res = $jmap->CallMethods([['Mailbox/queryChanges', { + sinceQueryState => $state, + filter => $filter, sort => $sort, + }, "R1"]]); + $self->assert_str_equals($state, $res->[0][1]->{newQueryState}); + + # Remove mailbox 2 from results and add mailbox 1 + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $mboxId1 => { + role => 'trash', + }, + $mboxId2 => { + role => undef, + }, + }, + }, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$mboxId1}); + $self->assert(exists $res->[0][1]{updated}{$mboxId2}); + + $res = $jmap->CallMethods([['Mailbox/queryChanges', { + sinceQueryState => $state, + filter => $filter, sort => $sort, + }, "R1"]]); + + $self->assert_str_not_equals($state, $res->[0][1]->{newQueryState}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{removed}}); + my %removed = map { $_ => 1 } @{$res->[0][1]{removed}}; + $self->assert(exists $removed{$mboxId1}); + $self->assert(exists $removed{$mboxId2}); + + $self->assert_num_equals(1, scalar @{$res->[0][1]{added}}); + $self->assert_str_equals($mboxId1, $res->[0][1]{added}[0]{id}); + $self->assert_num_equals(0, $res->[0][1]{added}[0]{index}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set b/cassandane/tiny-tests/JMAPMailbox/mailbox_set new file mode 100644 index 0000000000..5b2e7caf1b --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set @@ -0,0 +1,83 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "get inbox"; + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("Inbox", $inbox->{name}); + + my $state = $res->[0][1]{state}; + + xlog $self, "create mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "foo", + role => undef + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{created}); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "get mailbox $id"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id] }, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{list}[0]->{id}); + + my $mbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("foo", $mbox->{name}); + $self->assert_null($mbox->{parentId}); + $self->assert_null($mbox->{role}); + $self->assert_num_equals(10, $mbox->{sortOrder}); + $self->assert_equals(JSON::true, $mbox->{myRights}->{mayReadItems}); + $self->assert_equals(JSON::true, $mbox->{myRights}->{mayAddItems}); + $self->assert_equals(JSON::true, $mbox->{myRights}->{mayRemoveItems}); + $self->assert_equals(JSON::true, $mbox->{myRights}->{mayCreateChild}); + $self->assert_equals(JSON::true, $mbox->{myRights}->{mayRename}); + $self->assert_equals(JSON::true, $mbox->{myRights}->{mayDelete}); + $self->assert_num_equals(0, $mbox->{totalEmails}); + $self->assert_num_equals(0, $mbox->{unreadEmails}); + $self->assert_num_equals(0, $mbox->{totalThreads}); + $self->assert_num_equals(0, $mbox->{unreadThreads}); + + xlog $self, "update mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { update => { $id => { + name => "bar", + sortOrder => 20 + }}}, "R1"] + ]); + + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert(exists $res->[0][1]{updated}{$id}); + + xlog $self, "get mailbox $id"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id] }, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{list}[0]->{id}); + $mbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("bar", $mbox->{name}); + $self->assert_num_equals(20, $mbox->{sortOrder}); + + xlog $self, "destroy mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { destroy => [ $id ] }, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); + + xlog $self, "get mailbox $id"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id] }, "R1"]]); + $self->assert_str_equals($id, $res->[0][1]{notFound}[0]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_create_serverset_props b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_create_serverset_props new file mode 100644 index 0000000000..5065de5aca --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_create_serverset_props @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_create_serverset_props + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "can't create mailbox with server-set props"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxB => { + name => 'A', + role => undef, + # server-set properties + totalEmails => 0, + unreadEmails => 0, + totalThreads => 0, + unreadThreads => 0, + myRights => { + mayReadItems => JSON::true, + mayAddItems => JSON::true, + mayRemoveItems => JSON::true, + mayCreateChild => JSON::true, + mayDelete => JSON::true, + maySubmit => JSON::true, + maySetSeen => JSON::true, + maySetKeywords => JSON::true, + mayAdmin => JSON::true, + mayRename => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notCreated}{mboxB}{type}); + my @wantInvalidProps = ( + 'myRights', + 'totalEmails', + 'unreadEmails', + 'totalThreads', + 'unreadThreads', + ); + my @gotInvalidProps = @{$res->[0][1]{notCreated}{mboxB}{properties}}; + @wantInvalidProps = sort @wantInvalidProps; + @gotInvalidProps = sort @gotInvalidProps; + $self->assert_deep_equals(\@wantInvalidProps, \@gotInvalidProps); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_create_specialuse_nochildren b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_create_specialuse_nochildren new file mode 100644 index 0000000000..22e9307773 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_create_specialuse_nochildren @@ -0,0 +1,81 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_create_specialuse_nochildren + :min_version_3_7 :needs_component_jmap :NoStartInstances +{ + my ($self) = @_; + + $self->{instance}->{config}->set('specialuse_nochildren' => '\\Trash'); + $self->_start_instances(); + $self->_setup_http_service_objects(); + $self->setup_default_using(); + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + # set up a Trash folder with \Trash special-use annotation + my $res = $jmap->CallMethods([[ 'Mailbox/set', { + create => { + trashmbox => { + name => 'Trash', + role => 'trash', + } + } + }, 'R1']]); + my $trash_id = $res->[0][1]->{created}->{trashmbox}->{id}; + $self->assert_not_null($trash_id); + + # should not be able to create a child of \Trash + $res = $jmap->CallMethods([['Mailbox/set', { + create => { + 1 => { + parentId => $trash_id, + name => 'child', + }, + }, + }, "R1"]]); + + # XXX that syslogs an IOERROR, surprisingly -- ignore it + $self->{instance}->getsyslog(); + + $self->assert_null($res->[0][1]->{created}); + $self->assert_deep_equals({ 1 => { type => 'forbidden' } }, + $res->[0][1]->{notCreated}); + + # what if we remove the annotation + # (doing this with IMAP because JMAP can't simply remove it) + $imaptalk->setmetadata("Trash", "/private/specialuse", undef); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + # should be able to create the child now + $res = $jmap->CallMethods([['Mailbox/set', { + create => { + 1 => { + parentId => $trash_id, + name => 'child', + }, + }, + }, "R1"]]); + $self->assert_not_null($res->[0][1]->{created}->{1}->{id}); + + # should not be able to add the annotation back + $imaptalk->setmetadata("Trash", "/private/specialuse", '\\Trash'); + $self->assert_equals('no', $imaptalk->get_last_completion_response()); + + # should not be able to add the JMAP role back either + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $trash_id => { + role => 'trash', + } + } + }, "R1"]]); + + $self->assert_not_null($res->[0][1]->{notUpdated}->{$trash_id}); + $self->assert_null($res->[0][1]->{updated}); + $self->assert_str_equals('invalidProperties', + $res->[0][1]->{notUpdated}{$trash_id}{type}); + $self->assert_deep_equals(['role'], + $res->[0][1]->{notUpdated}{$trash_id}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_create b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_create new file mode 100644 index 0000000000..dd26141a0a --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_create @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_cycle_in_create + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # Attempt to create cyclic mailboxes. This should fail. + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + A => { + name => 'A', + parentId => '#C', + role => undef, + }, + B => { + name => 'B', + parentId => '#A', + role => undef, + }, + C => { + name => 'C', + parentId => '#B', + role => undef, + } + } + }, "R1"]]); + $self->assert_num_equals(3, scalar keys %{$res->[0][1]{notCreated}}); + $self->assert(exists $res->[0][1]{notCreated}{'A'}); + $self->assert(exists $res->[0][1]{notCreated}{'B'}); + $self->assert(exists $res->[0][1]{notCreated}{'C'}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_mboxtree b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_mboxtree new file mode 100644 index 0000000000..74b3c2f69d --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_mboxtree @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_cycle_in_mboxtree + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + # Create and get mailbox tree. + $imaptalk->create("INBOX.A") or die; + $imaptalk->create("INBOX.A.B") or die; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my ($idA, $idB) = ($m{"A"}{id}, $m{"B"}{id}); + + # Introduce a cycle in the mailbox tree. This should fail. + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $idA => { + parentId => $idB, + }, + }, + }, "R1"]]); + $self->assert(exists $res->[0][1]{notUpdated}{$idA}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_update b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_update new file mode 100644 index 0000000000..83de22dacc --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_cycle_in_update @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_cycle_in_update + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + # Create and get mailbox tree. + $imaptalk->create("INBOX.A") or die; + $imaptalk->create("INBOX.B") or die; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my ($idA, $idB) = ($m{"A"}{id}, $m{"B"}{id}); + + # Introduce a cycle in the mailbox tree. Since both + # operations could create the cycle, one operation must + # fail and the other succeed. It's not deterministic + # which will, resulting in mailboxes (A, A.B) or (B, B.A). + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $idB => { + parentId => $idA, + }, + $idA => { + parentId => $idB, + }, + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{notUpdated}}); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{updated}}); + $self->assert( + (exists $res->[0][1]{notUpdated}{$idA} and exists $res->[0][1]{updated}{$idB}) or + (exists $res->[0][1]{notUpdated}{$idB} and exists $res->[0][1]{updated}{$idA}) + ); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_empty b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_empty new file mode 100644 index 0000000000..a596b35e4a --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_empty @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_destroy_empty + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $store = $self->{store}; + my $talk = $store->get_client(); + + xlog $self, "Generate an email in INBOX via IMAP"; + $self->make_message("Email A") || die; + + xlog $self, "get email list"; + my $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + $self->assert_num_equals(1, scalar @{$res->[0][1]->{ids}}); + my $msgid = $res->[0][1]->{ids}[0]; + + xlog $self, "get inbox"; + $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("Inbox", $inbox->{name}); + + my $state = $res->[0][1]{state}; + + xlog $self, "create mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "foo", + parentId => $inbox->{id}, + role => undef + }}}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $self->assert_not_null($res->[0][1]{created}); + my $mboxid = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "copy email to newly created mailbox"; + $res = $jmap->CallMethods([['Email/set', { + update => { $msgid => { mailboxIds => { + $inbox->{id} => JSON::true, + $mboxid => JSON::true, + }}}, + }, "R1"]]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "attempt to destroy mailbox with email"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { destroy => [ $mboxid ] }, "R1"] + ]); + $self->assert_not_null($res->[0][1]{notDestroyed}{$mboxid}); + $self->assert_str_equals('mailboxHasEmail', $res->[0][1]{notDestroyed}{$mboxid}{type}); + + xlog $self, "remove email from mailbox"; + $res = $jmap->CallMethods([['Email/set', { + update => { $msgid => { mailboxIds => { + $inbox->{id} => JSON::true, + }}}, + }, "R1"]]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "destroy empty mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { destroy => [ $mboxid ] }, "R1"] + ]); + $self->assert_str_equals($mboxid, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox new file mode 100644 index 0000000000..0a08622108 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox @@ -0,0 +1,100 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_destroy_movetomailbox + :min_version_3_3 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $store = $self->{store}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + xlog "Create mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + mboxC => { + name => 'C', + }, + }, + }, 'R1'], + ['Email/set', { + create => { + emailA => { + mailboxIds => { + '#mboxA' => JSON::true, + }, + subject => 'emailA', + bodyStructure => { + type => 'text/plain', + partId => '1', + }, + bodyValues => { + 1 => { + value => 'emailA', + } + }, + }, + emailAB => { + mailboxIds => { + '#mboxA' => JSON::true, + '#mboxB' => JSON::true, + }, + subject => 'emailAB', + bodyStructure => { + type => 'text/plain', + partId => '1', + }, + bodyValues => { + 1 => { + value => 'emailAB', + } + }, + }, + }, + }, 'R2'], + ], $using); + my $mboxIdA = $res->[0][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxIdA); + my $mboxIdB = $res->[0][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxIdB); + my $mboxIdC = $res->[0][1]{created}{mboxC}{id}; + $self->assert_not_null($mboxIdC); + my $emailIdA = $res->[1][1]{created}{emailA}{id}; + $self->assert_not_null($emailIdA); + my $emailIdAB = $res->[1][1]{created}{emailAB}{id}; + $self->assert_not_null($emailIdAB); + + xlog "Destroy mailbox A and move emails to C"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [$mboxIdA], + onDestroyMoveToMailboxIfNoMailbox => $mboxIdC, + }, 'R1'], + ['Email/get', { + ids => [$emailIdA], + properties => ['mailboxIds'], + }, 'R2'], + ['Email/get', { + ids => [$emailIdAB], + properties => ['mailboxIds'], + }, 'R3'], + ], $using); + $self->assert_deep_equals([$mboxIdA], + $res->[0][1]{destroyed}); + $self->assert_deep_equals({$mboxIdC => JSON::true}, + $res->[1][1]{list}[0]{mailboxIds}); + $self->assert_deep_equals({$mboxIdB => JSON::true}, + $res->[2][1]{list}[0]{mailboxIds}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox_empty b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox_empty new file mode 100644 index 0000000000..2a1968c014 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox_empty @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_destroy_movetomailbox_empty + :min_version_3_3 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $store = $self->{store}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + xlog "Create mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + mboxC => { + name => 'C', + }, + }, + }, 'R1'], + ['Email/set', { + create => { + emailA => { + mailboxIds => { + '#mboxA' => JSON::true, + }, + subject => 'emailA', + bodyStructure => { + type => 'text/plain', + partId => '1', + }, + bodyValues => { + 1 => { + value => 'emailA', + } + }, + }, + }, + }, 'R2'], + ], $using); + my $mboxIdA = $res->[0][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxIdA); + my $mboxIdB = $res->[0][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxIdB); + my $mboxIdC = $res->[0][1]{created}{mboxC}{id}; + $self->assert_not_null($mboxIdC); + my $emailIdA = $res->[1][1]{created}{emailA}{id}; + $self->assert_not_null($emailIdA); + + xlog "Destroy mailbox B and move emails to C"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [$mboxIdB], + onDestroyMoveToMailboxIfNoMailbox => $mboxIdC, + }, 'R1'], + ['Email/get', { + ids => [$emailIdA], + properties => ['mailboxIds'], + }, 'R2'], + ], $using); + $self->assert_deep_equals([$mboxIdB], + $res->[0][1]{destroyed}); + $self->assert_deep_equals({$mboxIdA => JSON::true}, + $res->[1][1]{list}[0]{mailboxIds}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox_errors b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox_errors new file mode 100644 index 0000000000..eb7405e229 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_movetomailbox_errors @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_destroy_movetomailbox_errors + :min_version_3_3 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + xlog "Create mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + }, + mboxB => { + name => 'B', + }, + }, + }, 'R1'], + ], $using); + my $mboxIdA = $res->[0][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxIdA); + my $mboxIdB = $res->[0][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxIdB); + + xlog "Can't move emails to updated or destroyed mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [$mboxIdA], + onDestroyMoveToMailboxIfNoMailbox => $mboxIdA, + }, 'R1'], + ['Mailbox/set', { + update => { + $mboxIdB => { + role => 'trash', + }, + }, + destroy => [$mboxIdA], + onDestroyMoveToMailboxIfNoMailbox => $mboxIdB, + }, 'R2'], + ], $using); + $self->assert_str_equals('invalidArguments', $res->[0][1]{type}); + $self->assert_deep_equals(['onDestroyMoveToMailboxIfNoMailbox'], + $res->[0][1]{arguments}); + $self->assert_str_equals('invalidArguments', $res->[1][1]{type}); + $self->assert_deep_equals(['onDestroyMoveToMailboxIfNoMailbox'], + $res->[1][1]{arguments}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_removemsgs b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_removemsgs new file mode 100644 index 0000000000..254be1da06 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_removemsgs @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_destroy_removemsgs + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Create email in inbox and another mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { }, 'R1'], + ['Mailbox/set', { + create => { + mbox => { + name => 'A', + }, + }, + }, 'R2'], + ['Email/set', { + create => { + email => { + mailboxIds => { + '$inbox' => JSON::true, + '#mbox' => JSON::true, + }, + subject => 'email', + bodyStructure => { + type => 'text/plain', + partId => '1', + }, + bodyValues => { + 1 => { + value => 'email', + } + }, + }, + }, + }, 'R3'], + ]); + my $inboxId = $res->[0][1]{ids}[0]; + $self->assert_not_null($inboxId); + my $mboxId = $res->[1][1]{created}{mbox}{id}; + $self->assert_not_null($mboxId); + my $emailId = $res->[2][1]{created}{email}{id}; + $self->assert_not_null($emailId); + + $self->{instance}->getsyslog(); + + xlog "Destroy mailbox with onDestroyRemoveEmails"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [$mboxId], + onDestroyRemoveEmails => JSON::true, + }, 'R1'], + ['Email/get', { + ids => [$emailId], + properties => ['mailboxIds'], + }, 'R2'], + ]); + $self->assert_deep_equals([$mboxId], $res->[0][1]{destroyed}); + $self->assert_deep_equals({ $inboxId => JSON::true }, + $res->[1][1]{list}[0]{mailboxIds}); + + my ($maj, $min) = Cassandane::Instance->get_version(); + if ($maj > 3 || ($maj == 3 && $min >= 7)) { + $self->assert_syslog_matches( + $self->{instance}, + qr{Destroyed mailbox: mboxid=<$mboxId> msgcount=<1>} + ); + } +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_twice b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_twice new file mode 100644 index 0000000000..fc15b0c831 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_destroy_twice @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_destroy_twice + :min_version_3_8 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "foo", + role => undef + }}}, "R1"] + ]); + my $id = $res->[0][1]{created}{"1"}{id}; + + xlog $self, "destroy mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { destroy => [ $id ] }, "R1"] + ]); + $self->assert_str_equals($id, $res->[0][1]{destroyed}[0]); + + xlog $self, "destroy mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { destroy => [ $id ] }, "R1"] + ]); + $self->assert_str_equals("notFound", $res->[0][1]{notDestroyed}{$id}{type}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_extendedprops b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_extendedprops new file mode 100644 index 0000000000..81aa97c941 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_extendedprops @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_extendedprops + :min_version_3_3 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # we need 'https://cyrusimap.org/ns/jmap/mail' capability for + # isSeenShared property + my @using = @{ $jmap->DefaultUsing() }; + push @using, 'https://cyrusimap.org/ns/jmap/mail'; + $jmap->DefaultUsing(\@using); + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "A" => { + name => "A", + }, + "B" => { + name => "B", + isSeenShared => JSON::true, + color => '#ABCDEF', + showAsLabel => JSON::false, + } + } + }, "R1"] + ]); + $self->assert_equals(JSON::false, $res->[0][1]{created}{A}{isSeenShared}); + $self->assert(not exists $res->[0][1]{created}{B}{isSeenShared}); + $self->assert_null($res->[0][1]{created}{A}{color}); + $self->assert(not exists $res->[0][1]{created}{B}{color}); + $self->assert_equals(JSON::true, $res->[0][1]{created}{A}{showAsLabel}); + $self->assert(not exists $res->[0][1]{created}{B}{showAsLabel}); + my $mboxIdA = $res->[0][1]{created}{A}{id}; + my $mboxIdB = $res->[0][1]{created}{B}{id}; + + $res = $jmap->CallMethods([ + ['Mailbox/get', { + ids => [$mboxIdA, $mboxIdB], + properties => ['isSeenShared', 'color', 'showAsLabel'], + }, 'R1'] + ]); + $self->assert_equals($mboxIdA, $res->[0][1]{list}[0]{id}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]{isSeenShared}); + $self->assert_null($res->[0][1]{list}[0]{color}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{showAsLabel}); + $self->assert_equals($mboxIdB, $res->[0][1]{list}[1]{id}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[1]{isSeenShared}); + $self->assert_str_equals('#ABCDEF', $res->[0][1]{list}[1]{color}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[1]{showAsLabel}); + + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxIdA => { + isSeenShared => JSON::true, + color => '#123456', + showAsLabel => JSON::false, + }, + $mboxIdB => { + isSeenShared => JSON::false, + showAsLabel => JSON::false, + }, + } + }, "R1"] + ]); + $res = $jmap->CallMethods([ + ['Mailbox/get', { + ids => [$mboxIdA, $mboxIdB], + properties => ['isSeenShared', 'color', 'showAsLabel'], + }, 'R1'] + ]); + $self->assert_equals($mboxIdA, $res->[0][1]{list}[0]{id}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{isSeenShared}); + $self->assert_str_equals('#123456', $res->[0][1]{list}[0]{color}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]{showAsLabel}); + $self->assert_equals($mboxIdB, $res->[0][1]{list}[1]{id}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[1]{isSeenShared}); + $self->assert_str_equals('#ABCDEF', $res->[0][1]{list}[1]{color}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[1]{showAsLabel}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_inbox_children b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_inbox_children new file mode 100644 index 0000000000..050bef76e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_inbox_children @@ -0,0 +1,78 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_inbox_children + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.top") + or die "Cannot create mailbox INBOX.top: $@"; + + $imaptalk->create("INBOX.INBOX.foo") + or die "Cannot create mailbox INBOX.INBOX.foo: $@"; + + $imaptalk->create("INBOX.INBOX.foo.bar") + or die "Cannot create mailbox INBOX.INBOX.foo.bar: $@"; + + xlog $self, "get existing mailboxes"; + my $res = $jmap->CallMethods([['Mailbox/get', { properties => ['name', 'parentId']}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(4, scalar keys %m); + my $inbox = $m{"Inbox"}; + my $top = $m{"top"}; + my $foo = $m{"foo"}; + my $bar = $m{"bar"}; + + # INBOX + $self->assert_null($inbox->{parentId}); + $self->assert_null($top->{parentId}); + $self->assert_str_equals($inbox->{id}, $foo->{parentId}); + $self->assert_str_equals($foo->{id}, $bar->{parentId}); + + $res = $jmap->CallMethods([['Mailbox/set', { + create => { + 'a' => { name => 'tl', parentId => undef }, + 'b' => { name => 'sl', parentId => $inbox->{id} }, + }, + update => { + $top->{id} => { name => 'B', parentId => $inbox->{id} }, + $foo->{id} => { name => 'C', parentId => undef }, + }, + }, "R1"]]); + + $res = $jmap->CallMethods([['Mailbox/get', { properties => ['name', 'parentId']}, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('Mailbox/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + + %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(6, scalar keys %m); + $inbox = $m{"Inbox"}; + my $b = $m{"B"}; + my $c = $m{"C"}; + $bar = $m{"bar"}; + my $tl = $m{"tl"}; + my $sl = $m{"sl"}; + + # INBOX + $self->assert_null($inbox->{parentId}); + $self->assert_str_equals($inbox->{id}, $b->{parentId}); + $self->assert_null($c->{parentId}); + $self->assert_str_equals($c->{id}, $bar->{parentId}); + $self->assert_str_equals($inbox->{id}, $sl->{parentId}); + $self->assert_null($tl->{parentId}); + + my $list = $imaptalk->list("", "*"); + + my $mb = join(',', sort map { $_->[2] } @$list); + + $self->assert_str_equals("INBOX,INBOX.C,INBOX.C.bar,INBOX.INBOX.B,INBOX.INBOX.sl,INBOX.tl", $mb); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_annotation b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_annotation new file mode 100644 index 0000000000..4211386638 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_annotation @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_intermediary_annotation + :min_version_3_1 :max_version_3_4 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.foo") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId', 'sortOrder'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxId = $mboxByName{'i1'}->{id}; + $self->assert_num_equals(0, $mboxByName{'i1'}->{sortOrder}); + $self->assert_null($mboxByName{'i1'}->{parentId}); + + xlog $self, "Set annotation on intermediate"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxId => { + sortOrder => 7, + }, + } + }, 'R1'], + ['Mailbox/get', { + ids => [$mboxId], + properties => ['name', 'parentId', 'sortOrder'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$mboxId}); + $self->assert_num_equals(7, $res->[1][1]{list}[0]->{sortOrder}); + + xlog $self, "Assert mailbox tree"; + $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(3, scalar keys %mboxByName); + $self->assert_not_null($mboxByName{'Inbox'}); + $self->assert_not_null($mboxByName{'i1'}); + $self->assert_not_null($mboxByName{'foo'}); + $self->assert_null($mboxByName{i1}->{parentId}); + $self->assert_str_equals($mboxByName{i1}->{id}, $mboxByName{foo}->{parentId}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_createchild b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_createchild new file mode 100644 index 0000000000..c3f0717b88 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_createchild @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_intermediary_createchild + :min_version_3_1 :max_version_3_4 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.i2.i3.foo") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + 1 => { + name => 'bar', + parentId => $mboxByName{'i2'}->{id}, + }, + } + }, 'R1'] + ]); + $self->assert_not_null($res->[0][1]{created}{1}{id}); + + xlog $self, "Assert mailbox tree"; + $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(6, scalar keys %mboxByName); + $self->assert_not_null($mboxByName{'Inbox'}); + $self->assert_not_null($mboxByName{'i1'}); + $self->assert_not_null($mboxByName{'i2'}); + $self->assert_not_null($mboxByName{'i3'}); + $self->assert_not_null($mboxByName{'foo'}); + $self->assert_not_null($mboxByName{'bar'}); + $self->assert_null($mboxByName{i1}->{parentId}); + $self->assert_str_equals($mboxByName{i1}->{id}, $mboxByName{i2}->{parentId}); + $self->assert_str_equals($mboxByName{i2}->{id}, $mboxByName{i3}->{parentId}); + $self->assert_str_equals($mboxByName{i3}->{id}, $mboxByName{foo}->{parentId}); + $self->assert_str_equals($mboxByName{i2}->{id}, $mboxByName{bar}->{parentId}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_destroy b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_destroy new file mode 100644 index 0000000000..e30a22b3c7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_destroy @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_intermediary_destroy + :min_version_3_1 :max_version_3_4 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.i2.foo") or die; + $imap->create("INBOX.i1.bar") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdFoo = $mboxByName{'foo'}->{id}; + my $mboxId2 = $mboxByName{'i2'}->{id}; + + xlog $self, "Destroy intermediate"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [$mboxId2, $mboxIdFoo], + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{destroyed}}); + + xlog $self, "Assert mailbox tree and changes"; + $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"], + ]); + + # Intermediaries with real children are kept. + %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(3, scalar keys %mboxByName); + $self->assert_not_null($mboxByName{'Inbox'}); + $self->assert_not_null($mboxByName{'i1'}); + $self->assert_not_null($mboxByName{'bar'}); + $self->assert_null($mboxByName{i1}->{parentId}); + $self->assert_str_equals($mboxByName{i1}->{id}, $mboxByName{bar}->{parentId}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_destroy_child b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_destroy_child new file mode 100644 index 0000000000..f2adddccc7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_destroy_child @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_intermediary_destroy_child + :min_version_3_1 :max_version_3_4 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.i2.foo") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdFoo = $mboxByName{'foo'}->{id}; + my $mboxId1 = $mboxByName{'i1'}->{id}; + my $mboxId2 = $mboxByName{'i2'}->{id}; + my $state = $res->[0][1]{state}; + + xlog $self, "Destroy child of intermediate"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [$mboxIdFoo], + }, 'R1'], + ]); + $self->assert_str_equals($mboxIdFoo, $res->[0][1]{destroyed}[0]); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $state = $res->[0][1]{newState}; + + xlog $self, "Assert mailbox tree and changes"; + $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"], + ['Mailbox/changes', { + sinceState => $state, + }, 'R2'], + ]); + + # All intermediaries without real children are gone. + %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(1, scalar keys %mboxByName); + $self->assert_not_null($mboxByName{'Inbox'}); + + # But Mailbox/changes reports the implicitly destroyed mailboxes. + $self->assert_num_equals(2, scalar @{$res->[1][1]{destroyed}}); + my %destroyed = map { $_ => 1 } @{$res->[1][1]{destroyed}}; + $self->assert_not_null($destroyed{$mboxId1}); + $self->assert_not_null($destroyed{$mboxId2}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_move_child b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_move_child new file mode 100644 index 0000000000..7b9dc77a7c --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_move_child @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_intermediary_move_child + :min_version_3_1 :max_version_3_4 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.i2.foo") or die; + $imap->create("INBOX.i1.i3.bar") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxIdFoo = $mboxByName{'foo'}->{id}; + my $mboxId1 = $mboxByName{'i1'}->{id}; + my $mboxId2 = $mboxByName{'i2'}->{id}; + my $mboxId3 = $mboxByName{'i3'}->{id}; + my $mboxIdBar = $mboxByName{'bar'}->{id}; + my $state = $res->[0][1]{state}; + + xlog $self, "Move child of intermediary to another intermediary"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxIdBar => { + parentId => $mboxId2, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$mboxIdBar}); + $self->assert_str_not_equals($state, $res->[0][1]{newState}); + $state = $res->[0][1]{newState}; + + xlog $self, "Assert mailbox tree and changes"; + $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"], + ['Mailbox/changes', { + sinceState => $state, + }, 'R2'], + ]); + + # All intermediaries without real children are gone. + %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(5, scalar keys %mboxByName); + $self->assert_not_null($mboxByName{'Inbox'}); + $self->assert_not_null($mboxByName{'i1'}); + $self->assert_not_null($mboxByName{'i2'}); + $self->assert_not_null($mboxByName{'foo'}); + $self->assert_not_null($mboxByName{'bar'}); + $self->assert_null($mboxByName{i1}->{parentId}); + $self->assert_str_equals($mboxByName{i1}->{id}, $mboxByName{i2}->{parentId}); + $self->assert_str_equals($mboxByName{i2}->{id}, $mboxByName{foo}->{parentId}); + $self->assert_str_equals($mboxByName{i2}->{id}, $mboxByName{bar}->{parentId}); + + # But Mailbox/changes reports the implicitly destroyed mailboxes. + $self->assert_num_equals(1, scalar @{$res->[1][1]{destroyed}}); + $self->assert_str_equals($mboxId3, $res->[1][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_rename b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_rename new file mode 100644 index 0000000000..e633bd3710 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_intermediary_rename @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_intermediary_rename + :min_version_3_1 :max_version_3_4 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + xlog $self, "Create mailboxes"; + $imap->create("INBOX.i1.i2.foo") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + my %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $mboxId = $mboxByName{'i2'}->{id}; + my $mboxIdParent = $mboxByName{'i2'}->{parentId}; + $self->assert_not_null($mboxIdParent); + + xlog $self, "Rename intermediate"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxId => { + name => 'i3', + }, + } + }, 'R1'], + ['Mailbox/get', { + ids => [$mboxId], + properties => ['name', 'parentId'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$mboxId}); + $self->assert_str_equals('i3', $res->[1][1]{list}[0]{name}); + $self->assert_str_equals($mboxIdParent, $res->[1][1]{list}[0]{parentId}); + + xlog $self, "Assert mailbox tree"; + $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['name', 'parentId'], + }, "R1"] + ]); + %mboxByName = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_num_equals(4, scalar keys %mboxByName); + $self->assert_not_null($mboxByName{'Inbox'}); + $self->assert_not_null($mboxByName{'i1'}); + $self->assert_not_null($mboxByName{'i3'}); + $self->assert_not_null($mboxByName{'foo'}); + $self->assert_null($mboxByName{i1}->{parentId}); + $self->assert_str_equals($mboxByName{i1}->{id}, $mboxByName{i3}->{parentId}); + $self->assert_str_equals($mboxByName{i3}->{id}, $mboxByName{foo}->{parentId}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_issubscribed b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_issubscribed new file mode 100644 index 0000000000..ff3ef4bbc7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_issubscribed @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_issubscribed + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + "A" => { + name => "A", + }, + "B" => { + name => "B", + isSubscribed => JSON::true, + } + } + }, "R1"] + ]); + $self->assert_equals(JSON::false, $res->[0][1]{created}{A}{isSubscribed}); + $self->assert(not exists $res->[0][1]{created}{B}{isSubscribed}); + my $mboxIdA = $res->[0][1]{created}{A}{id}; + my $mboxIdB = $res->[0][1]{created}{B}{id}; + + $res = $jmap->CallMethods([ + ['Mailbox/get', { + ids => [$mboxIdA, $mboxIdB], + properties => ['isSubscribed'], + }, 'R1'] + ]); + $self->assert_equals($mboxIdA, $res->[0][1]{list}[0]{id}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[0]{isSubscribed}); + $self->assert_equals($mboxIdB, $res->[0][1]{list}[1]{id}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[1]{isSubscribed}); + + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxIdA => { + isSubscribed => JSON::true, + }, + $mboxIdB => { + isSubscribed => JSON::false, + }, + } + }, "R1"] + ]); + $res = $jmap->CallMethods([ + ['Mailbox/get', { + ids => [$mboxIdA, $mboxIdB], + properties => ['isSubscribed'], + }, 'R1'] + ]); + $self->assert_equals($mboxIdA, $res->[0][1]{list}[0]{id}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{isSubscribed}); + $self->assert_equals($mboxIdB, $res->[0][1]{list}[1]{id}); + $self->assert_equals(JSON::false, $res->[0][1]{list}[1]{isSubscribed}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_issue2377 b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_issue2377 new file mode 100644 index 0000000000..e662b96d86 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_issue2377 @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_issue2377 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "get inbox"; + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("Inbox", $inbox->{name}); + + my $state = $res->[0][1]{state}; + + xlog $self, "create mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "foo", + parentId => "#", + role => undef + }}}, "R1"] + ]); + $self->assert_not_null($res->[0][1]{notCreated}{'1'}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_collision b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_collision new file mode 100644 index 0000000000..fdc9a4504a --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_collision @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_name_collision + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "get inbox"; + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("Inbox", $inbox->{name}); + + my $state = $res->[0][1]{state}; + + xlog $self, "create three mailboxes named foo (two will fail)"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { + "1" => { + name => "foo", + parentId => $inbox->{id}, + role => undef + }, + "2" => { + name => "foo", + parentId => $inbox->{id}, + role => undef + }, + "3" => { + name => "foo", + parentId => $inbox->{id}, + role => undef + } + }}, "R1"] + ]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{created}}); + $self->assert_num_equals(2, scalar keys %{$res->[0][1]{notCreated}}); + + my $fooid = $res->[0][1]{created}{(keys %{$res->[0][1]{created}})[0]}{id}; + $self->assert_not_null($fooid); + + xlog $self, "create mailbox bar"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { + "1" => { + name => "bar", + parentId => $inbox->{id}, + role => undef + } + }}, 'R1'], + ]); + my $barid = $res->[0][1]{created}{"1"}{id}; + $self->assert_not_null($barid); + + # This MUST work per spec, but Cyrus /set does not support + # invalid interim states... + xlog $self, "rename bar to foo and foo to bar"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { update => { + $fooid => { + name => "bar", + }, + $barid => { + name => "foo", + }, + }}, 'R1'], + ]); + $self->assert_num_equals(2, scalar keys %{$res->[0][1]{updated}}); + + xlog $self, "get mailboxes"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$fooid, $barid] }, "R1"]]); + + # foo is bar + $self->assert_str_equals($fooid, $res->[0][1]{list}[0]->{id}); + $self->assert_str_equals("bar", $res->[0][1]{list}[0]->{name}); + + # and bar is foo + $self->assert_str_equals($barid, $res->[0][1]{list}[1]->{id}); + $self->assert_str_equals("foo", $res->[0][1]{list}[1]->{name}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_interop b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_interop new file mode 100644 index 0000000000..e1472e0215 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_interop @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_name_interop + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "create mailbox via IMAP"; + $imaptalk->create("INBOX.foo") + or die "Cannot create mailbox INBOX.foo: $@"; + + xlog $self, "get foo mailbox"; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my $foo = $m{"foo"}; + my $id = $foo->{id}; + $self->assert_str_equals("foo", $foo->{name}); + + xlog $self, "rename mailbox foo to oof via JMAP"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { update => { $id => { name => "oof" }}}, "R1"] + ]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "get mailbox via IMAP"; + my $data = $imaptalk->list("INBOX.oof", "%"); + $self->assert_num_equals(1, scalar @{$data}); + + xlog $self, "rename mailbox oof to bar via IMAP"; + $imaptalk->rename("INBOX.oof", "INBOX.bar") + or die "Cannot rename mailbox: $@"; + + xlog $self, "get mailbox $id"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id] }, "R1"]]); + $self->assert_str_equals("bar", $res->[0][1]{list}[0]->{name}); + + xlog $self, "rename mailbox bar to baz via JMAP"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { update => { $id => { name => "baz" }}}, "R1"] + ]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "get mailbox via IMAP"; + $data = $imaptalk->list("INBOX.baz", "%"); + $self->assert_num_equals(1, scalar @{$data}); + + xlog $self, "rename mailbox baz to IFeel\N{WHITE SMILING FACE} via IMAP"; + $imaptalk->rename("INBOX.baz", "INBOX.IFeel\N{WHITE SMILING FACE}") + or die "Cannot rename mailbox: $@"; + + xlog $self, "get mailbox $id"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id] }, "R1"]]); + $self->assert_str_equals("IFeel\N{WHITE SMILING FACE}", $res->[0][1]{list}[0]->{name}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_missing b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_missing new file mode 100644 index 0000000000..c10081b55f --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_missing @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_name_missing + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "create mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { + "1" => { role => undef }, + "2" => { role => undef, name => "\t " }, + }}, "R1"] + ]); + $self->assert_str_equals('Mailbox/set', $res->[0][0]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notCreated}{1}{type}); + $self->assert_str_equals('name', $res->[0][1]{notCreated}{1}{properties}[0]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notCreated}{2}{type}); + $self->assert_str_equals('name', $res->[0][1]{notCreated}{2}{properties}[0]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_swap b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_swap new file mode 100644 index 0000000000..592fe440c2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_swap @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_name_swap + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + A => { + name => 'A', parentId => undef, role => undef, + }, + B => { + name => 'B', parentId => undef, role => undef, + }, + }, + }, "R1"]]); + my $idA =$res->[0][1]{created}{A}{id}; + my $idB =$res->[0][1]{created}{B}{id}; + $self->assert_not_null($idA); + $self->assert_not_null($idB); + + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $idA => { name => 'B' }, + $idB => { name => 'A' }, + }, + }, "R1"]]); + $self->assert(exists $res->[0][1]{updated}{$idA}); + $self->assert(exists $res->[0][1]{updated}{$idB}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_unicode_nfc b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_unicode_nfc new file mode 100644 index 0000000000..8669222253 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_name_unicode_nfc @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_name_unicode_nfc + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "get inbox"; + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("Inbox", $inbox->{name}); + + my $state = $res->[0][1]{state}; + + my $name = "\N{ANGSTROM SIGN}ngstr\N{LATIN SMALL LETTER O WITH DIAERESIS}m"; + my $want = "\N{LATIN CAPITAL LETTER A WITH RING ABOVE}ngstr\N{LATIN SMALL LETTER O WITH DIAERESIS}m"; + + xlog $self, "create mailboxes with name not conforming to Net Unicode (NFC)"; + $res = $jmap->CallMethods([['Mailbox/set', { create => { "1" => { + name => "\N{ANGSTROM SIGN}ngstr\N{LATIN SMALL LETTER O WITH DIAERESIS}m", + parentId => $inbox->{id}, + role => undef + }}}, "R1"]]); + $self->assert_not_null($res->[0][1]{created}{1}); + my $id = $res->[0][1]{created}{1}{id}; + + xlog $self, "get mailbox $id"; + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id] }, "R1"]]); + $self->assert_str_equals($want, $res->[0][1]{list}[0]->{name}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_nameclash b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_nameclash new file mode 100644 index 0000000000..486412115e --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_nameclash @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_nameclash + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Test name-clash at top-level + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + A1 => { + name => 'A', parentId => undef, role => undef, + }, + A2 => { + name => 'A', parentId => undef, role => undef, + }, + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{notCreated}}); + + # Test name-clash at lower lever + my $parentA = (values %{$res->[0][1]{created}})[0]{id}; + $self->assert_not_null($parentA); + $res = $jmap->CallMethods([['Mailbox/set', { + create => { + B1 => { + name => 'B', parentId => $parentA, role => undef, + }, + B2 => { + name => 'B', parentId => $parentA, role => undef, + }, + }, + }, "R1"]]); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{created}}); + $self->assert_num_equals(1, scalar keys %{$res->[0][1]{notCreated}}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_no_outbox_role b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_no_outbox_role new file mode 100644 index 0000000000..52fdc63b46 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_no_outbox_role @@ -0,0 +1,19 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_no_outbox_role + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + # Regression test to make sure the non-standard 'outbox' + # role is rejected for mailboxes. + + my $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { + "1" => { name => "foo", parentId => undef, role => "outbox" }, + }}, "R1"] + ]); + $self->assert_str_equals("role", $res->[0][1]{notCreated}{1}{properties}[0]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_order b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_order new file mode 100644 index 0000000000..70bbd8c379 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_order @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_order + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Assert mailboxes are created in the right order. + my $RawRequest = { + headers => { + 'Authorization' => $jmap->auth_header(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + }, + content => '{ + "using" : ["urn:ietf:params:jmap:mail"], + "methodCalls" : [["Mailbox/set", { + "create" : { + "C" : { + "name" : "C", "parentId" : "#B", "role" : null + }, + "B" : { + "name" : "B", "parentId" : "#A", "role" : null + }, + "A" : { + "name" : "A", "parentId" : null, "role" : null + } + } + }, "R1"]] + }', + }; + my $RawResponse = $jmap->ua->post($jmap->uri(), $RawRequest); + if ($ENV{DEBUGJMAP}) { + warn "JMAP " . Dumper($RawRequest, $RawResponse); + } + $self->assert($RawResponse->{success}); + + my $res = eval { decode_json($RawResponse->{content}) }; + $res = $res->{methodResponses}; + $self->assert_not_null($res->[0][1]{created}{A}); + $self->assert_not_null($res->[0][1]{created}{B}); + $self->assert_not_null($res->[0][1]{created}{C}); + + # Assert mailboxes are destroyed in the right order. + $res = $jmap->CallMethods([['Mailbox/set', { + destroy => [ + $res->[0][1]{created}{A}{id}, + $res->[0][1]{created}{B}{id}, + $res->[0][1]{created}{C}{id}, + ] + }, "R1"]]); + $self->assert_num_equals(3, scalar @{$res->[0][1]{destroyed}}); + $self->assert_null($res->[0][1]{notDestroyed}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_order2 b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_order2 new file mode 100644 index 0000000000..2e765e5a43 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_order2 @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_order2 + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imaptalk = $self->{store}->get_client(); + + # Create and get mailbox tree. + $imaptalk->create("INBOX.A") or die; + $imaptalk->create("INBOX.A.B") or die; + my $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + my ($idA, $idB) = ($m{"A"}{id}, $m{"B"}{id}); + + # Use a non-trivial, but correct operations order: this + # asserts that name clashes and mailboxHasChild conflicts + # are resolved appropriately: the create depends on the + # deletion of current mailbox A, which depends on the + # update to move away the child from A, which requires + # the create to set the parentId. Fun times. + $res = $jmap->CallMethods([['Mailbox/set', { + create => { + Anew => { + name => 'A', + parentId => undef, + role => undef, + }, + }, + update => { + $idB => { + parentId => '#Anew', + }, + }, + destroy => [ + $idA, + ] + }, "R1"]]); + $self->assert(exists $res->[0][1]{created}{'Anew'}); + $self->assert(exists $res->[0][1]{updated}{$idB}); + $self->assert_str_equals($idA, $res->[0][1]{destroyed}[0]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_parent b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_parent new file mode 100644 index 0000000000..06a64990f2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_parent @@ -0,0 +1,124 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_parent + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + # Create mailboxes + xlog $self, "create mailbox foo"; + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + "1" => { + name => "foo", + parentId => undef, + role => undef } + } + }, "R1"]]); + my $id1 = $res->[0][1]{created}{"1"}{id}; + xlog $self, "create mailbox foo.bar"; + $res = $jmap->CallMethods([['Mailbox/set', { + create => { + "2" => { + name => "bar", + parentId => $id1, + role => undef } + } + }, "R1"]]); + my $id2 = $res->[0][1]{created}{"2"}{id}; + xlog $self, "create mailbox foo.bar.baz"; + $res = $jmap->CallMethods([['Mailbox/set', { + create => { + "3" => { + name => "baz", + parentId => $id2, + role => undef + } + } + }, "R1"]]); + my $id3 = $res->[0][1]{created}{"3"}{id}; + + # All set up? + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id1] }, "R1"]]); + $self->assert_null($res->[0][1]{list}[0]->{parentId}); + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id2] }, "R1"]]); + $self->assert_str_equals($id1, $res->[0][1]{list}[0]->{parentId}); + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id3] }, "R1"]]); + $self->assert_str_equals($id2, $res->[0][1]{list}[0]->{parentId}); + + xlog $self, "move foo.bar to bar"; + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $id2 => { + name => "bar", + parentId => undef, + role => undef } + } + }, "R1"]]); + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id2] }, "R1"]]); + $self->assert_null($res->[0][1]{list}[0]->{parentId}); + + xlog $self, "move bar.baz to foo.baz"; + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $id3 => { + name => "baz", + parentId => $id1, + role => undef + } + } + }, "R1"]]); + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id3] }, "R1"]]); + $self->assert_str_equals($id1, $res->[0][1]{list}[0]->{parentId}); + + xlog $self, "move foo to bar.foo"; + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $id1 => { + name => "foo", + parentId => $id2, + role => undef + } + } + }, "R1"]]); + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id1] }, "R1"]]); + $self->assert_str_equals($id2, $res->[0][1]{list}[0]->{parentId}); + + xlog $self, "move foo to non-existent parent"; + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $id1 => { + name => "foo", + parentId => "nope", + role => undef + } + } + }, "R1"]]); + my $errType = $res->[0][1]{notUpdated}{$id1}{type}; + my $errProp = $res->[0][1]{notUpdated}{$id1}{properties}; + $self->assert_str_equals("invalidProperties", $errType); + $self->assert_deep_equals([ "parentId" ], $errProp); + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id1] }, "R1"]]); + $self->assert_str_equals($id2, $res->[0][1]{list}[0]->{parentId}); + + xlog $self, "attempt to destroy bar (which has child foo)"; + $res = $jmap->CallMethods([['Mailbox/set', { + destroy => [$id2] + }, "R1"]]); + $errType = $res->[0][1]{notDestroyed}{$id2}{type}; + $self->assert_str_equals("mailboxHasChild", $errType); + $res = $jmap->CallMethods([['Mailbox/get', { ids => [$id2] }, "R1"]]); + $self->assert_null($res->[0][1]{list}[0]->{parentId}); + + xlog $self, "destroy all"; + $res = $jmap->CallMethods([['Mailbox/set', { + destroy => [$id3, $id1, $id2] + }, "R1"]]); + $self->assert_num_equals(3, scalar @{$res->[0][1]{destroyed}}); + $self->assert(grep {$_ eq $id1} @{$res->[0][1]{destroyed}}); + $self->assert(grep {$_ eq $id2} @{$res->[0][1]{destroyed}}); + $self->assert(grep {$_ eq $id3} @{$res->[0][1]{destroyed}}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_parent_acl b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_parent_acl new file mode 100644 index 0000000000..f7549a5b50 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_parent_acl @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_parent_acl + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "get inbox"; + my $res = $jmap->CallMethods([['Mailbox/get', { }, "R1"]]); + my $inbox = $res->[0][1]{list}[0]; + $self->assert_str_equals("Inbox", $inbox->{name}); + + xlog $self, "get inbox ACL"; + my $parentacl = $admintalk->getacl("user.cassandane"); + + xlog $self, "create mailbox"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { create => { "1" => { + name => "foo", + role => undef + }}}, "R1"] + ]); + $self->assert_not_null($res->[0][1]{created}); + + xlog $self, "get new mailbox ACL"; + my $myacl = $admintalk->getacl("user.cassandane.foo"); + + xlog $self, "assert ACL matches parent ACL"; + $self->assert_deep_equals($parentacl, $myacl); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_protected_move_parent b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_protected_move_parent new file mode 100644 index 0000000000..d1268c0ddd --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_protected_move_parent @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_protected_move_parent + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "create protected and unprotected roles"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + role => 'drafts', + }, + mboxB => { + name => 'B', + role => 'xspecialuse', + }, + mboxC => { + name => 'C', + }, + }, + }, "R2"], + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R3'], + ]); + my $mboxA = $res->[0][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[0][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + my $mboxC = $res->[0][1]{created}{mboxC}{id}; + $self->assert_not_null($mboxC); + xlog "move protected and unprotected roles in one method"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxA => { + parentId => $mboxC, + }, + $mboxB => { + parentId => $mboxC, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notUpdated}{$mboxA}{type}); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notUpdated}{$mboxB}{type}); + + xlog "move protected and unprotected roles in separate method"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxA => { + parentId => $mboxC, + }, + }, + }, 'R1'], + ['Mailbox/set', { + update => { + $mboxB => { + parentId => $mboxC, + }, + }, + }, 'R2'], + ]); + $self->assert_str_equals('invalidProperties', $res->[0][1]{notUpdated}{$mboxA}{type}); + $self->assert(exists $res->[1][1]{updated}{$mboxB}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_create b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_create new file mode 100644 index 0000000000..c65843aa45 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_create @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_role_create + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "create mailboxes with roles"; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R1'], + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + role => 'trash', + }, + mboxB => { + name => 'B', + role => 'junk', + }, + }, + }, "R2"], + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R3'], + ]); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + my $mboxA = $res->[1][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[1][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + my %roleByMbox = map { $_->{id} => $_->{role} } @{$res->[2][1]{list}}; + $self->assert_deep_equals({ + $inbox => 'inbox', + $mboxA => 'trash', + $mboxB => 'junk', + }, \%roleByMbox); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_dups_createrole b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_dups_createrole new file mode 100644 index 0000000000..d7a048c310 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_dups_createrole @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_role_dups_createrole + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "Can't create two mailboxes with the same role"; + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R1'], + ['Mailbox/set', { + create => { + mboxNope1 => { + name => 'nope1', + role => 'drafts', + }, + mboxNope2=> { + name => 'nope2', + role => 'drafts', + }, + }, + }, "R2"], + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R3'], + ]); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + $self->assert_deep_equals(['role'], $res->[1][1]{notCreated}{'mboxNope1'}{properties}); + $self->assert_deep_equals(['role'], $res->[1][1]{notCreated}{'mboxNope2'}{properties}); + my %roleByMbox = map { $_->{id} => $_->{role} } @{$res->[2][1]{list}}; + $self->assert_deep_equals({ + $inbox => 'inbox', + }, \%roleByMbox); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_dups_existingrole b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_dups_existingrole new file mode 100644 index 0000000000..27694a8bc7 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_dups_existingrole @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_role_dups_existingrole + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R1'], + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + role => 'junk', + }, + }, + }, "R2"], + ]); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + my $mboxA = $res->[1][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + + xlog "Can't create a mailbox with a duplicate role"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxB => { + name => 'B', + role => 'junk', + }, + }, + }, "R1"], + ]); + $self->assert_deep_equals(['role'], $res->[0][1]{notCreated}{'mboxB'}{properties}); + + xlog "Can't update a mailbox with a duplicate role"; + # create it first + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxB => { + name => 'B', + }, + }, + }, "R1"], + ]); + my $mboxB = $res->[0][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + # now update + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxB => { + name => 'B', + role => 'junk', + }, + }, + }, "R1"], + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R2'], + ]); + $self->assert_deep_equals(['role'], $res->[0][1]{notUpdated}{$mboxB}{properties}); + my %roleByMbox = map { $_->{id} => $_->{role} } @{$res->[1][1]{list}}; + $self->assert_deep_equals({ + $inbox => 'inbox', + $mboxA => 'junk', + $mboxB => undef, + }, \%roleByMbox); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_move_destroy b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_move_destroy new file mode 100644 index 0000000000..8431e673a1 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_move_destroy @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_role_move_destroy + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "move role by destroy"; + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R1'], + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + role => 'trash', + }, + }, + }, "R2"], + ]); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + my $mboxA = $res->[1][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + + $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxB => { + name => 'B', + role => 'trash', + }, + }, + destroy => [$mboxA], + }, "R1"], + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R2'], + ]); + $self->assert_deep_equals([$mboxA], $res->[0][1]{destroyed}); + my $mboxB = $res->[0][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + my %roleByMbox = map { $_->{id} => $_->{role} } @{$res->[1][1]{list}}; + $self->assert_deep_equals({ + $inbox => 'inbox', + $mboxB => 'trash', + }, \%roleByMbox); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_move_update b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_move_update new file mode 100644 index 0000000000..e47351778f --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_move_update @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_role_move_update + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R1'], + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + role => 'trash', + }, + mboxB => { + name => 'B', + }, + }, + }, "R2"], + ]); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + my $mboxA = $res->[1][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[1][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + + xlog "move trash role by update"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxA => { + role => undef, + }, + $mboxB => { + role => 'trash', + }, + }, + }, "R1"], + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R2'], + ]); + $self->assert(exists $res->[0][1]{updated}{$mboxA}); + $self->assert(exists $res->[0][1]{updated}{$mboxB}); + my %roleByMbox = map { $_->{id} => $_->{role} } @{$res->[1][1]{list}}; + $self->assert_deep_equals({ + $inbox => 'inbox', + $mboxA => undef, + $mboxB => 'trash', + }, \%roleByMbox); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_protected_destroy b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_protected_destroy new file mode 100644 index 0000000000..023ede3101 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_role_protected_destroy @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_role_protected_destroy + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + xlog "create protected and unprotected roles"; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R1'], + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + role => 'drafts', + }, + mboxB => { + name => 'B', + role => 'xspecialuse', + }, + }, + }, "R2"], + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R3'], + ]); + my $inbox = $res->[0][1]{list}[0]{id}; + $self->assert_not_null($inbox); + my $mboxA = $res->[1][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxA); + my $mboxB = $res->[1][1]{created}{mboxB}{id}; + $self->assert_not_null($mboxB); + my %roleByMbox = map { $_->{id} => $_->{role} } @{$res->[2][1]{list}}; + $self->assert_deep_equals({ + $inbox => 'inbox', + $mboxA => 'drafts', + $mboxB => 'xspecialuse', + }, \%roleByMbox); + + xlog "destroy protected and unprotected roles in one method"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [$mboxA, $mboxB], + }, 'R1'], + ]); + $self->assert_str_equals('serverFail', $res->[0][1]{notDestroyed}{$mboxA}{type}); + $self->assert_str_equals('serverFail', $res->[0][1]{notDestroyed}{$mboxB}{type}); + + xlog "destroy protected and unprotected roles in separate method"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + destroy => [$mboxA], + }, 'R1'], + ['Mailbox/set', { + destroy => [$mboxB], + }, 'R2'], + ['Mailbox/get', { + properties => ['role', 'name'], + }, 'R3'], + ]); + $self->assert_str_equals('serverFail', $res->[0][1]{notDestroyed}{$mboxA}{type}); + $self->assert_deep_equals([$mboxB], $res->[1][1]{destroyed}); + %roleByMbox = map { $_->{id} => $_->{role} } @{$res->[2][1]{list}}; + $self->assert_deep_equals({ + $inbox => 'inbox', + $mboxA => 'drafts', + }, \%roleByMbox); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_shared b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_shared new file mode 100644 index 0000000000..b7874bd6b2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_shared @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_shared + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # Create account + $self->{instance}->create_user("foo"); + + # Share inbox but do not allow to create subfolders + $admintalk->setacl("user.foo", "cassandane", "lr") or die; + + xlog $self, "get mailboxes for foo account"; + my $res = $jmap->CallMethods([['Mailbox/get', { accountId => "foo" }, "R1"]]); + my $inboxId = $res->[0][1]{list}[0]{id}; + + my $update = ['Mailbox/set', { + accountId => "foo", + update => { + $inboxId => { + name => "UpdatedInbox", + } + } + }, "R1"]; + + xlog $self, "update shared INBOX (should fail)"; + $res = $jmap->CallMethods([ $update ]); + $self->assert(exists $res->[0][1]{notUpdated}{$inboxId}); + + xlog $self, "Add update ACL rights to shared INBOX"; + $admintalk->setacl("user.foo", "cassandane", "lrw") or die; + + xlog $self, "update shared INBOX (should succeed)"; + $res = $jmap->CallMethods([ $update ]); + $self->assert(exists $res->[0][1]{updated}{$inboxId}); + + my $create = ['Mailbox/set', { + accountId => "foo", + create => { + "1" => { + name => "x", + } + } + }, "R1"]; + + xlog $self, "create mailbox child (should fail)"; + $res = $jmap->CallMethods([ $create ]); + $self->assert_not_null($res->[0][1]{notCreated}{1}); + + xlog $self, "Add update ACL rights to shared INBOX"; + $admintalk->setacl("user.foo", "cassandane", "lrwk") or die; + + xlog $self, "create mailbox child (should succeed)"; + $res = $jmap->CallMethods([ $create ]); + $self->assert_not_null($res->[0][1]{created}{1}); + my $childId = $res->[0][1]{created}{1}{id}; + + my $destroy = ['Mailbox/set', { + accountId => "foo", + destroy => [ $childId ], + }, 'R1' ]; + + xlog $self, "destroy shared mailbox child (should fail)"; + $res = $jmap->CallMethods([ $destroy ]); + $self->assert(exists $res->[0][1]{notDestroyed}{$childId}); + + xlog $self, "Add delete ACL rights"; + $admintalk->setacl("user.foo.x", "cassandane", "lrwkx") or die; + + xlog $self, "destroy shared mailbox child (should succeed)"; + $res = $jmap->CallMethods([ $destroy ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{destroyed}}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_sharewith b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_sharewith new file mode 100644 index 0000000000..57d5fb30c6 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_sharewith @@ -0,0 +1,99 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_sharewith + :min_version_3_3 :needs_component_jmap :NoAltNameSpace :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $admin = $self->{adminstore}->get_client(); + + my $using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail', + ]; + + my $inboxId = $self->getinbox()->{id}; + $self->assert_not_null($inboxId); + + $self->{instance}->create_user("sharee"); + + xlog $self, "Overwrite shareWith"; + my $res = $jmap->CallMethods([ + ['Mailbox/get', { + ids => [$inboxId], + properties => ['shareWith'], + }, 'R1'], + ['Mailbox/set', { + update => { + $inboxId => { + shareWith => { + sharee => { + mayRead => JSON::true, + }, + }, + }, + }, + }, 'R2'], + ['Mailbox/get', { + ids => [$inboxId], + properties => ['shareWith'], + }, 'R3'], + ], $using); + + $self->assert_null($res->[0][1]{list}[0]{shareWith}); + $self->assert_deep_equals({ + sharee => { + mayRead => JSON::true, + mayWrite => JSON::false, + mayAdmin => JSON::false, + }, + }, $res->[2][1]{list}[0]{shareWith}); + my $acl = $admin->getacl("user.cassandane"); + my %map = @$acl; + $self->assert_str_equals('lr', $map{sharee}); + + xlog $self, "Patch shareWith"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $inboxId => { + 'shareWith/sharee/mayWrite' => JSON::true, + }, + }, + }, 'R1'], + ['Mailbox/get', { + ids => [$inboxId], + properties => ['shareWith'], + }, 'R2'], + ], $using); + + $self->assert_deep_equals({ + sharee => { + mayRead => JSON::true, + mayWrite => JSON::true, + mayAdmin => JSON::false, + }, + }, $res->[1][1]{list}[0]{shareWith}); + $acl = $admin->getacl("user.cassandane"); + %map = @$acl; + $self->assert_str_equals('lrswitedn', $map{sharee}); + + xlog $self, "Patch shareWith with unknown right"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $inboxId => { + 'shareWith/sharee/unknownRight' => JSON::true, + }, + }, + }, 'R1'], + ], $using); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{$inboxId}{type}); + $self->assert_deep_equals(['shareWith/sharee/unknownRight'], + $res->[0][1]{notUpdated}{$inboxId}{properties}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_sharewith_acl b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_sharewith_acl new file mode 100644 index 0000000000..16dcbf2fdb --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_sharewith_acl @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_sharewith_acl + :min_version_3_5 :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $admin = $self->{adminstore}->get_client(); + my $imap = $self->{store}->get_client(); + + $imap->create("A") or die; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { + filter => { + name => 'A', + }, + }, 'R1'], + ]); + my $mboxId = $res->[0][1]{ids}[0]; + $self->assert_not_null($mboxId); + + $admin->create("user.sharee"); + + my @testCases = ({ + rights => { + mayAdmin => JSON::true, + }, + acl => 'kxca', + }, { + rights => { + mayWrite => JSON::true, + }, + acl => 'switedn', + }, { + rights => { + mayRead => JSON::true, + }, + acl => 'lr', + }); + + foreach(@testCases) { + + xlog "Run test for acl $_->{acl}"; + + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxId => { + shareWith => { + sharee => $_->{rights}, + }, + }, + }, + }, 'R1'], + ['Mailbox/get', { + ids => [$mboxId], + properties => ['shareWith'], + }, 'R2'], + ], [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', + 'https://cyrusimap.org/ns/jmap/mail' + ]) ; + + $_->{wantRights} ||= $_->{rights}; + + my %mergedrights = (( + mayAdmin => JSON::false, + mayWrite => JSON::false, + mayRead => JSON::false, + ), %{$_->{wantRights}}); + + $self->assert_deep_equals(\%mergedrights, + $res->[1][1]{list}[0]{shareWith}{sharee}); + my %acl = @{$admin->getacl("user.cassandane.A")}; + $self->assert_str_equals($_->{acl}, $acl{sharee}); + } +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_destroy b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_destroy new file mode 100644 index 0000000000..671e1ba948 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_destroy @@ -0,0 +1,38 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_subscriptions_destroy + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + A => { + name => 'A', parentId => undef, role => undef, + }, + }, + }, "R1"]]); + my $idA =$res->[0][1]{created}{A}{id}; + $self->assert_not_null($idA); + + my $subdata = $imap->list([qw(SUBSCRIBED)], "", "*"); + $self->assert_num_equals(0, scalar @{$subdata}); + + $imap->subscribe("INBOX.A") || die; + + $subdata = $imap->list([qw(SUBSCRIBED)], "", "*"); + $self->assert_num_equals(1, scalar @{$subdata}); + $self->assert_str_equals('INBOX.A', $subdata->[0][2]); + + $res = $jmap->CallMethods([['Mailbox/set', { + destroy => [$idA], + }, "R1"]]); + $self->assert_str_equals($idA, $res->[0][1]{destroyed}[0]); + + $subdata = $imap->list([qw(SUBSCRIBED)], "", "*"); + $self->assert_num_equals(0, scalar @{$subdata}); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_rename b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_rename new file mode 100644 index 0000000000..3bd7f36912 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_rename @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_subscriptions_rename + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + A => { + name => 'A', parentId => undef, role => undef, + }, + }, + }, "R1"]]); + my $idA =$res->[0][1]{created}{A}{id}; + $self->assert_not_null($idA); + $imap->subscribe("INBOX.A") || die; + + my $subdata = $imap->list([qw(SUBSCRIBED)], "", "*"); + $self->assert_num_equals(1, scalar @{$subdata}); + $self->assert_str_equals('INBOX.A', $subdata->[0][2]); + + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $idA => { + name => 'B', + }, + }, + }, "R1"]]); + $subdata = $imap->list([qw(SUBSCRIBED)], "", "*"); + $self->assert_num_equals(1, scalar @{$subdata}); + $self->assert_str_equals('INBOX.B', $subdata->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_rename_children b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_rename_children new file mode 100644 index 0000000000..9ae945a8d3 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_subscriptions_rename_children @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_subscriptions_rename_children + :min_version_3_1 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + A => { + name => 'A', parentId => undef, role => undef, + }, + C => { + name => 'C', parentId => '#A', role => undef, + }, + }, + }, "R1"]]); + my $idA =$res->[0][1]{created}{A}{id}; + $self->assert_not_null($idA); + $imap->subscribe("INBOX.A.C") || die; + + my $subdata = $imap->list([qw(SUBSCRIBED)], "", "*"); + $self->assert_num_equals(1, scalar @{$subdata}); + $self->assert_str_equals('INBOX.A.C', $subdata->[0][2]); + + $res = $jmap->CallMethods([['Mailbox/set', { + update => { + $idA => { + name => 'B', + }, + }, + }, "R1"]]); + $subdata = $imap->list([qw(SUBSCRIBED)], "", "*"); + $self->assert_num_equals(1, scalar @{$subdata}); + $self->assert_str_equals('INBOX.B.C', $subdata->[0][2]); +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_set_update_serverset_props b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_update_serverset_props new file mode 100644 index 0000000000..80211fc952 --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_set_update_serverset_props @@ -0,0 +1,111 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_set_update_serverset_props + :min_version_3_1 :needs_component_jmap +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create mailbox"; + my $res = $jmap->CallMethods([ + ['Mailbox/set', { + create => { + mboxA => { + name => 'A', + role => undef, + }, + }, + }, 'R1'], + ['Mailbox/get', { + ids => ['#mboxA'], + }, 'R2'], + ]); + my $mboxIdA = $res->[0][1]{created}{mboxA}{id}; + $self->assert_not_null($mboxIdA); + my $mboxA = $res->[1][1]{list}[0]; + $self->assert_not_null($mboxA); + + xlog "update with matching server-set properties"; + $mboxA->{name} = 'XA'; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxIdA => $mboxA, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$mboxIdA}); + + xlog "update with matching server-set properties"; + # Assert default values before we change them. + $self->assert_num_equals(0, $mboxA->{totalEmails}); + $self->assert_num_equals(0, $mboxA->{unreadEmails}); + $self->assert_num_equals(0, $mboxA->{totalThreads}); + $self->assert_num_equals(0, $mboxA->{unreadThreads}); + $self->assert_deep_equals({ + mayReadItems => JSON::true, + mayAddItems => JSON::true, + mayRemoveItems => JSON::true, + mayCreateChild => JSON::true, + mayDelete => JSON::true, + maySubmit => JSON::true, + maySetSeen => JSON::true, + maySetKeywords => JSON::true, + mayAdmin => JSON::true, + mayRename => JSON::true, + }, $mboxA->{myRights}); + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxIdA => { + totalEmails => 1, + unreadEmails => 1, + totalThreads => 1, + unreadThreads => 1, + myRights => { + mayReadItems => JSON::false, + mayAddItems => JSON::false, + mayRemoveItems => JSON::false, + mayCreateChild => JSON::false, + mayDelete => JSON::false, + maySubmit => JSON::false, + maySetSeen => JSON::false, + maySetKeywords => JSON::false, + mayAdmin => JSON::false, + mayRename => JSON::false, + }, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{$mboxIdA}{type}); + my @wantInvalidProps = ( + 'myRights', + 'totalEmails', + 'unreadEmails', + 'totalThreads', + 'unreadThreads', + ); + my @gotInvalidProps = @{$res->[0][1]{notUpdated}{$mboxIdA}{properties}}; + @wantInvalidProps = sort @wantInvalidProps; + @gotInvalidProps = sort @gotInvalidProps; + $self->assert_deep_equals(\@wantInvalidProps, \@gotInvalidProps); + + xlog "update with unknown mailbox right"; + $res = $jmap->CallMethods([ + ['Mailbox/set', { + update => { + $mboxIdA => { + 'myRights/mayXxx' => JSON::false, + }, + }, + }, 'R1'], + ]); + $self->assert_str_equals('invalidProperties', + $res->[0][1]{notUpdated}{$mboxIdA}{type}); + $self->assert_deep_equals(['myRights'], + $res->[0][1]{notUpdated}{$mboxIdA}{properties}) +} diff --git a/cassandane/tiny-tests/JMAPMailbox/mailbox_trash_counts_ondelete b/cassandane/tiny-tests/JMAPMailbox/mailbox_trash_counts_ondelete new file mode 100644 index 0000000000..3cb15e8c8d --- /dev/null +++ b/cassandane/tiny-tests/JMAPMailbox/mailbox_trash_counts_ondelete @@ -0,0 +1,114 @@ +#!perl +use Cassandane::Tiny; + +sub test_mailbox_trash_counts_ondelete + :min_version_3_3 :needs_component_jmap :NoAltNameSpace +{ + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + $imap->uid(1); + + xlog "Set up mailboxes"; + my $res = $jmap->CallMethods([ + ['Mailbox/query', { }, 'R1'], + ['Mailbox/set', { + create => { + "a" => { name => "a", parentId => undef }, + "b" => { name => "b", parentId => undef }, + "trash" => { name => "Trash", parentId => undef, role => "trash" }, + }, + }, 'R2'], + ]); + my %ids = map { $_ => $res->[1][1]{created}{$_}{id} } + keys %{$res->[1][1]{created}}; + + xlog "Set up messages"; + my %raw = ( + A => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test A\r +EOF + B => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test B\r +EOF + C => <<"EOF", +From: \r +To: to\@local\r +Subject: test\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +Message-Id: \r +In-Reply-To: \r +\r +test C\r +EOF + D => <<"EOF", +From: \r +To: to\@local\r +Subject: test2\r +Message-Id: \r +Date: Wed, 7 Dec 2019 22:11:11 +1100\r +MIME-Version: 1.0\r +Content-Type: text/plain\r +\r +test D\r +EOF + ); + + # threads: + # T1: A B C + # T2: D + + xlog $self, "Set up all the emails in all the folders"; + $imap->append('INBOX.a', "(\\Seen)", $raw{A}) || die $@; + $imap->append('INBOX.a', "()", $raw{B}) || die $@; + $imap->append('INBOX.a', "(\\Seen)", $raw{C}) || die $@; + $imap->append('INBOX.a', "()", $raw{D}) || die $@; + + $self->_check_counts('Initial Test', + a => [ 4, 2, 2, 2 ], + b => [ 0, 0, 0, 0 ], + Trash => [ 0, 0, 0, 0 ], + ); + + xlog $self, "Move everything to trash"; + $imap->select("INBOX.a"); + $imap->move("1:*", "INBOX.Trash"); + $self->_check_counts('After move all to Trash', + a => [ 0, 0, 0, 0 ], + b => [ 0, 0, 0, 0 ], + Trash => [ 4, 2, 2, 2 ], + ); + + xlog $self, "Destroy everything via JMAP"; + + $res = $jmap->CallMethods([['Email/query', {}, "R1"]]); + my $ids = $res->[0][1]->{ids}; + $res = $jmap->CallMethods([['Email/set', { destroy => $ids }, "R1"]]); + + $self->_check_counts('After Destroy Everything', + a => [ 0, 0, 0, 0 ], + b => [ 0, 0, 0, 0 ], + Trash => [ 0, 0, 0, 0 ], + ); +} diff --git a/cassandane/tiny-tests/JMAPSieve/deliver-compile b/cassandane/tiny-tests/JMAPSieve/deliver-compile new file mode 100644 index 0000000000..4fb7258aaf --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/deliver-compile @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_compile + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $target = "INBOX.target"; + + xlog $self, "Create the target folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($target) + or die "Cannot create $target: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Install a sieve script filing all mail into the target folder"; + my $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => "require [\"fileinto\"];\r\nfileinto \"$target\";\r\n"}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => JSON::null, + blobId => "#A" + } + }, + onSuccessActivateScript => "#1" + }, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_equals(JSON::true, $res->[1][1]{created}{1}{isActive}); + $self->assert_null($res->[1][1]{updated}); + $self->assert_null($res->[1][1]{destroyed}); + + my $id = $res->[1][1]{created}{"1"}{id}; + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Delete the compiled bytecode"; + my $sieve_dir = $self->{instance}->get_sieve_script_dir('cassandane'); + my $fname = "$sieve_dir/$id.bc"; + unlink $fname or die "Cannot unlink $fname: $!"; + + sleep 1; # so the two deliveries get different syslog timestamps + + xlog $self, "Deliver another message - lmtpd should rebuild the missing bytecode"; + my $msg2 = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg2); + + xlog $self, "Check that both messages made it to the target"; + $self->{store}->set_folder($target); + $self->check_messages({ 1 => $msg1, 2 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/JMAPSieve/getmetadata b/cassandane/tiny-tests/JMAPSieve/getmetadata new file mode 100644 index 0000000000..b9d574e829 --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/getmetadata @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_getmetadata + :min_version_3_6 :needs_component_sieve :needs_component_jmap + :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "Install a no-op sieve script"; + my $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => "keep;\r\n"}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => JSON::null, + blobId => "#A" + } + }, + onSuccessActivateScript => "#1" + }, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_equals(JSON::true, $res->[1][1]{created}{1}{isActive}); + $self->assert_null($res->[1][1]{updated}); + $self->assert_null($res->[1][1]{destroyed}); + + my $id = $res->[1][1]{created}{"1"}{id}; + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + my $imaptalk = $self->{store}->get_client(); + + # LIST doesn't show DAV mailboxes + $res = $imaptalk->list('', '*'); + $self->assert_mailbox_structure($res, '.', { + 'INBOX' => [ '\\HasNoChildren' ], + }); + + # better not see any DAV mailboxes via GETMETADATA either + $res = $imaptalk->getmetadata('*', '/private/comment'); + $self->assert_deep_equals({ 'INBOX' => { '/private/comment' => undef } }, + $res); +} diff --git a/cassandane/tiny-tests/JMAPSieve/legacy-sieve-replication b/cassandane/tiny-tests/JMAPSieve/legacy-sieve-replication new file mode 100644 index 0000000000..5f83c9e449 --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/legacy-sieve-replication @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_legacy_sieve_replication + :min_version_3_9 :MailboxLegacyDirs :ImmediateDelete +{ + my ($self) = @_; + + # can't do anything without captured syslog + if (!$self->{instance}->{have_syslog_replacement}) { + xlog $self, "can't examine syslog, test is useless"; + return; + } + + # create #sieve mailbox + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->_imap_cmd('CREATE', 0, '', + "user.cassandane.#sieve", [ 'TYPE', 'SIEVE' ]); + + # extract #sieve mailbox containing legacy Sieve code + $self->{instance}->unpackfile(abs_path('data/cyrus/legacy_sieve.tar.gz'), + 'data/user/cassandane'); + + # this will fail due to replica being unable to compile the legacy Sieve + eval { + $self->run_replication(); + }; + + # sync_client should have logged the failure + if ($self->{instance}->{have_syslog_replacement}) { + my @mlines = $self->{instance}->getsyslog(); + $self->assert_matches(qr/IOERROR: user replication failed/, "@mlines"); + $self->assert_matches(qr/MAILBOX received NO response: IMAP_SYNC_BADSIEVE/, "@mlines"); + } + + # immediately delete the #sieve mailbox to prevent _check_sanity() + # from complaining about INCONSISTENCIES and failing the test + $admintalk = $self->{adminstore}->get_client(); + $admintalk->delete("user.cassandane.#sieve"); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-blind-replace-active b/cassandane/tiny-tests/JMAPSieve/sieve-blind-replace-active new file mode 100644 index 0000000000..611379a664 --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-blind-replace-active @@ -0,0 +1,120 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_blind_replace_active + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create initial script"; + my $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => "keep;"}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => JSON::null, + blobId => "#A" + } + }, + onSuccessActivateScript => "#1" + }, "R1"], + ['SieveScript/query', { + filter => { + isActive => JSON::false, + } + }, "R2"], + ['SieveScript/set', { + '#destroy' => { + resultOf => 'R2', + name => 'SieveScript/query', + path => '/ids' + } + }, "R3"], + ['SieveScript/get', { + }, "R4"] + ]); + $self->assert_not_null($res); + $self->assert_equals(JSON::true, $res->[1][1]{created}{1}{isActive}); + $self->assert_null($res->[1][1]{updated}); + $self->assert_null($res->[1][1]{destroyed}); + + my $id1 = $res->[1][1]{created}{"1"}{id}; + + $self->assert_deep_equals([], $res->[2][1]{ids}); + + $self->assert_null($res->[3][1]{created}); + $self->assert_null($res->[3][1]{updated}); + $self->assert_null($res->[3][1]{destroyed}); + + $self->assert_num_equals(1, scalar @{$res->[4][1]{list}}); + $self->assert_str_equals($id1, $res->[4][1]{list}[0]{name}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[0]{isActive}); + + my $blobId = $res->[4][1]{list}[0]{blobId}; + + xlog $self, "download script blob"; + $res = $self->download('cassandane', $blobId); + $self->assert_str_equals('200', $res->{status}); + $self->assert_str_equals('keep;', $res->{content}); + + xlog "replace active script"; + $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "B" => { data => [{'data:asText' => "discard;"}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "2" => { + name => JSON::null, + blobId => "#B" + } + }, + onSuccessActivateScript => "#2" + }, "R1"], + ['SieveScript/query', { + filter => { + isActive => JSON::false, + } + }, "R2"], + ['SieveScript/set', { + '#destroy' => { + resultOf => 'R2', + name => 'SieveScript/query', + path => '/ids' + } + }, "R3"], + ['SieveScript/get', { + }, "R4"] + ]); + $self->assert_not_null($res); + $self->assert_equals(JSON::true, $res->[1][1]{created}{2}{isActive}); + $self->assert_equals(JSON::false, $res->[1][1]{updated}{$id1}{isActive}); + $self->assert_null($res->[1][1]{destroyed}); + + my $id2 = $res->[1][1]{created}{"2"}{id}; + + $self->assert_deep_equals([$id1], $res->[2][1]{ids}); + + $self->assert_null($res->[3][1]{created}); + $self->assert_null($res->[3][1]{updated}); + $self->assert_deep_equals([$id1], $res->[3][1]{destroyed}); + + $self->assert_num_equals(1, scalar @{$res->[4][1]{list}}); + $self->assert_str_equals($id2, $res->[4][1]{list}[0]{name}); + $self->assert_equals(JSON::true, $res->[4][1]{list}[0]{isActive}); + + $blobId = $res->[4][1]{list}[0]{blobId}; + + xlog $self, "download script blob"; + $res = $self->download('cassandane', $blobId); + $self->assert_str_equals('200', $res->{status}); + $self->assert_str_equals('discard;', $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-get b/cassandane/tiny-tests/JMAPSieve/sieve-get new file mode 100644 index 0000000000..ee8216b658 --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-get @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_get + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + my $target = "INBOX.target"; + + xlog $self, "Install a sieve script filing all mail into a folder"; + my $script = <{instance}->install_sieve_script($script); + + xlog "get all scripts"; + my $res = $jmap->CallMethods([ + ['SieveScript/get', { + properties => ['name', 'isActive'], + }, "R1"]]); + $self->assert_not_null($res); + $self->assert_str_equals('SieveScript/get', $res->[0][0]); + $self->assert_str_equals('R1', $res->[0][2]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('test1', $res->[0][1]{list}[0]{name}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{isActive}); + + my $id = $res->[0][1]{list}[0]{id}; + + xlog "get script by id"; + $res = $jmap->CallMethods([ + ['SieveScript/get', { + ids => [$id], + }, "R2"]]); + $self->assert_not_null($res); + $self->assert_str_equals('SieveScript/get', $res->[0][0]); + $self->assert_str_equals('R2', $res->[0][2]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{list}}); + $self->assert_str_equals('test1', $res->[0][1]{list}[0]{name}); + $self->assert_equals(JSON::true, $res->[0][1]{list}[0]{isActive}); + + my $blobId = $res->[0][1]{list}[0]{blobId}; + + xlog $self, "download script blob"; + $res = $self->download('cassandane', $blobId); + $self->assert_str_equals('200', $res->{status}); + $self->assert_str_equals($script, $res->{content}); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-query b/cassandane/tiny-tests/JMAPSieve/sieve-query new file mode 100644 index 0000000000..ef83187c3b --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-query @@ -0,0 +1,160 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_query + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create script"; + my $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => "keep;"}] }, + "B" => { data => [{'data:asText' => "discard;"}] }, + "C" => { data => [{'data:asText' => "redirect \"test\@example.com\";"}] }, + "D" => { data => [{'data:asText' => "stop;"}] }, + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => "foo", + blobId => "#A" + }, + "2" => { + name => "bar", + blobId => "#B" + }, + "3" => { + name => "pooh", + blobId => "#C" + }, + "4" => { + name => "abc", + blobId => "#D" + } + }, + onSuccessActivateScript => "#1" + }, "R1"], + ]); + $self->assert_not_null($res); + my $id1 = $res->[1][1]{created}{"1"}{id}; + my $id2 = $res->[1][1]{created}{"2"}{id}; + my $id3 = $res->[1][1]{created}{"3"}{id}; + my $id4 = $res->[1][1]{created}{"4"}{id}; + + xlog $self, "get unfiltered list"; + $res = $jmap->CallMethods([ ['SieveScript/query', { }, "R1"] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + + xlog $self, "sort by name"; + $res = $jmap->CallMethods([ ['SieveScript/query', { + sort => [{ + property => 'name', + }] + }, "R1"] ]); + $self->assert_num_equals(4, $res->[0][1]{total}); + $self->assert_num_equals(4, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id4, $res->[0][1]{ids}[0]); + $self->assert_str_equals($id2, $res->[0][1]{ids}[1]); + $self->assert_str_equals($id1, $res->[0][1]{ids}[2]); + $self->assert_str_equals($id3, $res->[0][1]{ids}[3]); + + xlog $self, "filter by isActive"; + $res = $jmap->CallMethods([ ['SieveScript/query', { + filter => { + isActive => JSON::true, + } + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id1, $res->[0][1]{ids}[0]); + + xlog $self, "filter by not isActive"; + $res = $jmap->CallMethods([ ['SieveScript/query', { + filter => { + isActive => JSON::false, + } + }, "R1"] ]); + $self->assert_num_equals(3, $res->[0][1]{total}); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + my %scriptIds = map { $_ => 1 } @{$res->[0][1]{ids}}; + $self->assert_not_null($scriptIds{$id2}); + $self->assert_not_null($scriptIds{$id3}); + $self->assert_not_null($scriptIds{$id4}); + + xlog $self, "filter by name containing 'oo', sorted descending"; + $res = $jmap->CallMethods([ ['SieveScript/query', { + filter => { + name => 'oo', + }, + sort => [{ + property => 'name', + isAscending => JSON::false, + }] + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id3, $res->[0][1]{ids}[0]); + $self->assert_str_equals($id1, $res->[0][1]{ids}[1]); + + xlog $self, "filter by name not containing 'oo'"; + $res = $jmap->CallMethods([ ['SieveScript/query', { + filter => { + operator => 'NOT', + conditions => [{ + name => 'oo', + }] + }, + }, "R1"] ]); + $self->assert_num_equals(2, $res->[0][1]{total}); + $self->assert_num_equals(2, scalar @{$res->[0][1]{ids}}); + %scriptIds = map { $_ => 1 } @{$res->[0][1]{ids}}; + $self->assert_not_null($scriptIds{$id2}); + $self->assert_not_null($scriptIds{$id4}); + + xlog $self, "filter by name containing 'oo' and inactive"; + $res = $jmap->CallMethods([ ['SieveScript/query', { + filter => { + operator => 'AND', + conditions => [{ + name => 'oo', + isActive => JSON::false, + }] + }, + }, "R1"] ]); + $self->assert_num_equals(1, $res->[0][1]{total}); + $self->assert_num_equals(1, scalar @{$res->[0][1]{ids}}); + $self->assert_str_equals($id3, $res->[0][1]{ids}[0]); + + xlog $self, "filter by name not containing 'oo' or active"; + $res = $jmap->CallMethods([ ['SieveScript/query', { + filter => { + operator => 'OR', + conditions => [ + { + operator => 'NOT', + conditions => [{ + name => 'oo', + }] + }, + { + isActive => JSON::true, + }] + }, + sort => [{ + property => 'name', + isAscending => JSON::true, + }] + }, "R1"] ]); + $self->assert_num_equals(3, $res->[0][1]{total}); + $self->assert_num_equals(3, scalar @{$res->[0][1]{ids}}); + %scriptIds = map { $_ => 1 } @{$res->[0][1]{ids}}; + $self->assert_not_null($scriptIds{$id1}); + $self->assert_not_null($scriptIds{$id2}); + $self->assert_not_null($scriptIds{$id4}); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-set b/cassandane/tiny-tests/JMAPSieve/sieve-set new file mode 100644 index 0000000000..5e8595f92e --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-set @@ -0,0 +1,160 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_set + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $script1 = <{jmap}; + + my $res = $jmap->Upload($script1, "application/sieve"); + my $blobid = $res->{blobId}; + + xlog "create script"; + $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => $script2}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => "foo", + blobId => $blobid + }, + "2" => { + name => JSON::null, + blobId => "#A" + } + }, + onSuccessActivateScript => "#1" + }, "R1"], + ['SieveScript/get', { + 'ids' => [ '#1', '#2' ] + }, "R2"] + ]); + $self->assert_not_null($res); + $self->assert_equals(JSON::true, $res->[1][1]{created}{1}{isActive}); + $self->assert_equals(JSON::false, $res->[1][1]{created}{2}{isActive}); + + my $id1 = $res->[1][1]{created}{"1"}{id}; + my $id2 = $res->[1][1]{created}{"2"}{id}; + + $self->assert_num_equals(2, scalar @{$res->[2][1]{list}}); + $self->assert_str_equals('foo', $res->[2][1]{list}[0]{name}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{isActive}); + $self->assert_str_equals($id2, $res->[2][1]{list}[1]{name}); + $self->assert_equals(JSON::false, $res->[2][1]{list}[1]{isActive}); + + xlog "attempt to create script with same name"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + create => { + "1" => { + name => "foo", + blobId => $blobid + } + }, + }, "R1"], + ['SieveScript/get', { + }, "R2"] + ]); + $self->assert_not_null($res); + $self->assert_null($res->[0][1]{created}); + $self->assert_str_equals('alreadyExists', $res->[0][1]{notCreated}{1}{type}); + $self->assert_num_equals(2, scalar @{$res->[1][1]{list}}); + + xlog "rename and deactivate script"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + update => { + $id1 => { + name => "bar" + } + }, + onSuccessDeactivatescript => JSON::true + }, "R3"] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + $self->assert_equals(JSON::false, $res->[0][1]{updated}{$id1}{isActive}); + + xlog "rewrite one script and activate another"; + $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "B" => { data => [{'data:asText' => $script3}] } + } + }, "R0"], + ['SieveScript/set', { + update => { + $id1 => { + blobId => "#B", + } + }, + onSuccessActivateScript => $id2 + }, "R4"], + ]); + $self->assert_not_null($res->[1][1]{updated}); + $self->assert_not_null($res->[1][1]{updated}{$id1}{blobId}); + $self->assert_equals(JSON::true, $res->[1][1]{updated}{$id2}{isActive}); + $self->assert_null($res->[1][1]{notUpdated}); + + xlog "change active script"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + onSuccessActivateScript => $id1 + }, "R4"], + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_equals(JSON::true, $res->[0][1]{updated}{$id1}{isActive}); + $self->assert_equals(JSON::false, $res->[0][1]{updated}{$id2}{isActive}); + $self->assert_null($res->[0][1]{notUpdated}); + + xlog "attempt to delete active script"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + destroy => [ $id1 ], + }, "R6"], + ['SieveScript/get', { + }, "R7"] + ]); + $self->assert_null($res->[0][1]{destroyed}); + $self->assert_not_null($res->[0][1]{notDestroyed}); + $self->assert_num_equals(2, scalar @{$res->[1][1]{list}}); + + xlog "delete active script"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + onSuccessDeactivatescript => JSON::true + }, "R8"], + ['SieveScript/set', { + destroy => [ $id1 ], + }, "R8.5"], + ['SieveScript/get', { + }, "R9"] + ]); + $self->assert_equals(JSON::false, $res->[0][1]{updated}{$id1}{isActive}); + $self->assert_not_null($res->[1][1]{destroyed}); + $self->assert_null($res->[1][1]{notDestroyed}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{list}}); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-set-bad-script b/cassandane/tiny-tests/JMAPSieve/sieve-set-bad-script new file mode 100644 index 0000000000..02d407f2a0 --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-set-bad-script @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_set_bad_script + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "create bad script"; + my $res = $jmap->Upload("keepme;", "application/sieve"); + my $blobid = $res->{blobId}; + + $res = $jmap->CallMethods([ + ['SieveScript/set', { + create => { + "1" => { + name => "foo", + blobId => $blobid + } + } + }, "R1"] + ]); + $self->assert_not_null($res); + $self->assert_null($res->[0][1]{created}); + $self->assert_str_equals('invalidScript', $res->[0][1]{notCreated}{1}{type}); + + xlog "update bad script"; + $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => "keep;"}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => "foo", + blobId => "#A" + } + }, + update => { + "#1" => { + blobId => $blobid + } + }, + destroy => [ "#1" ] + }, "R2"] + ]); + $self->assert_not_null($res); + + my $id = $res->[1][1]{created}{"1"}{id}; + + $self->assert_null($res->[1][1]{updated}); + $self->assert_str_equals('invalidScript', $res->[1][1]{notUpdated}{$id}{type}); + $self->assert_not_null($res->[1][1]{destroyed}); + $self->assert_str_equals($id, $res->[1][1]{destroyed}[0]); + $self->assert_null($res->[1][1]{notDestroyed}); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-set-replication b/cassandane/tiny-tests/JMAPSieve/sieve-set-replication new file mode 100644 index 0000000000..e26c7bb8ca --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-set-replication @@ -0,0 +1,183 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_set_replication + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $script1 = <{jmap}; + + my $res = $jmap->Upload($script1, "application/sieve"); + my $blobid = $res->{blobId}; + + xlog "create script"; + $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => $script2}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => "foo", + blobId => $blobid + }, + "2" => { + name => JSON::null, + blobId => "#A" + } + }, + onSuccessActivateScript => "#1" + }, "R1"], + ['SieveScript/get', { + 'ids' => [ '#1', '#2' ] + }, "R2"] + ]); + $self->assert_not_null($res); + $self->assert_equals(JSON::true, $res->[1][1]{created}{1}{isActive}); + $self->assert_equals(JSON::false, $res->[1][1]{created}{2}{isActive}); + + my $id1 = $res->[1][1]{created}{"1"}{id}; + my $id2 = $res->[1][1]{created}{"2"}{id}; + + $self->assert_num_equals(2, scalar @{$res->[2][1]{list}}); + $self->assert_str_equals('foo', $res->[2][1]{list}[0]{name}); + $self->assert_equals(JSON::true, $res->[2][1]{list}[0]{isActive}); + $self->assert_str_equals($id2, $res->[2][1]{list}[1]{name}); + $self->assert_equals(JSON::false, $res->[2][1]{list}[1]{isActive}); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog "attempt to create script with same name"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + create => { + "1" => { + name => "foo", + blobId => $blobid + } + }, + }, "R1"], + ['SieveScript/get', { + }, "R2"] + ]); + $self->assert_not_null($res); + $self->assert_null($res->[0][1]{created}); + $self->assert_str_equals('alreadyExists', $res->[0][1]{notCreated}{1}{type}); + $self->assert_num_equals(2, scalar @{$res->[1][1]{list}}); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog "rename and deactivate script"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + update => { + $id1 => { + name => "bar" + } + }, + # legacy deactivation prior to onSuccessDeactivateScript + onSuccessActivateScript => JSON::null + }, "R3"] + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_null($res->[0][1]{notUpdated}); + $self->assert_equals(JSON::false, $res->[0][1]{updated}{$id1}{isActive}); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog "rewrite one script and activate another"; + $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "B" => { data => [{'data:asText' => $script3}] } + } + }, "R0"], + ['SieveScript/set', { + update => { + $id1 => { + blobId => "#B", + } + }, + onSuccessActivateScript => $id2 + }, "R4"], + ]); + $self->assert_not_null($res->[1][1]{updated}); + $self->assert_not_null($res->[1][1]{updated}{$id1}{blobId}); + $self->assert_equals(JSON::true, $res->[1][1]{updated}{$id2}{isActive}); + $self->assert_null($res->[1][1]{notUpdated}); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog "change active script"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + onSuccessActivateScript => $id1 + }, "R4"], + ]); + $self->assert_not_null($res->[0][1]{updated}); + $self->assert_equals(JSON::true, $res->[0][1]{updated}{$id1}{isActive}); + $self->assert_equals(JSON::false, $res->[0][1]{updated}{$id2}{isActive}); + $self->assert_null($res->[0][1]{notUpdated}); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog "attempt to delete active script"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + destroy => [ $id1 ], + }, "R6"], + ['SieveScript/get', { + }, "R7"] + ]); + $self->assert_null($res->[0][1]{destroyed}); + $self->assert_not_null($res->[0][1]{notDestroyed}); + $self->assert_num_equals(2, scalar @{$res->[1][1]{list}}); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog "delete active script"; + $res = $jmap->CallMethods([ + ['SieveScript/set', { + # legacy deactivation prior to onSuccessDeactivateScript + onSuccessActivateScript => JSON::null + }, "R8"], + ['SieveScript/set', { + destroy => [ $id1 ], + }, "R8.5"], + ['SieveScript/get', { + }, "R9"] + ]); + $self->assert_equals(JSON::false, $res->[0][1]{updated}{$id1}{isActive}); + $self->assert_not_null($res->[1][1]{destroyed}); + $self->assert_null($res->[1][1]{notDestroyed}); + $self->assert_num_equals(1, scalar @{$res->[2][1]{list}}); + + $self->run_replication(); + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-test b/cassandane/tiny-tests/JMAPSieve/sieve-test new file mode 100644 index 0000000000..41575b1500 --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-test @@ -0,0 +1,76 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_test + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $script = <{jmap}; + + xlog "create script"; + my $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => $script}] } + } + }, "R0"], + ['SieveScript/set', { + create => { + "1" => { + name => "foo", + blobId => "#A" + } + } + }, "R1"] + ]); + $self->assert_not_null($res); + + my $scriptid = $res->[1][1]{created}{"1"}{blobId}; + + xlog "create email"; + $res = $jmap->CallMethods([['Mailbox/get', { properties => ["id"] }, "R1"]]); + my $inboxid = $res->[0][1]{list}[0]{id}; + + my $email = { + mailboxIds => { $inboxid => JSON::true }, + from => [ { name => "Yosemite Sam", email => "sam\@acme.local" } ] , + to => [ { name => "Bugs Bunny", email => "bugs\@acme.local" }, ], + subject => "Memo", + textBody => [{ partId => '1' }], + bodyValues => { '1' => { value => "Whoa!" }} + }; + + $res = $jmap->CallMethods([ + ['Email/set', { create => { "1" => $email }}, "R2"], + ]); + + my $emailid = $res->[0][1]{created}{"1"}{blobId}; + + xlog "test script"; + $res = $jmap->CallMethods([ + ['SieveScript/test', { + scriptBlobId => "$scriptid", + emailBlobIds => [ "$emailid" ], + envelope => JSON::null, + lastVacationResponse => JSON::null + }, "R3"] + ]); + $self->assert_not_null($res); + $self->assert_not_null($res->[0][1]{completed}); + $self->assert_str_equals('fileinto', + $res->[0][1]{completed}{$emailid}[0][0]); + $self->assert_str_equals('keep', + $res->[0][1]{completed}{$emailid}[1][0]); + $self->assert_null($res->[0][1]{notCompleted}); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-test-singlecommand b/cassandane/tiny-tests/JMAPSieve/sieve-test-singlecommand new file mode 100644 index 0000000000..e6022894d2 --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-test-singlecommand @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_test_singlecommand + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $email1 = <<'EOF'; +From: "Some Example Sender" +To: cassandane@example.com +Subject: test email +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email1 =~ s/\r?\n/\r\n/gs; + + my $email2 = <<'EOF'; +From: "Some Example Sender" +To: cassandane@example.com +Subject: Hello! +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email2 =~ s/\r?\n/\r\n/gs; + + my $script = <{jmap}; + + xlog "test script"; + my $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "1" => { data => [{'data:asText' => $email1}] }, + "3" => { data => [{'data:asText' => $email2}] }, + "2" => { data => [{'data:asText' => $script}] }, + }}, 'R0'], + ['SieveScript/test', { + emailBlobIds => [ '#1', 'foobar', '#3' ], + scriptBlobId => '#2', + envelope => { + mailFrom => { + email => 'foo@example.com', + parameters => JSON::null + }, + rcptTo => [ { + email => 'cassandane@example.com', + parameters => JSON::null + } ] + }, + lastVacationResponse => JSON::null + }, "R1"] + ]); + $self->assert_not_null($res); + + my $emailid1 = $res->[0][1]{created}{1}{blobId}; + my $emailid2 = $res->[0][1]{created}{3}{blobId}; + + $self->assert_not_null($res->[1][1]{completed}); + $self->assert_str_equals('fileinto', + $res->[1][1]{completed}{$emailid1}[0][0]); + $self->assert_str_equals('keep', + $res->[1][1]{completed}{$emailid1}[1][0]); + $self->assert_str_equals('vacation', + $res->[1][1]{completed}{$emailid2}[0][0]); + $self->assert_str_equals('keep', + $res->[1][1]{completed}{$emailid2}[1][0]); + + $self->assert_not_null($res->[1][1]{notCompleted}); + $self->assert_str_equals('blobNotFound', + $res->[1][1]{notCompleted}{foobar}{type}); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-test-upload b/cassandane/tiny-tests/JMAPSieve/sieve-test-upload new file mode 100644 index 0000000000..0f5eec40df --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-test-upload @@ -0,0 +1,91 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_test_upload + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $email1 = <<'EOF'; +From: "Some Example Sender" +To: cassandane@example.com +Subject: test email +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email1 =~ s/\r?\n/\r\n/gs; + + my $email2 = <<'EOF'; +From: "Some Example Sender" +To: cassandane@example.com +Subject: Hello! +Date: Wed, 7 Dec 2016 22:11:11 +1100 +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +This is a test email. +EOF + $email2 =~ s/\r?\n/\r\n/gs; + + my $script = <{jmap}; + + my $res = $jmap->Upload($email1, "message/rfc822"); + my $emailid1 = $res->{blobId}; + + $res = $jmap->Upload($email2, "message/rfc822"); + my $emailid2 = $res->{blobId}; + + $res = $jmap->Upload($script, "application/sieve"); + my $scriptid = $res->{blobId}; + + xlog "test script"; + $res = $jmap->CallMethods([ + ['SieveScript/test', { + emailBlobIds => [ $emailid1, 'foobar', $emailid2 ], + scriptBlobId => $scriptid, + envelope => { + mailFrom => { + email => 'foo@example.com', + parameters => JSON::null + }, + rcptTo => [ { + email => 'cassandane@example.com', + parameters => JSON::null + } ] + }, + lastVacationResponse => JSON::null + }, "R1"] + ]); + $self->assert_not_null($res); + + $self->assert_not_null($res->[0][1]{completed}); + $self->assert_str_equals('fileinto', + $res->[0][1]{completed}{$emailid1}[0][0]); + $self->assert_str_equals('keep', + $res->[0][1]{completed}{$emailid1}[1][0]); + $self->assert_str_equals('vacation', + $res->[0][1]{completed}{$emailid2}[0][0]); + $self->assert_str_equals('keep', + $res->[0][1]{completed}{$emailid2}[1][0]); + + $self->assert_not_null($res->[0][1]{notCompleted}); + $self->assert_str_equals('blobNotFound', + $res->[0][1]{notCompleted}{foobar}{type}); +} diff --git a/cassandane/tiny-tests/JMAPSieve/sieve-validate b/cassandane/tiny-tests/JMAPSieve/sieve-validate new file mode 100644 index 0000000000..9a39010e49 --- /dev/null +++ b/cassandane/tiny-tests/JMAPSieve/sieve-validate @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_validate + :min_version_3_3 :needs_component_sieve :needs_component_jmap :JMAPExtensions +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog "validating scripts"; + my $res = $jmap->CallMethods([ + ['Blob/upload', { + create => { + "A" => { data => [{'data:asText' => "keepme;"}] }, + "B" => { data => [{'data:asText' => "keep;"}] } + } + }, "R0"], + ['SieveScript/validate', { + blobId => JSON::null + }, "R1"], + ['SieveScript/validate', { + blobId => "#A", + blobId => JSON::null + }, "R2"], + ['SieveScript/validate', { + blobId => "#A" + }, "R3"], + ['SieveScript/validate', { + blobId => "#B" + }, "R4"], + ]); + $self->assert_not_null($res); + + $self->assert_str_equals("error", $res->[1][0]); + $self->assert_str_equals("invalidArguments", $res->[1][1]{type}); + + $self->assert_str_equals("error", $res->[2][0]); + $self->assert_str_equals("invalidArguments", $res->[2][1]{type}); + + $self->assert_str_equals("SieveScript/validate", $res->[3][0]); + $self->assert_str_equals("invalidScript", $res->[3][1]{error}{type}); + + $self->assert_str_equals("SieveScript/validate", $res->[4][0]); + $self->assert_null($res->[4][1]{error}); +} diff --git a/cassandane/tiny-tests/List/crossdomains b/cassandane/tiny-tests/List/crossdomains new file mode 100644 index 0000000000..e3e76e4b04 --- /dev/null +++ b/cassandane/tiny-tests/List/crossdomains @@ -0,0 +1,26 @@ +#!perl +use Cassandane::Tiny; + +sub test_crossdomains + :UnixHierarchySep :VirtDomains :CrossDomains :min_version_3_0 :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user/foo\@example.com"); + $admintalk->create("user/bar\@example.net"); + $admintalk->create("user/bar/Shared\@example.net"); # yay bogus domaining + + $admintalk->setacl("user/foo\@example.com", 'cassandane' => 'lrswipkxtecd'); + $admintalk->setacl("user/bar/Shared\@example.net", 'cassandane' => 'lrswipkxtecd'); + + my $data = $imaptalk->list("", "*"); + + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasNoChildren', + 'user/foo@example.com' => '\\HasNoChildren', + 'user/bar@example.net/Shared' => '\\HasNoChildren', + }); +} diff --git a/cassandane/tiny-tests/List/crossdomains_alt b/cassandane/tiny-tests/List/crossdomains_alt new file mode 100644 index 0000000000..54fbe1dae5 --- /dev/null +++ b/cassandane/tiny-tests/List/crossdomains_alt @@ -0,0 +1,26 @@ +#!perl +use Cassandane::Tiny; + +sub test_crossdomains_alt + :UnixHierarchySep :VirtDomains :CrossDomains :AltNamespace :min_version_3_0 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->create("user/foo\@example.com"); + $admintalk->create("user/bar\@example.net"); + $admintalk->create("user/bar/Shared\@example.net"); # yay bogus domaining + + $admintalk->setacl("user/foo\@example.com", 'cassandane' => 'lrswipkxtecd'); + $admintalk->setacl("user/bar/Shared\@example.net", 'cassandane' => 'lrswipkxtecd'); + + my $data = $imaptalk->list("", "*"); + + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasNoChildren', + 'Other Users/foo@example.com' => '\\HasNoChildren', + 'Other Users/bar@example.net/Shared' => '\\HasNoChildren', + }); +} diff --git a/cassandane/tiny-tests/List/delete_nounsubscribe b/cassandane/tiny-tests/List/delete_nounsubscribe new file mode 100644 index 0000000000..bb778c685b --- /dev/null +++ b/cassandane/tiny-tests/List/delete_nounsubscribe @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_delete_nounsubscribe + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( deltest deltest/sub1 deltest/sub2 )] ], + [ 'subscribe' => [qw( deltest deltest/sub2 )] ], + [ 'delete' => 'deltest/sub2' ], + ]); + + my $subdata = $imaptalk->list([qw(SUBSCRIBED)], "", "*"); + + $self->assert_mailbox_structure($subdata, '/', { + 'INBOX' => '\\Subscribed', + 'deltest' => [qw( \\Subscribed )], + 'deltest/sub2' => [qw( \\NonExistent \\Subscribed )], + }); +} diff --git a/cassandane/tiny-tests/List/delete_unsubscribe b/cassandane/tiny-tests/List/delete_unsubscribe new file mode 100644 index 0000000000..edcfaa47fb --- /dev/null +++ b/cassandane/tiny-tests/List/delete_unsubscribe @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_delete_unsubscribe + :UnixHierarchySep :AltNamespace :NoStartInstances :min_version_3_0 +{ + my ($self) = @_; + + $self->{instance}->{config}->set('delete_unsubscribe' => 'yes'); + $self->_start_instances(); + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( deltest deltest/sub1 deltest/sub2 )] ], + [ 'subscribe' => [qw( deltest deltest/sub2 )] ], + [ 'delete' => 'deltest/sub2' ], + ]); + + my $subdata = $imaptalk->list([qw(SUBSCRIBED)], "", "*"); + + $self->assert_mailbox_structure($subdata, '/', { + 'INBOX' => '\\Subscribed', + 'deltest' => '\\Subscribed', + }); +} diff --git a/cassandane/tiny-tests/List/dotuser_gh1875_novirt b/cassandane/tiny-tests/List/dotuser_gh1875_novirt new file mode 100644 index 0000000000..691a97eee6 --- /dev/null +++ b/cassandane/tiny-tests/List/dotuser_gh1875_novirt @@ -0,0 +1,29 @@ +#!perl +use Cassandane::Tiny; + +sub test_dotuser_gh1875_novirt + :UnixHierarchySep +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user/foo.bar"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo.bar"); + my $footalk = $foostore->get_client(); + + $footalk->create("INBOX/Drafts"); + $footalk->create("INBOX/Sent"); + $footalk->create("INBOX/Trash"); + + my $data = $footalk->list("", "*"); + + xlog $self, Dumper $data; + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX/Sent' => [qw( \\HasNoChildren )], + 'INBOX/Drafts' => [qw( \\HasNoChildren )], + 'INBOX/Trash' => [qw( \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/dotuser_gh1875_novirt_altns b/cassandane/tiny-tests/List/dotuser_gh1875_novirt_altns new file mode 100644 index 0000000000..5f2738a9ae --- /dev/null +++ b/cassandane/tiny-tests/List/dotuser_gh1875_novirt_altns @@ -0,0 +1,29 @@ +#!perl +use Cassandane::Tiny; + +sub test_dotuser_gh1875_novirt_altns + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user/foo.bar"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo.bar"); + my $footalk = $foostore->get_client(); + + $footalk->create("Drafts"); + $footalk->create("Sent"); + $footalk->create("Trash"); + + my $data = $footalk->list("", "*"); + + xlog $self, Dumper $data; + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Sent' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren )], + 'Trash' => [qw( \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/dotuser_gh1875_virt b/cassandane/tiny-tests/List/dotuser_gh1875_virt new file mode 100644 index 0000000000..c32aba6fc9 --- /dev/null +++ b/cassandane/tiny-tests/List/dotuser_gh1875_virt @@ -0,0 +1,29 @@ +#!perl +use Cassandane::Tiny; + +sub test_dotuser_gh1875_virt + :VirtDomains :UnixHierarchySep +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user/foo.bar\@example.com"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo.bar\@example.com"); + my $footalk = $foostore->get_client(); + + $footalk->create("INBOX/Drafts"); + $footalk->create("INBOX/Sent"); + $footalk->create("INBOX/Trash"); + + my $data = $footalk->list("", "*"); + + xlog $self, Dumper $data; + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasChildren )], + 'INBOX/Sent' => [qw( \\HasNoChildren )], + 'INBOX/Drafts' => [qw( \\HasNoChildren )], + 'INBOX/Trash' => [qw( \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/dotuser_gh1875_virt_altns b/cassandane/tiny-tests/List/dotuser_gh1875_virt_altns new file mode 100644 index 0000000000..7b70a3ae3e --- /dev/null +++ b/cassandane/tiny-tests/List/dotuser_gh1875_virt_altns @@ -0,0 +1,29 @@ +#!perl +use Cassandane::Tiny; + +sub test_dotuser_gh1875_virt_altns + :VirtDomains :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user/foo.bar\@example.com"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo.bar\@example.com"); + my $footalk = $foostore->get_client(); + + $footalk->create("Drafts"); + $footalk->create("Sent"); + $footalk->create("Trash"); + + my $data = $footalk->list("", "*"); + + xlog $self, Dumper $data; + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Sent' => [qw( \\HasNoChildren )], + 'Drafts' => [qw( \\HasNoChildren )], + 'Trash' => [qw( \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/empty_mailbox b/cassandane/tiny-tests/List/empty_mailbox new file mode 100644 index 0000000000..3ea5919155 --- /dev/null +++ b/cassandane/tiny-tests/List/empty_mailbox @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_empty_mailbox + :UnixHierarchySep +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", ""); + + $self->assert_mailbox_structure($data, '/', { + '' => [ '\\Noselect' ], + }); +} diff --git a/cassandane/tiny-tests/List/folder_at_novirtdomains b/cassandane/tiny-tests/List/folder_at_novirtdomains new file mode 100644 index 0000000000..8f3e5e7077 --- /dev/null +++ b/cassandane/tiny-tests/List/folder_at_novirtdomains @@ -0,0 +1,21 @@ +#!perl +use Cassandane::Tiny; + +sub test_folder_at_novirtdomains + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( foo@bar )] ], + ]); + + my $data = $imaptalk->list("", "%", "RETURN", [qw( CHILDREN )]); + + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasNoChildren', + 'foo@bar' => '\\HasNoChildren', + }); +} diff --git a/cassandane/tiny-tests/List/inbox_altnamespace b/cassandane/tiny-tests/List/inbox_altnamespace new file mode 100644 index 0000000000..5f4a1de24e --- /dev/null +++ b/cassandane/tiny-tests/List/inbox_altnamespace @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_inbox_altnamespace + :UnixHierarchySep :VirtDomains :CrossDomains :AltNamespace :min_version_3_0 :max_version_3_4 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + foreach my $Folder ("user/cassandane/INBOX/sub", "user/cassandane/AEARLY", + "user/cassandane/sub2", "user/cassandane/sub2/achild", + "user/cassandane/INBOX/very/deep/one", + "user/cassandane/not/so/deep", + # stuff you can't see + "user/cassandane/INBOX", + "user/cassandane/inbox", + "user/cassandane/inbox/subnobody") { + $admintalk->create($Folder); + $admintalk->setacl($Folder, 'cassandane' => 'lrswipkxtecd'); + } + + my $data = $imaptalk->list("", "*"); + + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasChildren', + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + 'AEARLY' => '\\HasNoChildren', + 'not/so/deep' => '\\HasNoChildren', + 'sub2' => '\\HasChildren', + 'sub2/achild' => '\\HasNoChildren', + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody' => '\\HasNoChildren', + }); + + my $data2 = $imaptalk->list("", "%"); + + $self->assert_mailbox_structure($data2, '/', { + 'INBOX' => '\\HasChildren', + 'AEARLY' => '\\HasNoChildren', + 'not' => '\\HasChildren \\Noselect', + 'sub2' => '\\HasChildren', + 'Alt Folders' => '\\HasChildren \\Noselect', + }); + + my $data3 = $imaptalk->list("", "INBOX/%"); + + $self->assert_mailbox_structure($data3, '/', { + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\HasChildren \\Noselect', + }); +} diff --git a/cassandane/tiny-tests/List/inbox_altnamespace_no_intermediates b/cassandane/tiny-tests/List/inbox_altnamespace_no_intermediates new file mode 100644 index 0000000000..107a2fbc6b --- /dev/null +++ b/cassandane/tiny-tests/List/inbox_altnamespace_no_intermediates @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_inbox_altnamespace_no_intermediates + :UnixHierarchySep :VirtDomains :CrossDomains :AltNamespace :min_version_3_5 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + foreach my $Folder ("user/cassandane/INBOX/sub", "user/cassandane/AEARLY", + "user/cassandane/sub2", "user/cassandane/sub2/achild", + "user/cassandane/INBOX/very/deep/one", + "user/cassandane/not/so/deep", + # stuff you can't see + "user/cassandane/INBOX", + "user/cassandane/inbox", + "user/cassandane/inbox/subnobody") { + $admintalk->create($Folder); + $admintalk->setacl($Folder, 'cassandane' => 'lrswipkxtecd'); + } + + my $data = $imaptalk->list("", "*"); + + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasChildren', + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\HasChildren', + 'INBOX/very/deep' => '\\HasChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + 'AEARLY' => '\\HasNoChildren', + 'not' => '\\HasChildren', + 'not/so' => '\\HasChildren', + 'not/so/deep' => '\\HasNoChildren', + 'sub2' => '\\HasChildren', + 'sub2/achild' => '\\HasNoChildren', + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody' => '\\HasNoChildren', + }); + + my $data2 = $imaptalk->list("", "%"); + + $self->assert_mailbox_structure($data2, '/', { + 'INBOX' => '\\HasChildren', + 'AEARLY' => '\\HasNoChildren', + 'not' => '\\HasChildren', + 'sub2' => '\\HasChildren', + 'Alt Folders' => '\\HasChildren \\Noselect', + }); + + my $data3 = $imaptalk->list("", "INBOX/%"); + + $self->assert_mailbox_structure($data3, '/', { + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\HasChildren', + }); +} diff --git a/cassandane/tiny-tests/List/list_non_extended_trash_nochildren b/cassandane/tiny-tests/List/list_non_extended_trash_nochildren new file mode 100644 index 0000000000..1e8bd3f8f8 --- /dev/null +++ b/cassandane/tiny-tests/List/list_non_extended_trash_nochildren @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_list_non_extended_trash_nochildren + :UnixHierarchySep :AltNamespace :NoStartInstances :min_version_3_7 +{ + my ($self) = @_; + + $self->{instance}->{config}->set('specialuse_nochildren' => '\\Trash'); + $self->_start_instances(); + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( ToDo Projects Projects/Foo SentMail MyDrafts Trash Snoozed) ] ], + ]); + + $imaptalk->setmetadata("SentMail", "/private/specialuse", "\\Sent"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("MyDrafts", "/private/specialuse", "\\Drafts"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("Trash", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("Snoozed", "/private/specialuse", "\\Snoozed"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + my $alldata = $imaptalk->list("", "%"); + + $self->assert_mailbox_structure($alldata, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'ToDo' => [qw( \\HasNoChildren )], + 'Projects' => [qw( \\HasChildren )], + 'SentMail' => [qw( \\Sent \\HasNoChildren )], + 'MyDrafts' => [qw( \\Drafts \\HasNoChildren )], + 'Trash' => [qw( \\Trash \\HasNoChildren \\Noinferiors )], + 'Snoozed' => [qw( \\Snoozed \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/list_return_subscribed b/cassandane/tiny-tests/List/list_return_subscribed new file mode 100644 index 0000000000..7fca5703a6 --- /dev/null +++ b/cassandane/tiny-tests/List/list_return_subscribed @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_list_return_subscribed + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( Fruit Fruit/Apple Fruit/Banana Fruit/Peach)] ], + [ 'subscribe' => [qw( Fruit/Banana Fruit/Peach )] ], + [ 'delete' => 'Fruit/Peach' ], + [ 'create' => [qw( Tofu Vegetable Vegetable/Broccoli Vegetable/Corn )] ], + [ 'subscribe' => [qw( Vegetable Vegetable/Broccoli )] ], + ]); + + my $subdata = $imaptalk->list([qw()], "", "*", 'RETURN', [qw(SUBSCRIBED)]); + + xlog(Dumper $subdata); + $self->assert_mailbox_structure($subdata, '/', { + 'INBOX' => [qw( \\Subscribed \\HasNoChildren )], + 'Fruit' => [qw( \\HasChildren )], + 'Fruit/Apple' => [qw( \\HasNoChildren )], + 'Fruit/Banana' => [qw( \\Subscribed \\HasNoChildren )], + 'Tofu' => [qw( \\HasNoChildren )], + 'Vegetable' => [qw( \\Subscribed \\HasChildren )], + 'Vegetable/Broccoli' => [qw( \\Subscribed \\HasNoChildren )], + 'Vegetable/Corn' => [qw( \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/list_return_subscribed_trash_nochildren b/cassandane/tiny-tests/List/list_return_subscribed_trash_nochildren new file mode 100644 index 0000000000..857592afa7 --- /dev/null +++ b/cassandane/tiny-tests/List/list_return_subscribed_trash_nochildren @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_list_return_subscribed_trash_nochildren + :UnixHierarchySep :AltNamespace :NoStartInstances :min_version_3_7 +{ + my ($self) = @_; + + $self->{instance}->{config}->set('specialuse_nochildren' => '\\Trash'); + $self->_start_instances(); + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( ToDo Projects Projects/Foo SentMail MyDrafts Trash Snoozed) ] ], + [ 'subscribe' => [qw( SentMail Trash) ] ], + ]); + + $imaptalk->setmetadata("SentMail", "/private/specialuse", "\\Sent"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("MyDrafts", "/private/specialuse", "\\Drafts"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("Trash", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("Snoozed", "/private/specialuse", "\\Snoozed"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + my $alldata = $imaptalk->list([qw( SPECIAL-USE )], "", "*", + 'RETURN', [qw(SUBSCRIBED)]); + + xlog $self, Dumper $alldata; + $self->assert_mailbox_structure($alldata, '/', { + 'SentMail' => [qw( \\Sent \\HasNoChildren \\Subscribed )], + 'MyDrafts' => [qw( \\Drafts \\HasNoChildren )], + 'Trash' => [qw( \\Trash \\Noinferiors \\Subscribed )], + 'Snoozed' => [qw( \\Snoozed \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/list_special_use_return_subscribed b/cassandane/tiny-tests/List/list_special_use_return_subscribed new file mode 100644 index 0000000000..09aa6548d5 --- /dev/null +++ b/cassandane/tiny-tests/List/list_special_use_return_subscribed @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_list_special_use_return_subscribed + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( ToDo Projects Projects/Foo SentMail MyDrafts Trash) ] ], + [ 'subscribe' => [qw( SentMail Trash) ] ], + ]); + + $imaptalk->setmetadata("SentMail", "/private/specialuse", "\\Sent"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("MyDrafts", "/private/specialuse", "\\Drafts"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("Trash", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + my $alldata = $imaptalk->list([qw( SPECIAL-USE )], "", "*", + 'RETURN', [qw(SUBSCRIBED)]); + + xlog $self, Dumper $alldata; + $self->assert_mailbox_structure($alldata, '/', { + 'SentMail' => [qw( \\Sent \\HasNoChildren \\Subscribed )], + 'MyDrafts' => [qw( \\Drafts \\HasNoChildren )], + 'Trash' => [qw( \\Trash \\HasNoChildren \\Subscribed )], + }); + +} diff --git a/cassandane/tiny-tests/List/list_subscribed_return_children b/cassandane/tiny-tests/List/list_subscribed_return_children new file mode 100644 index 0000000000..00f73f3227 --- /dev/null +++ b/cassandane/tiny-tests/List/list_subscribed_return_children @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_list_subscribed_return_children + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( Fruit Fruit/Apple Fruit/Banana Fruit/Peach)] ], + [ 'subscribe' => [qw( Fruit/Banana Fruit/Peach )] ], + [ 'delete' => 'Fruit/Peach' ], + [ 'create' => [qw( Tofu Vegetable Vegetable/Broccoli Vegetable/Corn )] ], + [ 'subscribe' => [qw( Vegetable )] ], + ]); + + xlog $self, "listing..."; + my $subdata = $imaptalk->list([qw(SUBSCRIBED)], "", "*", "RETURN", [qw(CHILDREN)]); + + xlog $self, "subscribed to: " . Dumper $subdata; + $self->assert_mailbox_structure($subdata, '/', { + 'INBOX' => [qw( \\Subscribed \\HasNoChildren )], + 'Fruit/Banana' => [qw( \\Subscribed \\HasNoChildren )], + 'Fruit/Peach' => [qw( \\NonExistent \\Subscribed \\HasNoChildren )], + 'Vegetable' => [qw( \\Subscribed \\HasChildren )], + }, 'strict'); +} diff --git a/cassandane/tiny-tests/List/list_subscribed_return_children_noaltns b/cassandane/tiny-tests/List/list_subscribed_return_children_noaltns new file mode 100644 index 0000000000..f1a69f08a6 --- /dev/null +++ b/cassandane/tiny-tests/List/list_subscribed_return_children_noaltns @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_list_subscribed_return_children_noaltns + :UnixHierarchySep +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( INBOX/Fruit INBOX/Fruit/Apple INBOX/Fruit/Banana + INBOX/Fruit/Peach )] ], + [ 'subscribe' => [qw( INBOX/Fruit/Banana INBOX/Fruit/Peach )] ], + [ 'delete' => 'INBOX/Fruit/Peach' ], + [ 'create' => [qw( INBOX/Tofu INBOX/Vegetable INBOX/Vegetable/Broccoli + INBOX/Vegetable/Corn )] ], + [ 'subscribe' => [qw( INBOX/Vegetable )] ], + ]); + + xlog $self, "listing..."; + my $subdata = $imaptalk->list([qw(SUBSCRIBED)], "", "*", "RETURN", [qw(CHILDREN)]); + + xlog $self, "subscribed to: " . Dumper $subdata; + $self->assert_mailbox_structure($subdata, '/', { + 'INBOX' => [qw( \\Subscribed \\HasChildren )], + 'INBOX/Fruit/Banana' => [qw( \\Subscribed \\HasNoChildren )], + 'INBOX/Fruit/Peach' => [qw( \\NonExistent \\Subscribed \\HasNoChildren )], + 'INBOX/Vegetable' => [qw( \\Subscribed \\HasChildren )], + }, 'strict'); +} diff --git a/cassandane/tiny-tests/List/lookup_only_otheruser b/cassandane/tiny-tests/List/lookup_only_otheruser new file mode 100644 index 0000000000..35ccc2d79a --- /dev/null +++ b/cassandane/tiny-tests/List/lookup_only_otheruser @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_lookup_only_otheruser + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + $self->{instance}->create_user("other"); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user/other/foo'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl('user/other/foo', + 'cassandane' => 'l'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Other Users/other/foo' => [qw( \\HasNoChildren )], + }); + + # only "l" permission, should be able to list, but not select! + $imaptalk->select('Other Users/other/foo'); + $self->assert_str_equals('no', + $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/List/lookup_only_otheruser_noaltns b/cassandane/tiny-tests/List/lookup_only_otheruser_noaltns new file mode 100644 index 0000000000..6614e6cc00 --- /dev/null +++ b/cassandane/tiny-tests/List/lookup_only_otheruser_noaltns @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_lookup_only_otheruser_noaltns + :UnixHierarchySep :NoAltNamespace +{ + my ($self) = @_; + + $self->{instance}->create_user("other"); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user/other/foo'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl('user/other/foo', + 'cassandane' => 'l'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'user/other/foo' => [qw( \\HasNoChildren )], + }); + + # only "l" permission, should be able to list, but not select! + $imaptalk->select('user/other/foo'); + $self->assert_str_equals('no', + $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/List/lookup_only_otheruser_noaltns_racl b/cassandane/tiny-tests/List/lookup_only_otheruser_noaltns_racl new file mode 100644 index 0000000000..d7802b6263 --- /dev/null +++ b/cassandane/tiny-tests/List/lookup_only_otheruser_noaltns_racl @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_lookup_only_otheruser_noaltns_racl + :UnixHierarchySep :NoAltNamespace :ReverseACLs +{ + my ($self) = @_; + + $self->{instance}->create_user("other"); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user/other/foo'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl('user/other/foo', + 'cassandane' => 'l'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'user/other/foo' => [qw( \\HasNoChildren )], + }); + + # only "l" permission, should be able to list, but not select! + $imaptalk->select('user/other/foo'); + $self->assert_str_equals('no', + $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/List/lookup_only_otheruser_racl b/cassandane/tiny-tests/List/lookup_only_otheruser_racl new file mode 100644 index 0000000000..51895ff2a2 --- /dev/null +++ b/cassandane/tiny-tests/List/lookup_only_otheruser_racl @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_lookup_only_otheruser_racl + :UnixHierarchySep :AltNamespace :ReverseACLs +{ + my ($self) = @_; + + $self->{instance}->create_user("other"); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user/other/foo'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl('user/other/foo', + 'cassandane' => 'l'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Other Users/other/foo' => [qw( \\HasNoChildren )], + }); + + # only "l" permission, should be able to list, but not select! + $imaptalk->select('Other Users/other/foo'); + $self->assert_str_equals('no', + $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/List/lookup_only_own b/cassandane/tiny-tests/List/lookup_only_own new file mode 100644 index 0000000000..f4f89e5e9e --- /dev/null +++ b/cassandane/tiny-tests/List/lookup_only_own @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_lookup_only_own + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + $self->{instance}->create_user("other"); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user/cassandane/foo'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl('user/cassandane/foo', + 'cassandane' => 'l'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'foo' => [qw( \\HasNoChildren )], + }); + + # only "l" permission, should be able to list, but not select! + $imaptalk->select('foo'); + $self->assert_str_equals('no', + $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/List/lookup_only_own_racl b/cassandane/tiny-tests/List/lookup_only_own_racl new file mode 100644 index 0000000000..e09e7d23cb --- /dev/null +++ b/cassandane/tiny-tests/List/lookup_only_own_racl @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_lookup_only_own_racl + :UnixHierarchySep :AltNamespace :ReverseACLs +{ + my ($self) = @_; + + $self->{instance}->create_user("other"); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('user/cassandane/foo'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl('user/cassandane/foo', + 'cassandane' => 'l'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'foo' => [qw( \\HasNoChildren )], + }); + + # only "l" permission, should be able to list, but not select! + $imaptalk->select('foo'); + $self->assert_str_equals('no', + $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/List/lookup_only_shared b/cassandane/tiny-tests/List/lookup_only_shared new file mode 100644 index 0000000000..48e15d9622 --- /dev/null +++ b/cassandane/tiny-tests/List/lookup_only_shared @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_lookup_only_shared + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('shared'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl('shared', + 'cassandane' => 'l'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Shared Folders/shared' => [qw( \\HasNoChildren )], + }); + + # implicit "anyone:r" on shared mailboxes means that the + # cassandane user can also select this, despite only having + # "l" of their own + $imaptalk->select('Shared Folders/shared'); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/List/lookup_only_shared_racl b/cassandane/tiny-tests/List/lookup_only_shared_racl new file mode 100644 index 0000000000..3cd45898c4 --- /dev/null +++ b/cassandane/tiny-tests/List/lookup_only_shared_racl @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_lookup_only_shared_racl + :UnixHierarchySep :AltNamespace :ReverseACLs +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create('shared'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl('shared', + 'cassandane' => 'l'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Shared Folders/shared' => [qw( \\HasNoChildren )], + }); + + # implicit "anyone:r" on shared mailboxes means that the + # cassandane user can also select this, despite only having + # "l" of their own + $imaptalk->select('Shared Folders/shared'); + $self->assert_str_equals('ok', + $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/List/no_inbox_tombstone b/cassandane/tiny-tests/List/no_inbox_tombstone new file mode 100644 index 0000000000..3fcf1c9a0b --- /dev/null +++ b/cassandane/tiny-tests/List/no_inbox_tombstone @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_no_inbox_tombstone + :UnixHierarchySep :ReverseACLs :AllowMoves +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + $admintalk->rename("user/cassandane", "user/cassandane-old"); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + + my $tombstone_name = 'user.cassandane'; + + my $mailboxesdb = $self->{instance}->read_mailboxes_db(); + $self->assert_matches(qr{d}, $mailboxesdb->{$tombstone_name}->{mbtype}); + + my $imaptalk = $self->{store}->get_client(); + + # basic list + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', {}); + + # basic xlist + $data = $imaptalk->xlist("", "*"); + $self->assert_str_equals('ok', $data); # no mailboxes listed + + # partial match list + $data = $imaptalk->list("", "INB*"); + $self->assert_mailbox_structure($data, '/', {}); + + # partial match xlist + $data = $imaptalk->xlist("", "INB*"); + $self->assert_str_equals('ok', $data); # no mailboxes listed + + # direct list + $data = $imaptalk->list("", "INBOX"); + $self->assert_mailbox_structure($data, '/', {}); + + # direct xlist + $data = $imaptalk->xlist("", "INBOX"); + $self->assert_str_equals('ok', $data); # no mailboxes listed +} diff --git a/cassandane/tiny-tests/List/no_tombstones b/cassandane/tiny-tests/List/no_tombstones new file mode 100644 index 0000000000..f6f0508b6d --- /dev/null +++ b/cassandane/tiny-tests/List/no_tombstones @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_no_tombstones + :UnixHierarchySep :AltNamespace :ReverseACLs +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( INBOX/Tombstone )] ], + [ 'subscribe' => [qw( INBOX/Tombstone )] ], + [ 'delete' => 'INBOX/Tombstone' ], + ]); + + my $tombstone_name = 'user.cassandane.INBOX.Tombstone'; + + my $mailboxesdb = $self->{instance}->read_mailboxes_db(); + $self->assert_matches(qr{d}, $mailboxesdb->{$tombstone_name}->{mbtype}); + + # basic list + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + }); + + # basic xlist + $data = $imaptalk->xlist("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + }); + + # partial match list + $data = $imaptalk->list("", "INB*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + }); + + # partial match xlist + $data = $imaptalk->xlist("", "INB*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + }); + + # direct list + $data = $imaptalk->list("", "INBOX/Tombstone"); + $self->assert_mailbox_structure($data, '/', {}); + + # direct xlist + $data = $imaptalk->xlist("", "INBOX/Tombstone"); + $self->assert_str_equals('ok', $data); # no mailboxes listed +} diff --git a/cassandane/tiny-tests/List/otherusers_pattern b/cassandane/tiny-tests/List/otherusers_pattern new file mode 100644 index 0000000000..7774e31fbf --- /dev/null +++ b/cassandane/tiny-tests/List/otherusers_pattern @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_otherusers_pattern + :NoAltNameSpace +{ + my ($self) = @_; + $self->{instance}->create_user("foo"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo"); + my $footalk = $foostore->get_client(); + + $footalk->create('INBOX.mytest'); + $self->assert_str_equals('ok', $footalk->get_last_completion_response()); + $footalk->create('INBOX.mytest.mysubtest'); + $self->assert_str_equals('ok', $footalk->get_last_completion_response()); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->setacl("user.foo", + 'cassandane' => 'lrswipkxtecd'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl("user.foo.mytest", + 'cassandane' => 'lrswipkxtecd'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl("user.foo.mytest.mysubtest", + 'cassandane' => 'lrswipkxtecd'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $casstalk = $self->{store}->get_client(); + my $data; + + $data = $casstalk->list("", "user.%"); + $self->assert_mailbox_structure($data, '.', { + 'user.foo' => [qw( \\HasChildren )], + }); + + $data = $casstalk->list("", "user.foo.%"); + $self->assert_mailbox_structure($data, '.', { + 'user.foo.mytest' => [qw( \\HasChildren )], + }); + + $data = $casstalk->list("", "user.foo.mytest.%"); + $self->assert_mailbox_structure($data, '.', { + 'user.foo.mytest.mysubtest' => [qw( \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/otherusers_pattern_unixhs b/cassandane/tiny-tests/List/otherusers_pattern_unixhs new file mode 100644 index 0000000000..646221fa8e --- /dev/null +++ b/cassandane/tiny-tests/List/otherusers_pattern_unixhs @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_otherusers_pattern_unixhs + :UnixHierarchySep :NoAltNameSpace +{ + my ($self) = @_; + $self->{instance}->create_user("foo"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo"); + my $footalk = $foostore->get_client(); + + $footalk->create('INBOX/mytest'); + $self->assert_str_equals('ok', $footalk->get_last_completion_response()); + $footalk->create('INBOX/mytest/mysubtest'); + $self->assert_str_equals('ok', $footalk->get_last_completion_response()); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->setacl("user/foo", + 'cassandane' => 'lrswipkxtecd'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl("user/foo/mytest", + 'cassandane' => 'lrswipkxtecd'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl("user/foo/mytest/mysubtest", + 'cassandane' => 'lrswipkxtecd'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + my $casstalk = $self->{store}->get_client(); + my $data; + + $data = $casstalk->list("", "user/%"); + $self->assert_mailbox_structure($data, '/', { + 'user/foo' => [qw( \\HasChildren )], + }); + + $data = $casstalk->list("", "user/foo/%"); + $self->assert_mailbox_structure($data, '/', { + 'user/foo/mytest' => [qw( \\HasChildren )], + }); + + $data = $casstalk->list("", "user/foo/mytest/%"); + $self->assert_mailbox_structure($data, '/', { + 'user/foo/mytest/mysubtest' => [qw( \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/outlook_compatible_xlist_empty_mailbox b/cassandane/tiny-tests/List/outlook_compatible_xlist_empty_mailbox new file mode 100644 index 0000000000..ffe3a0d15a --- /dev/null +++ b/cassandane/tiny-tests/List/outlook_compatible_xlist_empty_mailbox @@ -0,0 +1,18 @@ +#!perl +use Cassandane::Tiny; + +sub test_outlook_compatible_xlist_empty_mailbox + :UnixHierarchySep +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + my $data = $imaptalk->xlist("", ""); + + $self->assert(ref $data, "expected list response, got scalar: $data"); + + $self->assert_mailbox_structure($data, '/', { + '' => [ '\\Noselect' ], + }); +} diff --git a/cassandane/tiny-tests/List/percent b/cassandane/tiny-tests/List/percent new file mode 100644 index 0000000000..30dfb9a5cb --- /dev/null +++ b/cassandane/tiny-tests/List/percent @@ -0,0 +1,170 @@ +#!perl +use Cassandane::Tiny; + +# https://tools.ietf.org/html/rfc3501#section-6.3.8 +# If the "%" wildcard is the last character of a +# mailbox name argument, matching levels of hierarchy +# are also returned. +sub test_percent + :NoAltNameSpace :max_version_3_4 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # INBOX needs to exist even if we can't see it + $admintalk->create('user.bar'); + + foreach my $Folder ("user.cassandane.INBOX.sub", "user.cassandane.AEARLY", + "user.cassandane.sub2", "user.cassandane.sub2.achild", + "user.cassandane.INBOX.very.deep.one", + "user.cassandane.not.so.deep", + # stuff you can't see + "user.cassandane.INBOX", + "user.cassandane.inbox", + "user.cassandane.inbox.subnobody.deep", + "user.cassandane.Inbox.subnobody.deep", + # other users + "user.bar.Trash", + "user.foo", + "user.foo.really.deep", + # shared + "shared stuff.something") { + $admintalk->create($Folder); + $admintalk->setacl($Folder, 'cassandane' => 'lrswipkxtecd'); + } + + xlog $self, "List *"; + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => '\\HasChildren', + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.INBOX.sub' => '\\HasNoChildren', + 'INBOX.INBOX.very.deep.one' => '\\HasNoChildren', + 'INBOX.Inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.not.so.deep' => '\\HasNoChildren', + 'INBOX.sub2' => '\\HasChildren', + 'INBOX.sub2.achild' => '\\HasNoChildren', + 'user.bar.Trash' => '\\HasNoChildren', + 'user.foo' => '\\HasChildren', + 'user.foo.really.deep' => '\\HasNoChildren', + 'shared stuff.something' => '\\HasNoChildren', + }); + + #xlog $self, "LIST %"; + #$data = $imaptalk->list("", "%"); + #$self->assert_mailbox_structure($data, '.', { + #'INBOX' => '\\HasChildren', + #'user' => '\\Noselect \\HasChildren', + #'shared stuff' => '\\Noselect \\HasChildren', + #}); + + xlog $self, "List *%"; + $data = $imaptalk->list("", "*%"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => '\\HasChildren', + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.INBOX.sub' => '\\HasNoChildren', + 'INBOX.INBOX.very' => '\\Noselect \\HasChildren', + 'INBOX.INBOX.very.deep' => '\\Noselect \\HasChildren', + 'INBOX.INBOX.very.deep.one' => '\\HasNoChildren', + 'INBOX.Inbox' => '\\Noselect \\HasChildren', + 'INBOX.Inbox.subnobody' => '\\Noselect \\HasChildren', + 'INBOX.Inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.inbox.subnobody' => '\\Noselect \\HasChildren', + 'INBOX.inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.not' => '\\Noselect \\HasChildren', + 'INBOX.not.so' => '\\Noselect \\HasChildren', + 'INBOX.not.so.deep' => '\\HasNoChildren', + 'INBOX.sub2' => '\\HasChildren', + 'INBOX.sub2.achild' => '\\HasNoChildren', + 'user' => '\\Noselect \\HasChildren', + 'user.bar' => '\\Noselect \\HasChildren', + 'user.bar.Trash' => '\\HasNoChildren', + 'user.foo' => '\\HasChildren', + 'user.foo.really' => '\\Noselect \\HasChildren', + 'user.foo.really.deep' => '\\HasNoChildren', + 'shared stuff' => '\\Noselect \\HasChildren', + 'shared stuff.something' => '\\HasNoChildren', + }); + + xlog $self, "LIST INBOX.*"; + $data = $imaptalk->list("INBOX.", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.INBOX.sub' => '\\HasNoChildren', + 'INBOX.INBOX.very.deep.one' => '\\HasNoChildren', + 'INBOX.Inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.not.so.deep' => '\\HasNoChildren', + 'INBOX.sub2' => '\\HasChildren', + 'INBOX.sub2.achild' => '\\HasNoChildren', + }); + + xlog $self, "LIST INBOX.*%"; + $data = $imaptalk->list("INBOX.", "*%"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.INBOX.sub' => '\\HasNoChildren', + 'INBOX.INBOX.very' => '\\Noselect \\HasChildren', + 'INBOX.INBOX.very.deep' => '\\Noselect \\HasChildren', + 'INBOX.INBOX.very.deep.one' => '\\HasNoChildren', + 'INBOX.Inbox' => '\\Noselect \\HasChildren', + 'INBOX.Inbox.subnobody' => '\\Noselect \\HasChildren', + 'INBOX.Inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.inbox.subnobody' => '\\Noselect \\HasChildren', + 'INBOX.inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.not' => '\\Noselect \\HasChildren', + 'INBOX.not.so' => '\\Noselect \\HasChildren', + 'INBOX.not.so.deep' => '\\HasNoChildren', + 'INBOX.sub2' => '\\HasChildren', + 'INBOX.sub2.achild' => '\\HasNoChildren', + }); + + xlog $self, "LIST INBOX.%"; + $data = $imaptalk->list("INBOX.", "%"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.Inbox' => '\\Noselect \\HasChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.not' => '\\Noselect \\HasChildren', + 'INBOX.sub2' => '\\HasChildren', + }); + + xlog $self, "List user.*"; + $data = $imaptalk->list("user.", "*"); + $self->assert_mailbox_structure($data, '.', { + 'user.bar.Trash' => '\\HasNoChildren', + 'user.foo' => '\\HasChildren', + 'user.foo.really.deep' => '\\HasNoChildren', + }); + + xlog $self, "List user.*%"; + $data = $imaptalk->list("user.", "*%"); + $self->assert_mailbox_structure($data, '.', { + 'user.bar' => '\\Noselect \\HasChildren', + 'user.bar.Trash' => '\\HasNoChildren', + 'user.foo' => '\\HasChildren', + 'user.foo.really' => '\\Noselect \\HasChildren', + 'user.foo.really.deep' => '\\HasNoChildren', + }); + + #xlog $self, "List user.%"; + #$data = $imaptalk->list("user.", "%"); + #$self->assert_mailbox_structure($data, '.', { + # 'user.bar' => '\\Noselect \\HasChildren', + # 'user.foo' => '\\HasChildren', + #}); + +} diff --git a/cassandane/tiny-tests/List/percent_altns b/cassandane/tiny-tests/List/percent_altns new file mode 100644 index 0000000000..69ac7e2a56 --- /dev/null +++ b/cassandane/tiny-tests/List/percent_altns @@ -0,0 +1,218 @@ +#!perl +use Cassandane::Tiny; + +# https://tools.ietf.org/html/rfc3501#section-6.3.8 +# If the "%" wildcard is the last character of a +# mailbox name argument, matching levels of hierarchy +# are also returned. +sub test_percent_altns + :UnixHierarchySep :VirtDomains :CrossDomains :AltNamespace :max_version_3_4 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # INBOX needs to exist even if we can't see it + $admintalk->create('user/bar'); + + foreach my $Folder ("user/cassandane/INBOX/sub", "user/cassandane/AEARLY", + "user/cassandane/sub2", "user/cassandane/sub2/achild", + "user/cassandane/INBOX/very/deep/one", + "user/cassandane/not/so/deep", + # stuff you can't see + "user/cassandane/INBOX", + "user/cassandane/inbox", + "user/cassandane/inbox/subnobody/deep", + "user/cassandane/Inbox/subnobody/deep", + # other users + "user/bar/Trash", + "user/foo", + "user/foo/really/deep", + # shared + "shared stuff/something") { + $admintalk->create($Folder); + $admintalk->setacl($Folder, 'cassandane' => 'lrswipkxtecd'); + } + + xlog $self, "List *"; + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasChildren', + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + 'AEARLY' => '\\HasNoChildren', + 'not/so/deep' => '\\HasNoChildren', + 'sub2' => '\\HasChildren', + 'sub2/achild' => '\\HasNoChildren', + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody/deep' => '\\HasNoChildren', + 'Alt Folders/Inbox/subnobody/deep' => '\\HasNoChildren', + 'Other Users/bar@defdomain/Trash' => '\\HasNoChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + 'Other Users/foo@defdomain/really/deep' => '\\HasNoChildren', + 'Shared Folders/shared stuff@defdomain/something' => '\\HasNoChildren', + }); + + xlog $self, "List *%"; + $data = $imaptalk->list("", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasChildren', + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\Noselect \\HasChildren', + 'INBOX/very/deep' => '\\Noselect \\HasChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + 'AEARLY' => '\\HasNoChildren', + 'not' => '\\Noselect \\HasChildren', + 'not/so' => '\\Noselect \\HasChildren', + 'not/so/deep' => '\\HasNoChildren', + 'sub2' => '\\HasChildren', + 'sub2/achild' => '\\HasNoChildren', + 'Alt Folders' => '\\Noselect \\HasChildren', + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody' => '\\Noselect \\HasChildren', + 'Alt Folders/inbox/subnobody/deep' => '\\HasNoChildren', + 'Alt Folders/Inbox' => '\\Noselect \\HasChildren', + 'Alt Folders/Inbox/subnobody' => '\\Noselect \\HasChildren', + 'Alt Folders/Inbox/subnobody/deep' => '\\HasNoChildren', + 'Other Users' => '\\Noselect \\HasChildren', + 'Other Users/bar@defdomain' => '\\Noselect \\HasChildren', + 'Other Users/bar@defdomain/Trash' => '\\HasNoChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + 'Other Users/foo@defdomain/really' => '\\Noselect \\HasChildren', + 'Other Users/foo@defdomain/really/deep' => '\\HasNoChildren', + 'Shared Folders' => '\\Noselect \\HasChildren', + 'Shared Folders/shared stuff@defdomain' => '\\Noselect \\HasChildren', + 'Shared Folders/shared stuff@defdomain/something' => '\\HasNoChildren', + }); + + xlog $self, "List %"; + $data = $imaptalk->list("", "%"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasChildren', + 'AEARLY' => '\\HasNoChildren', + 'not' => '\\Noselect \\HasChildren', + 'sub2' => '\\HasChildren', + 'Alt Folders' => '\\Noselect \\HasChildren', + 'Other Users' => '\\Noselect \\HasChildren', + 'Shared Folders' => '\\Noselect \\HasChildren', + }); + + # check some partials + + xlog $self, "List INBOX/*"; + $data = $imaptalk->list("INBOX/", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + }); + + xlog $self, "List INBOX/*%"; + $data = $imaptalk->list("INBOX/", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\Noselect \\HasChildren', + 'INBOX/very/deep' => '\\Noselect \\HasChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + }); + + xlog $self, "List INBOX/%"; + $data = $imaptalk->list("INBOX/", "%"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\Noselect \\HasChildren', + }); + + xlog $self, "List AEARLY/*"; + $data = $imaptalk->list("AEARLY/", "*"); + $self->assert_mailbox_structure($data, '/', {}); + + xlog $self, "List AEARLY/*%"; + $data = $imaptalk->list("AEARLY/", "*%"); + $self->assert_mailbox_structure($data, '/', {}); + + xlog $self, "List AEARLY/%"; + $data = $imaptalk->list("AEARLY/", "%"); + $self->assert_mailbox_structure($data, '/', {}); + + xlog $self, "List sub2/*"; + $data = $imaptalk->list("sub2/", "*"); + $self->assert_mailbox_structure($data, '/', { + 'sub2/achild' => '\\HasNoChildren', + }); + + xlog $self, "List sub2/*%"; + $data = $imaptalk->list("sub2/", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'sub2/achild' => '\\HasNoChildren', + }); + + xlog $self, "List sub2/%"; + $data = $imaptalk->list("sub2/", "%"); + $self->assert_mailbox_structure($data, '/', { + 'sub2/achild' => '\\HasNoChildren', + }); + + xlog $self, "List Alt Folders/*"; + $data = $imaptalk->list("Alt Folders/", "*"); + $self->assert_mailbox_structure($data, '/', { + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody/deep' => '\\HasNoChildren', + 'Alt Folders/Inbox/subnobody/deep' => '\\HasNoChildren', + }); + + xlog $self, "List Alt Folders/*%"; + $data = $imaptalk->list("Alt Folders/", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody' => '\\Noselect \\HasChildren', + 'Alt Folders/inbox/subnobody/deep' => '\\HasNoChildren', + 'Alt Folders/Inbox' => '\\Noselect \\HasChildren', + 'Alt Folders/Inbox/subnobody' => '\\Noselect \\HasChildren', + 'Alt Folders/Inbox/subnobody/deep' => '\\HasNoChildren', + }); + + xlog $self, "List Alt Folders/%"; + $data = $imaptalk->list("Alt Folders/", "%"); + $self->assert_mailbox_structure($data, '/', { + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/Inbox' => '\\Noselect \\HasChildren', + }); + + xlog $self, "List Other Users"; + $data = $imaptalk->list("", "Other Users"); + $self->assert_mailbox_structure($data, '/', { + 'Other Users' => '\\Noselect \\HasChildren', + }); + + xlog $self, "List Other Users/*"; + $data = $imaptalk->list("Other Users/", "*"); + $self->assert_mailbox_structure($data, '/', { + 'Other Users/bar@defdomain/Trash' => '\\HasNoChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + 'Other Users/foo@defdomain/really/deep' => '\\HasNoChildren', + }); + + xlog $self, "List Other Users/*%"; + $data = $imaptalk->list("Other Users/", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'Other Users/bar@defdomain' => '\\Noselect \\HasChildren', + 'Other Users/bar@defdomain/Trash' => '\\HasNoChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + 'Other Users/foo@defdomain/really' => '\\Noselect \\HasChildren', + 'Other Users/foo@defdomain/really/deep' => '\\HasNoChildren', + }); + + xlog $self, "List Other Users/%"; + $data = $imaptalk->list("Other Users/", "%"); + $self->assert_mailbox_structure($data, '/', { + 'Other Users/bar@defdomain' => '\\Noselect \\HasChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + }); + +} diff --git a/cassandane/tiny-tests/List/percent_altns_no_intermediates b/cassandane/tiny-tests/List/percent_altns_no_intermediates new file mode 100644 index 0000000000..1cc3651622 --- /dev/null +++ b/cassandane/tiny-tests/List/percent_altns_no_intermediates @@ -0,0 +1,229 @@ +#!perl +use Cassandane::Tiny; + +sub test_percent_altns_no_intermediates + :UnixHierarchySep :VirtDomains :CrossDomains :AltNamespace :min_version_3_5 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # INBOX needs to exist even if we can't see it + $admintalk->create('user/bar'); + + foreach my $Folder ("user/cassandane/INBOX/sub", "user/cassandane/AEARLY", + "user/cassandane/sub2", "user/cassandane/sub2/achild", + "user/cassandane/INBOX/very/deep/one", + "user/cassandane/not/so/deep", + # stuff you can't see + "user/cassandane/INBOX", + "user/cassandane/inbox", + "user/cassandane/inbox/subnobody/deep", + "user/cassandane/Inbox/subnobody/deep", + # other users + "user/bar/Trash", + "user/foo", + "user/foo/really/deep", + # shared + "shared stuff/something") { + $admintalk->create($Folder); + $admintalk->setacl($Folder, 'cassandane' => 'lrswipkxtecd'); + } + + xlog $self, "List *"; + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasChildren', + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\HasChildren', + 'INBOX/very/deep' => '\\HasChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + 'AEARLY' => '\\HasNoChildren', + 'not' => '\\HasChildren', + 'not/so' => '\\HasChildren', + 'not/so/deep' => '\\HasNoChildren', + 'sub2' => '\\HasChildren', + 'sub2/achild' => '\\HasNoChildren', + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/Inbox' => '\\HasChildren', + 'Alt Folders/Inbox/subnobody' => '\\HasChildren', + 'Alt Folders/Inbox/subnobody/deep' => '\\HasNoChildren', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody' => '\\HasChildren', + 'Alt Folders/inbox/subnobody/deep' => '\\HasNoChildren', + 'Other Users/bar@defdomain/Trash' => '\\HasNoChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + 'Other Users/foo@defdomain/really' => '\\HasChildren', + 'Other Users/foo@defdomain/really/deep' => '\\HasNoChildren', + 'Shared Folders/shared stuff@defdomain' => '\\HasChildren', + 'Shared Folders/shared stuff@defdomain/something' => '\\HasNoChildren', + }); + + xlog $self, "List *%"; + $data = $imaptalk->list("", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasChildren', + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\HasChildren', + 'INBOX/very/deep' => '\\HasChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + 'AEARLY' => '\\HasNoChildren', + 'not' => '\\HasChildren', + 'not/so' => '\\HasChildren', + 'not/so/deep' => '\\HasNoChildren', + 'sub2' => '\\HasChildren', + 'sub2/achild' => '\\HasNoChildren', + 'Alt Folders' => '\\Noselect \\HasChildren', + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody' => '\\HasChildren', + 'Alt Folders/inbox/subnobody/deep' => '\\HasNoChildren', + 'Alt Folders/Inbox' => '\\HasChildren', + 'Alt Folders/Inbox/subnobody' => '\\HasChildren', + 'Alt Folders/Inbox/subnobody/deep' => '\\HasNoChildren', + 'Other Users' => '\\Noselect \\HasChildren', + 'Other Users/bar@defdomain' => '\\Noselect \\HasChildren', + 'Other Users/bar@defdomain/Trash' => '\\HasNoChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + 'Other Users/foo@defdomain/really' => '\\HasChildren', + 'Other Users/foo@defdomain/really/deep' => '\\HasNoChildren', + 'Shared Folders' => '\\Noselect \\HasChildren', + 'Shared Folders/shared stuff@defdomain' => '\\HasChildren', + 'Shared Folders/shared stuff@defdomain/something' => '\\HasNoChildren', + }); + + xlog $self, "List %"; + $data = $imaptalk->list("", "%"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasChildren', + 'AEARLY' => '\\HasNoChildren', + 'not' => '\\HasChildren', + 'sub2' => '\\HasChildren', + 'Alt Folders' => '\\Noselect \\HasChildren', + 'Other Users' => '\\Noselect \\HasChildren', + 'Shared Folders' => '\\Noselect \\HasChildren', + }); + + # check some partials + + xlog $self, "List INBOX/*"; + $data = $imaptalk->list("INBOX/", "*"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\HasChildren', + 'INBOX/very/deep' => '\\HasChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + }); + + xlog $self, "List INBOX/*%"; + $data = $imaptalk->list("INBOX/", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\HasChildren', + 'INBOX/very/deep' => '\\HasChildren', + 'INBOX/very/deep/one' => '\\HasNoChildren', + }); + + xlog $self, "List INBOX/%"; + $data = $imaptalk->list("INBOX/", "%"); + $self->assert_mailbox_structure($data, '/', { + 'INBOX/sub' => '\\HasNoChildren', + 'INBOX/very' => '\\HasChildren', + }); + + xlog $self, "List AEARLY/*"; + $data = $imaptalk->list("AEARLY/", "*"); + $self->assert_mailbox_structure($data, '/', {}); + + xlog $self, "List AEARLY/*%"; + $data = $imaptalk->list("AEARLY/", "*%"); + $self->assert_mailbox_structure($data, '/', {}); + + xlog $self, "List AEARLY/%"; + $data = $imaptalk->list("AEARLY/", "%"); + $self->assert_mailbox_structure($data, '/', {}); + + xlog $self, "List sub2/*"; + $data = $imaptalk->list("sub2/", "*"); + $self->assert_mailbox_structure($data, '/', { + 'sub2/achild' => '\\HasNoChildren', + }); + + xlog $self, "List sub2/*%"; + $data = $imaptalk->list("sub2/", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'sub2/achild' => '\\HasNoChildren', + }); + + xlog $self, "List sub2/%"; + $data = $imaptalk->list("sub2/", "%"); + $self->assert_mailbox_structure($data, '/', { + 'sub2/achild' => '\\HasNoChildren', + }); + + xlog $self, "List Alt Folders/*"; + $data = $imaptalk->list("Alt Folders/", "*"); + $self->assert_mailbox_structure($data, '/', { + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody' => '\\HasChildren', + 'Alt Folders/inbox/subnobody/deep' => '\\HasNoChildren', + 'Alt Folders/Inbox' => '\\HasChildren', + 'Alt Folders/Inbox/subnobody' => '\\HasChildren', + 'Alt Folders/Inbox/subnobody/deep' => '\\HasNoChildren', + }); + + xlog $self, "List Alt Folders/*%"; + $data = $imaptalk->list("Alt Folders/", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/inbox/subnobody' => '\\HasChildren', + 'Alt Folders/inbox/subnobody/deep' => '\\HasNoChildren', + 'Alt Folders/Inbox' => '\\HasChildren', + 'Alt Folders/Inbox/subnobody' => '\\HasChildren', + 'Alt Folders/Inbox/subnobody/deep' => '\\HasNoChildren', + }); + + xlog $self, "List Alt Folders/%"; + $data = $imaptalk->list("Alt Folders/", "%"); + $self->assert_mailbox_structure($data, '/', { + 'Alt Folders/INBOX' => '\\HasNoChildren \\Noinferiors', + 'Alt Folders/inbox' => '\\HasChildren', + 'Alt Folders/Inbox' => '\\HasChildren', + }); + + xlog $self, "List Other Users"; + $data = $imaptalk->list("", "Other Users"); + $self->assert_mailbox_structure($data, '/', { + 'Other Users' => '\\Noselect \\HasChildren', + }); + + xlog $self, "List Other Users/*"; + $data = $imaptalk->list("Other Users/", "*"); + $self->assert_mailbox_structure($data, '/', { + 'Other Users/bar@defdomain/Trash' => '\\HasNoChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + 'Other Users/foo@defdomain/really' => '\\HasChildren', + 'Other Users/foo@defdomain/really/deep' => '\\HasNoChildren', + }); + + xlog $self, "List Other Users/*%"; + $data = $imaptalk->list("Other Users/", "*%"); + $self->assert_mailbox_structure($data, '/', { + 'Other Users/bar@defdomain' => '\\Noselect \\HasChildren', + 'Other Users/bar@defdomain/Trash' => '\\HasNoChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + 'Other Users/foo@defdomain/really' => '\\HasChildren', + 'Other Users/foo@defdomain/really/deep' => '\\HasNoChildren', + }); + + xlog $self, "List Other Users/%"; + $data = $imaptalk->list("Other Users/", "%"); + $self->assert_mailbox_structure($data, '/', { + 'Other Users/bar@defdomain' => '\\Noselect \\HasChildren', + 'Other Users/foo@defdomain' => '\\HasChildren', + }); + +} diff --git a/cassandane/tiny-tests/List/percent_no_intermediates b/cassandane/tiny-tests/List/percent_no_intermediates new file mode 100644 index 0000000000..1a0005d791 --- /dev/null +++ b/cassandane/tiny-tests/List/percent_no_intermediates @@ -0,0 +1,185 @@ +#!perl +use Cassandane::Tiny; + +sub test_percent_no_intermediates + :NoAltNameSpace :min_version_3_5 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + # INBOX needs to exist even if we can't see it + $admintalk->create('user.bar'); + + foreach my $Folder ("user.cassandane.INBOX.sub", "user.cassandane.AEARLY", + "user.cassandane.sub2", "user.cassandane.sub2.achild", + "user.cassandane.INBOX.very.deep.one", + "user.cassandane.not.so.deep", + # stuff you can't see + "user.cassandane.INBOX", + "user.cassandane.inbox", + "user.cassandane.inbox.subnobody.deep", + "user.cassandane.Inbox.subnobody.deep", + # other users + "user.bar.Trash", + "user.foo", + "user.foo.really.deep", + # shared + "shared stuff.something") { + $admintalk->create($Folder); + $admintalk->setacl($Folder, 'cassandane' => 'lrswipkxtecd'); + } + + xlog $self, "List *"; + my $data = $imaptalk->list("", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => '\\HasChildren', + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.INBOX.sub' => '\\HasNoChildren', + 'INBOX.INBOX.very.deep.one' => '\\HasNoChildren', + 'INBOX.Inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.not.so.deep' => '\\HasNoChildren', + 'INBOX.sub2' => '\\HasChildren', + 'INBOX.sub2.achild' => '\\HasNoChildren', + 'user.bar.Trash' => '\\HasNoChildren', + 'user.foo' => '\\HasChildren', + 'user.foo.really.deep' => '\\HasNoChildren', + 'shared stuff.something' => '\\HasNoChildren', + + 'INBOX.INBOX.very' => '\\HasChildren', + 'INBOX.INBOX.very.deep' => '\\HasChildren', + 'INBOX.inbox.subnobody' => '\\HasChildren', + 'INBOX.not' => '\\HasChildren', + 'INBOX.not.so' => '\\HasChildren', + 'INBOX.Inbox' => '\\HasChildren', + 'INBOX.Inbox.subnobody' => '\\HasChildren', + 'INBOX.inbox.subnobody' => '\\HasChildren', + 'user.foo.really' => '\\HasChildren', + 'shared stuff' => '\\HasChildren', + }); + + #xlog $self, "LIST %"; + #$data = $imaptalk->list("", "%"); + #$self->assert_mailbox_structure($data, '.', { + #'INBOX' => '\\HasChildren', + #'user' => '\\Noselect \\HasChildren', + #'shared stuff' => '\\Noselect \\HasChildren', + #}); + + xlog $self, "List *%"; + $data = $imaptalk->list("", "*%"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX' => '\\HasChildren', + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.INBOX.sub' => '\\HasNoChildren', + 'INBOX.INBOX.very' => '\\HasChildren', + 'INBOX.INBOX.very.deep' => '\\HasChildren', + 'INBOX.INBOX.very.deep.one' => '\\HasNoChildren', + 'INBOX.Inbox' => '\\HasChildren', + 'INBOX.Inbox.subnobody' => '\\HasChildren', + 'INBOX.Inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.inbox.subnobody' => '\\HasChildren', + 'INBOX.inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.not' => '\\HasChildren', + 'INBOX.not.so' => '\\HasChildren', + 'INBOX.not.so.deep' => '\\HasNoChildren', + 'INBOX.sub2' => '\\HasChildren', + 'INBOX.sub2.achild' => '\\HasNoChildren', + 'user' => '\\Noselect \\HasChildren', + 'user.bar' => '\\Noselect \\HasChildren', + 'user.bar.Trash' => '\\HasNoChildren', + 'user.foo' => '\\HasChildren', + 'user.foo.really' => '\\HasChildren', + 'user.foo.really.deep' => '\\HasNoChildren', + 'shared stuff' => '\\HasChildren', + 'shared stuff.something' => '\\HasNoChildren', + }); + + xlog $self, "LIST INBOX.*"; + $data = $imaptalk->list("INBOX.", "*"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.INBOX.sub' => '\\HasNoChildren', + 'INBOX.INBOX.very' => '\\HasChildren', + 'INBOX.INBOX.very.deep' => '\\HasChildren', + 'INBOX.INBOX.very.deep.one' => '\\HasNoChildren', + 'INBOX.Inbox' => '\\HasChildren', + 'INBOX.Inbox.subnobody' => '\\HasChildren', + 'INBOX.Inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.inbox.subnobody' => '\\HasChildren', + 'INBOX.inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.not' => '\\HasChildren', + 'INBOX.not.so' => '\\HasChildren', + 'INBOX.not.so.deep' => '\\HasNoChildren', + 'INBOX.sub2' => '\\HasChildren', + 'INBOX.sub2.achild' => '\\HasNoChildren', + }); + + xlog $self, "LIST INBOX.*%"; + $data = $imaptalk->list("INBOX.", "*%"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.INBOX.sub' => '\\HasNoChildren', + 'INBOX.INBOX.very' => '\\HasChildren', + 'INBOX.INBOX.very.deep' => '\\HasChildren', + 'INBOX.INBOX.very.deep.one' => '\\HasNoChildren', + 'INBOX.Inbox' => '\\HasChildren', + 'INBOX.Inbox.subnobody' => '\\HasChildren', + 'INBOX.Inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.inbox.subnobody' => '\\HasChildren', + 'INBOX.inbox.subnobody.deep' => '\\HasNoChildren', + 'INBOX.not' => '\\HasChildren', + 'INBOX.not.so' => '\\HasChildren', + 'INBOX.not.so.deep' => '\\HasNoChildren', + 'INBOX.sub2' => '\\HasChildren', + 'INBOX.sub2.achild' => '\\HasNoChildren', + }); + + xlog $self, "LIST INBOX.%"; + $data = $imaptalk->list("INBOX.", "%"); + $self->assert_mailbox_structure($data, '.', { + 'INBOX.AEARLY' => '\\HasNoChildren', + 'INBOX.INBOX' => '\\HasChildren', + 'INBOX.Inbox' => '\\HasChildren', + 'INBOX.inbox' => '\\HasChildren', + 'INBOX.not' => '\\HasChildren', + 'INBOX.sub2' => '\\HasChildren', + }); + + xlog $self, "List user.*"; + $data = $imaptalk->list("user.", "*"); + $self->assert_mailbox_structure($data, '.', { + 'user.bar.Trash' => '\\HasNoChildren', + 'user.foo' => '\\HasChildren', + 'user.foo.really' => '\\HasChildren', + 'user.foo.really.deep' => '\\HasNoChildren', + }); + + xlog $self, "List user.*%"; + $data = $imaptalk->list("user.", "*%"); + $self->assert_mailbox_structure($data, '.', { + 'user.bar' => '\\HasChildren', + 'user.bar.Trash' => '\\HasNoChildren', + 'user.foo' => '\\HasChildren', + 'user.foo.really' => '\\HasChildren', + 'user.foo.really.deep' => '\\HasNoChildren', + }); + + #xlog $self, "List user.%"; + #$data = $imaptalk->list("user.", "%"); + #$self->assert_mailbox_structure($data, '.', { + # 'user.bar' => '\\Noselect \\HasChildren', + # 'user.foo' => '\\HasChildren', + #}); + +} diff --git a/cassandane/tiny-tests/List/recursivematch b/cassandane/tiny-tests/List/recursivematch new file mode 100644 index 0000000000..890754b4d0 --- /dev/null +++ b/cassandane/tiny-tests/List/recursivematch @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_recursivematch + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( Fruit Fruit/Apple Fruit/Banana Fruit/Peach)] ], + [ 'subscribe' => [qw( Fruit/Banana Fruit/Peach )] ], + [ 'delete' => 'Fruit/Peach' ], + [ 'create' => [qw( Tofu Vegetable Vegetable/Broccoli Vegetable/Corn )] ], + [ 'subscribe' => [qw( Vegetable Vegetable/Broccoli )] ], + ]); + + my $subdata = $imaptalk->list([qw(SUBSCRIBED RECURSIVEMATCH)], "", "*"); + + xlog(Dumper $subdata); + $self->assert_mailbox_structure($subdata, '/', { + 'INBOX' => '\\Subscribed', + 'Fruit/Banana' => '\\Subscribed', + 'Fruit/Peach' => [qw( \\NonExistent \\Subscribed )], + 'Vegetable' => [qw( \\Subscribed \\HasChildren )], # HasChildren not required by spec, but cyrus tells us + 'Vegetable/Broccoli' => '\\Subscribed', + }); +} diff --git a/cassandane/tiny-tests/List/recursivematch_percent b/cassandane/tiny-tests/List/recursivematch_percent new file mode 100644 index 0000000000..831ae1151d --- /dev/null +++ b/cassandane/tiny-tests/List/recursivematch_percent @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_recursivematch_percent + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( Fruit Fruit/Apple Fruit/Banana Fruit/Peach)] ], + [ 'subscribe' => [qw( Fruit/Banana Fruit/Peach )] ], + [ 'delete' => 'Fruit/Peach' ], + [ 'create' => [qw( Tofu Vegetable Vegetable/Broccoli Vegetable/Corn )] ], + [ 'subscribe' => [qw( Vegetable Vegetable/Broccoli )] ], + ]); + + my $subdata = $imaptalk->list([qw(SUBSCRIBED RECURSIVEMATCH)], "", "%"); + + xlog(Dumper $subdata); + $self->assert_mailbox_structure($subdata, '/', { + 'INBOX' => [qw( \\Subscribed )], + 'Fruit' => [qw( \\NonExistent \\HasChildren )], + 'Vegetable' => [qw( \\Subscribed \\HasChildren )], # HasChildren not required by spec, but cyrus tells us + }); +} diff --git a/cassandane/tiny-tests/List/rfc5258_ex01_list_all b/cassandane/tiny-tests/List/rfc5258_ex01_list_all new file mode 100644 index 0000000000..e17c5e2371 --- /dev/null +++ b/cassandane/tiny-tests/List/rfc5258_ex01_list_all @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5258_ex01_list_all + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( Fruit Fruit/Apple Fruit/Banana Fruit/Peach)] ], + [ 'subscribe' => [qw( Fruit/Banana Fruit/Peach )] ], + [ 'delete' => 'Fruit/Peach' ], + [ 'create' => [qw( Tofu Vegetable Vegetable/Broccoli Vegetable/Corn )] ], + [ 'subscribe' => [qw( Vegetable Vegetable/Broccoli )] ], + ]); + + my $alldata = $imaptalk->list("", "*"); + + $self->assert_mailbox_structure($alldata, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'Fruit' => [qw( \\HasChildren )], + 'Fruit/Apple' => [qw( \\HasNoChildren )], + 'Fruit/Banana' => [qw( \\HasNoChildren )], + 'Tofu' => [qw( \\HasNoChildren )], + 'Vegetable' => [qw( \\HasChildren )], + 'Vegetable/Broccoli' => [qw( \\HasNoChildren )], + 'Vegetable/Corn' => [qw( \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/rfc5258_ex02_list_subscribed b/cassandane/tiny-tests/List/rfc5258_ex02_list_subscribed new file mode 100644 index 0000000000..8c2cd876e4 --- /dev/null +++ b/cassandane/tiny-tests/List/rfc5258_ex02_list_subscribed @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5258_ex02_list_subscribed + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( Fruit Fruit/Apple Fruit/Banana Fruit/Peach)] ], + [ 'subscribe' => [qw( Fruit/Banana Fruit/Peach )] ], + [ 'delete' => 'Fruit/Peach' ], + [ 'create' => [qw( Tofu Vegetable Vegetable/Broccoli Vegetable/Corn )] ], + [ 'subscribe' => [qw( Vegetable Vegetable/Broccoli )] ], + ]); + + my $subdata = $imaptalk->list([qw(SUBSCRIBED)], "", "*"); + + xlog(Dumper $subdata); + $self->assert_mailbox_structure($subdata, '/', { + 'INBOX' => '\\Subscribed', + 'Fruit/Banana' => '\\Subscribed', + 'Fruit/Peach' => [qw( \\NonExistent \\Subscribed )], + 'Vegetable' => [qw( \\Subscribed \\HasChildren )], # HasChildren not required by spec, but cyrus tells us + 'Vegetable/Broccoli' => '\\Subscribed', + }); +} diff --git a/cassandane/tiny-tests/List/rfc5258_ex03_children b/cassandane/tiny-tests/List/rfc5258_ex03_children new file mode 100644 index 0000000000..682db11ebb --- /dev/null +++ b/cassandane/tiny-tests/List/rfc5258_ex03_children @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5258_ex03_children + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'subscribe' => 'INBOX' ], + [ 'create' => [qw( Fruit Fruit/Apple Fruit/Banana Fruit/Peach)] ], + [ 'subscribe' => [qw( Fruit/Banana Fruit/Peach )] ], + [ 'delete' => 'Fruit/Peach' ], + [ 'create' => [qw( Tofu Vegetable Vegetable/Broccoli Vegetable/Corn )] ], + [ 'subscribe' => [qw( Vegetable Vegetable/Broccoli )] ], + ]); + + my $data = $imaptalk->list( + [qw()], "", "%", 'RETURN', [qw(CHILDREN)], + ); + + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [ '\\HasNoChildren' ], + 'Fruit' => [ '\\HasChildren' ], + 'Tofu' => [ '\\HasNoChildren' ], + 'Vegetable' => [ '\\HasChildren' ], + }); +} diff --git a/cassandane/tiny-tests/List/rfc5258_ex07_multiple_mailbox_patterns b/cassandane/tiny-tests/List/rfc5258_ex07_multiple_mailbox_patterns new file mode 100644 index 0000000000..30251c1654 --- /dev/null +++ b/cassandane/tiny-tests/List/rfc5258_ex07_multiple_mailbox_patterns @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5258_ex07_multiple_mailbox_patterns + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => 'Drafts' ], + [ 'create' => [qw( + Sent Sent/March2004 Sent/December2003 Sent/August2004 + )] ], + [ 'create' => [qw( Unlisted Unlisted/Foo )] ], + ]); + + my $data = $imaptalk->list("", [qw( INBOX Drafts Sent/% )]); + + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => [ '\\HasNoChildren' ], + 'Drafts' => [ '\\HasNoChildren' ], + 'Sent/August2004' => [ '\\HasNoChildren' ], + 'Sent/December2003' => [ '\\HasNoChildren' ], + 'Sent/March2004' => [ '\\HasNoChildren' ], + }); +} diff --git a/cassandane/tiny-tests/List/rfc5258_ex08_haschildren_childinfo b/cassandane/tiny-tests/List/rfc5258_ex08_haschildren_childinfo new file mode 100644 index 0000000000..b44f9f6ebb --- /dev/null +++ b/cassandane/tiny-tests/List/rfc5258_ex08_haschildren_childinfo @@ -0,0 +1,25 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5258_ex08_haschildren_childinfo + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( Foo Foo/Bar Foo/Baz Moo )] ], + ]); + + my $data = $imaptalk->list("", "%", "RETURN", [qw( CHILDREN )]); + + $self->assert_mailbox_structure($data, '/', { + 'INBOX' => '\\HasNoChildren', + 'Foo' => '\\HasChildren', + 'Moo' => '\\HasNoChildren', + }); + + # TODO probably break the rest of this test out into 8a, 8b etc + xlog('FIXME much more to test here...'); +} diff --git a/cassandane/tiny-tests/List/rfc6154_ex02a_list_return_special_use b/cassandane/tiny-tests/List/rfc6154_ex02a_list_return_special_use new file mode 100644 index 0000000000..e159f0e5a5 --- /dev/null +++ b/cassandane/tiny-tests/List/rfc6154_ex02a_list_return_special_use @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6154_ex02a_list_return_special_use + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( ToDo Projects Projects/Foo SentMail MyDrafts Trash) ] ], + ]); + + $imaptalk->setmetadata("SentMail", "/private/specialuse", "\\Sent"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("MyDrafts", "/private/specialuse", "\\Drafts"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("Trash", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + my $alldata = $imaptalk->list("", "%", 'RETURN', [qw( SPECIAL-USE )]); + + $self->assert_mailbox_structure($alldata, '/', { + 'INBOX' => [qw( \\HasNoChildren )], + 'ToDo' => [qw( \\HasNoChildren )], + 'Projects' => [qw( \\HasChildren )], + 'SentMail' => [qw( \\Sent \\HasNoChildren )], + 'MyDrafts' => [qw( \\Drafts \\HasNoChildren )], + 'Trash' => [qw( \\Trash \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/rfc6154_ex02b_list_special_use b/cassandane/tiny-tests/List/rfc6154_ex02b_list_special_use new file mode 100644 index 0000000000..b1226e3f1c --- /dev/null +++ b/cassandane/tiny-tests/List/rfc6154_ex02b_list_special_use @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc6154_ex02b_list_special_use + :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + $self->setup_mailbox_structure($imaptalk, [ + [ 'create' => [qw( ToDo Projects Projects/Foo SentMail MyDrafts Trash) ] ], + ]); + + $imaptalk->setmetadata("SentMail", "/private/specialuse", "\\Sent"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("MyDrafts", "/private/specialuse", "\\Drafts"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + $imaptalk->setmetadata("Trash", "/private/specialuse", "\\Trash"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + my $alldata = $imaptalk->list([qw( SPECIAL-USE )], "", "%"); + + $self->assert_mailbox_structure($alldata, '/', { + 'SentMail' => [qw( \\Sent \\HasNoChildren )], + 'MyDrafts' => [qw( \\Drafts \\HasNoChildren )], + 'Trash' => [qw( \\Trash \\HasNoChildren )], + }); +} diff --git a/cassandane/tiny-tests/List/virtdomains_return_subscribed_altns b/cassandane/tiny-tests/List/virtdomains_return_subscribed_altns new file mode 100644 index 0000000000..06ff923a0c --- /dev/null +++ b/cassandane/tiny-tests/List/virtdomains_return_subscribed_altns @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_virtdomains_return_subscribed_altns + :VirtDomains :UnixHierarchySep :AltNamespace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user/foo\@example.com"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo\@example.com"); + my $footalk = $foostore->get_client(); + + $footalk->create("Drafts"); + $footalk->create("Sent"); + $footalk->create("Trash"); + + $footalk->subscribe("INBOX"); + $footalk->subscribe("Drafts"); + $footalk->subscribe("Sent"); + $footalk->subscribe("Trash"); + + $footalk->setmetadata("Drafts", "/private/specialuse", "\\Drafts"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + $footalk->setmetadata("Sent", "/private/specialuse", "\\Sent"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + my $specialuse = $footalk->list([qw( SPECIAL-USE )], "", "*", + 'RETURN', [qw(SUBSCRIBED)]); + + xlog $self, Dumper $specialuse; + $self->assert_mailbox_structure($specialuse, '/', { + 'Sent' => [qw( \\Sent \\HasNoChildren \\Subscribed )], + 'Drafts' => [qw( \\Drafts \\HasNoChildren \\Subscribed )], + }); + + $admintalk->create("user/bar\@example.com"); + $admintalk->create("user/bar/shared-folder\@example.com"); # yay bogus domaining + $admintalk->setacl("user/bar/shared-folder\@example.com", + 'foo@example.com' => 'lrswipkxtecd'); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + + $footalk->subscribe("Other Users/bar/shared-folder"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + $admintalk->create("another-namespace\@example.com"); + $admintalk->create("another-namespace/folder\@example.com"); + $admintalk->setacl("another-namespace/folder\@example.com", + 'foo@example.com' => 'lrswipkxtecd'); + + $footalk->subscribe("Shared Folders/another-namespace/folder"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + my $alldata = $footalk->list("", "*", 'RETURN', [qw(SUBSCRIBED)]); + + xlog $self, Dumper $alldata; + $self->assert_mailbox_structure($alldata, '/', { + 'INBOX' => [qw( \\HasNoChildren \\Subscribed )], + 'Drafts' => [qw( \\HasNoChildren \\Subscribed )], + 'Sent' => [qw( \\HasNoChildren \\Subscribed )], + 'Trash' => [qw( \\HasNoChildren \\Subscribed )], + 'Other Users/bar/shared-folder' + => [qw( \\HasNoChildren \\Subscribed )], + 'Shared Folders/another-namespace' + => [qw( \\HasChildren )], + 'Shared Folders/another-namespace/folder' + => [qw( \\HasNoChildren \\Subscribed )], + }); +} diff --git a/cassandane/tiny-tests/List/virtdomains_return_subscribed_noaltns b/cassandane/tiny-tests/List/virtdomains_return_subscribed_noaltns new file mode 100644 index 0000000000..9f2a646113 --- /dev/null +++ b/cassandane/tiny-tests/List/virtdomains_return_subscribed_noaltns @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_virtdomains_return_subscribed_noaltns + :VirtDomains :UnixHierarchySep :NoAltNameSpace +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user/foo\@example.com"); + + my $foostore = $self->{instance}->get_service('imap')->create_store( + username => "foo\@example.com"); + my $footalk = $foostore->get_client(); + + $footalk->create("INBOX/Drafts"); + $footalk->create("INBOX/Sent"); + $footalk->create("INBOX/Trash"); + + $footalk->subscribe("INBOX"); + $footalk->subscribe("INBOX/Drafts"); + $footalk->subscribe("INBOX/Sent"); + $footalk->subscribe("INBOX/Trash"); + + $footalk->setmetadata("INBOX/Drafts", "/private/specialuse", "\\Drafts"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + $footalk->setmetadata("INBOX/Sent", "/private/specialuse", "\\Sent"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + my $specialuse = $footalk->list([qw( SPECIAL-USE )], "", "*", + 'RETURN', [qw(SUBSCRIBED)]); + + xlog $self, Dumper $specialuse; + $self->assert_mailbox_structure($specialuse, '/', { + 'INBOX/Sent' => [qw( \\Sent \\HasNoChildren \\Subscribed )], + 'INBOX/Drafts' => [qw( \\Drafts \\HasNoChildren \\Subscribed )], + }); + + $admintalk->create("user/bar\@example.com"); + $admintalk->create("user/bar/shared-folder\@example.com"); # yay bogus domaining + $admintalk->setacl("user/bar/shared-folder\@example.com", + 'foo@example.com' => 'lrswipkxtecd'); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + + $footalk->subscribe("user/bar/shared-folder"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + $admintalk->create("another-namespace\@example.com"); + $admintalk->create("another-namespace/folder\@example.com"); + $admintalk->setacl("another-namespace/folder\@example.com", + 'foo@example.com' => 'lrswipkxtecd'); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + + $footalk->subscribe("another-namespace/folder"); + $self->assert_equals('ok', $footalk->get_last_completion_response()); + + my $alldata = $footalk->list("", "*", 'RETURN', [qw(SUBSCRIBED)]); + + xlog $self, Dumper $alldata; + $self->assert_mailbox_structure($alldata, '/', { + 'INBOX' => [qw( \\HasChildren \\Subscribed )], + 'INBOX/Drafts' => [qw( \\HasNoChildren \\Subscribed )], + 'INBOX/Sent' => [qw( \\HasNoChildren \\Subscribed )], + 'INBOX/Trash' => [qw( \\HasNoChildren \\Subscribed )], + 'user/bar/shared-folder' => [qw( \\HasNoChildren \\Subscribed )], + 'another-namespace' => [qw( \\HasChildren ) ], + 'another-namespace/folder' => [qw( \\HasNoChildren \\Subscribed )], + }); +} diff --git a/cassandane/tiny-tests/Metadata/capabilities b/cassandane/tiny-tests/Metadata/capabilities new file mode 100644 index 0000000000..4aadd85ef6 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/capabilities @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +# +# Test the capabilities +# +sub test_capabilities +{ + my ($self) = @_; + my $imaptalk = $self->{store}->get_client(); + + my $caps = $imaptalk->capability(); + xlog $self, "RFC5257 defines capability ANNOTATE-EXPERIMENT-1"; + $self->assert_not_null($caps->{"annotate-experiment-1"}); + xlog $self, "RFC5464 defines capability METADATA"; + $self->assert_not_null($caps->{"metadata"}); +} diff --git a/cassandane/tiny-tests/Metadata/comment b/cassandane/tiny-tests/Metadata/comment new file mode 100644 index 0000000000..283d5fe700 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/comment @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_comment +{ + my ($self) = @_; + + xlog $self, "testing /shared/comment"; + + my $imaptalk = $self->{store}->get_client(); + my $res; + my $entry = '/shared/comment'; + my $value1 = "Hello World this is a value"; + + xlog $self, "initial value is NIL"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + + xlog $self, "cannot set the value as ordinary user"; + $imaptalk->setmetadata("", $entry, $value1); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert($imaptalk->get_last_error() =~ m/permission denied/i); + + xlog $self, "can set the value as admin"; + $imaptalk = $self->{adminstore}->get_client(); + $imaptalk->setmetadata("", $entry, $value1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "can get the set value back"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "the annot gives the same value in a new connection"; + $self->{adminstore}->disconnect(); + $imaptalk = $self->{adminstore}->get_client(); + $self->assert($imaptalk->state() == Mail::IMAPTalk::Authenticated); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "can delete value"; + $imaptalk->setmetadata("", $entry, undef); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => undef } + }; + $self->assert_deep_equals($expected, $res); +} diff --git a/cassandane/tiny-tests/Metadata/comment_repl b/cassandane/tiny-tests/Metadata/comment_repl new file mode 100644 index 0000000000..0a40fb1ba7 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/comment_repl @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_comment_repl + :Replication :SyncLog :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing /shared/comment"; + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + $self->assert_not_null($self->{replica}); + + my $imaptalk = $self->{master_store}->get_client(); + + my $res; + my $entry = '/shared/comment'; + my $value1 = "Hello World this is a value"; + + xlog $self, "initial value is NIL"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + + xlog $self, "can set the value as admin"; + $imaptalk = $self->{adminstore}->get_client(); + $imaptalk->setmetadata("", $entry, $value1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "replica value is NIL"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + xlog $self, "the annot gives the same value on the replica"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "can delete value"; + $imaptalk = $self->{adminstore}->get_client(); + $imaptalk->setmetadata("", $entry, undef); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => undef } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "run replication to clear annot"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + xlog $self, "replica value is NIL"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + +} diff --git a/cassandane/tiny-tests/Metadata/copy_messages b/cassandane/tiny-tests/Metadata/copy_messages new file mode 100644 index 0000000000..435e09740d --- /dev/null +++ b/cassandane/tiny-tests/Metadata/copy_messages @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_copy_messages +{ + my ($self) = @_; + + xlog $self, "testing COPY with message scope annotations (BZ3528)"; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $from_folder = 'INBOX.from'; + my $to_folder = 'INBOX.to'; + + $self->{store}->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Create subfolders to copy from and to"; + my $store = $self->{store}; + my $talk = $store->get_client(); + $talk->create($from_folder) + or die "Cannot create mailbox $from_folder: $@"; + $talk->create($to_folder) + or die "Cannot create mailbox $to_folder: $@"; + + $store->set_folder($from_folder); + + my @data_by_uid = ( + undef, + # data thanks to hipsteripsum.me + "american apparel", + "mixtape aesthetic", + "organic quinoa" + ); + + xlog $self, "Append some messages and store annotations"; + my %exp; + my $uid = 1; + while (defined $data_by_uid[$uid]) + { + my $data = $data_by_uid[$uid]; + my $msg = $self->make_message("Message $uid"); + $msg->set_attribute('uid', $uid); + $msg->set_annotation($entry, $attrib, $data); + $exp{$uid} = $msg; + $self->set_msg_annotation(undef, $uid, $entry, $attrib, $data); + $uid++; + } + + xlog $self, "Check the annotations are there"; + $self->check_messages(\%exp); + + xlog $self, "COPY the messages"; + $talk = $store->get_client(); + $talk->copy('1:*', $to_folder); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Messages are now in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Messages are still in the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Delete the messages from the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $talk = $store->get_client(); + $talk->store('1:*', '+flags', '(\\Deleted)'); + $talk->expunge(); + + xlog $self, "Messages are gone from the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $self->check_messages({}); + + xlog $self, "Messages are still in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + +} diff --git a/cassandane/tiny-tests/Metadata/createspecialuse b/cassandane/tiny-tests/Metadata/createspecialuse new file mode 100644 index 0000000000..6a8ddddcd8 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/createspecialuse @@ -0,0 +1,26 @@ +#!perl +use Cassandane::Tiny; + +sub test_createspecialuse + :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "testing create specialuse"; + + my $imaptalk = $self->{store}->get_client(); + my $res; + my $entry = '/private/specialuse'; + my $folder = "INBOX.Archive"; + my $use = "\\Archive"; + $imaptalk->create($folder, "(USE ($use))") + or die "Cannot create mailbox $folder with special-use $use: $@"; + + xlog $self, "initial value for $folder is $use"; + $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + $folder => { $entry => $use } + }, $res); +} diff --git a/cassandane/tiny-tests/Metadata/cvt_cyrusdb b/cassandane/tiny-tests/Metadata/cvt_cyrusdb new file mode 100644 index 0000000000..7ba9d1b12b --- /dev/null +++ b/cassandane/tiny-tests/Metadata/cvt_cyrusdb @@ -0,0 +1,121 @@ +#!perl +use Cassandane::Tiny; + +sub test_cvt_cyrusdb +{ + my ($self) = @_; + + xlog $self, "test cvt_cyrusdb between annotation db and flat files (BZ2686)"; + + my $folder = 'INBOX'; + my $fentry = '/private/comment'; + my $mentry = '/comment'; + my $mattrib = 'value.priv'; + my $evilchars = " \t\r\n\0\001"; + + my $store = $self->{store}; + $store->set_fetch_attributes('uid', "annotation ($mentry $mattrib)"); + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "store annotations"; + my $data = $self->make_random_data(2, maxreps => 20, separators => $evilchars); + $talk->setmetadata($folder, $fentry, $data); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "add some messages"; + my $uid = 1; + my %exp; + for (1..10) + { + my $msg = $self->make_message("Message $_"); + $exp{$uid} = $msg; + $msg->set_attribute('uid', $uid); + my $data = $self->make_random_data(7, maxreps => 20, separators => $evilchars); + $msg->set_annotation($mentry, $mattrib, $data); + $talk->store('' . $uid, 'annotation', + [$mentry, [$mattrib, $data]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $uid++; + } + + xlog $self, "Check the messages are all there"; + $self->check_messages(\%exp); + + xlog $self, "Check the mailbox annotation is still there"; + my $res = $talk->getmetadata($folder, $fentry); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => $data } + }, $res); + + xlog $self, "Shut down the instance"; + $self->{store}->disconnect(); + $self->{adminstore}->disconnect(); + $talk = undef; + $admintalk = undef; + $self->{instance}->stop(); + $self->{instance}->{re_use_dir} = 1; + + xlog $self, "Convert the global annotation db to flat"; + my $basedir = $self->{instance}->{basedir}; + my $global_db = "$basedir/conf/annotations.db"; + my $global_flat = "$basedir/xann.txt"; + my $format = $self->{instance}->{config}->get('annotation_db'); + $format = $format // 'twoskip'; + + $self->assert_not_file_test($global_flat, '-f'); + $self->{instance}->run_command({ cyrus => 1 }, + 'cvt_cyrusdb', + $global_db, $format, + $global_flat, 'flat'); + $self->assert_file_test($global_flat, '-f'); + + xlog $self, "Convert the mailbox annotation db to flat"; + my $datapath = $self->{instance}->folder_to_directory('user.cassandane'); + my $mailbox_db = "$datapath/cyrus.annotations"; + my $mailbox_flat = "$basedir/xcassann.txt"; + + $self->assert_not_file_test($mailbox_flat, '-f'); + $self->{instance}->run_command({ cyrus => 1 }, + 'cvt_cyrusdb', + $mailbox_db, $format, + $mailbox_flat, 'flat'); + $self->assert_file_test($mailbox_flat, '-f'); + + xlog $self, "Move aside the original annotation dbs"; + rename($global_db, "$global_db.NOT") + or die "Cannot rename $global_db to $global_db.NOT: $!"; + rename($mailbox_db, "$mailbox_db.NOT") + or die "Cannot rename $mailbox_db to $mailbox_db.NOT: $!"; + $self->assert_not_file_test($global_db, '-f'); + $self->assert_not_file_test($mailbox_db, '-f'); + + xlog $self, "restore the global annotation db from flat"; + $self->{instance}->run_command({ cyrus => 1 }, + 'cvt_cyrusdb', + $global_flat, 'flat', + $global_db, $format); + $self->assert_file_test($global_db, '-f'); + + xlog $self, "restore the mailbox annotation db from flat"; + $self->{instance}->run_command({ cyrus => 1 }, + 'cvt_cyrusdb', + $mailbox_flat, 'flat', + $mailbox_db, $format); + $self->assert_file_test($mailbox_db, '-f'); + + xlog $self, "Start the instance up again and reconnect"; + $self->{instance}->start(); + $talk = $store->get_client(); + + xlog $self, "Check the messages are still all there"; + $self->check_messages(\%exp); + + xlog $self, "Check the mailbox annotation is still there"; + $res = $talk->getmetadata($folder, $fentry); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => $data } + }, $res); +} diff --git a/cassandane/tiny-tests/Metadata/embedded_nuls b/cassandane/tiny-tests/Metadata/embedded_nuls new file mode 100644 index 0000000000..8ae6fafaa3 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/embedded_nuls @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_embedded_nuls +{ + my ($self) = @_; + + xlog $self, "testing getting and setting embedded NULs"; + + my $imaptalk = $self->{store}->get_client(); + my $folder = 'INBOX.test_embedded_nuls'; + my $entry = '/private/comment'; + my $binary = "Hello\0World"; + + xlog $self, "create a temporary mailbox"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + xlog $self, "initially, NIL is reported"; + my $res = $imaptalk->getmetadata($folder, $entry) + or die "Cannot get metadata: $@"; + $self->assert_num_equals(1, scalar keys %$res); + $self->assert_null($res->{$folder}{$entry}); + + xlog $self, "set and then get the same back again"; + $imaptalk->setmetadata($folder, $entry, $binary); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals($binary, $res->{$folder}{$entry}); + + xlog $self, "remove it again"; + $imaptalk->setmetadata($folder, $entry, undef); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "check it's gone now"; + $res = $imaptalk->getmetadata($folder, $entry) + or die "Cannot get metadata: $@"; + $self->assert_num_equals(1, scalar keys %$res); + $self->assert_null($res->{$folder}{$entry}); + + xlog $self, "clean up temporary mailbox"; + $imaptalk->delete($folder) + or die "Cannot delete mailbox $folder: $@"; +} diff --git a/cassandane/tiny-tests/Metadata/expunge_messages b/cassandane/tiny-tests/Metadata/expunge_messages new file mode 100644 index 0000000000..7f779cd721 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/expunge_messages @@ -0,0 +1,112 @@ +#!perl +use Cassandane::Tiny; + +sub test_expunge_messages +{ + my ($self) = @_; + + xlog $self, "testing expunge of messages with message scope"; + xlog $self, "annotations [IRIS-1553]"; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + + $self->{store}->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + my $talk = $self->{store}->get_client(); + $talk->uid(1); + + my @data_by_uid = ( + undef, + # data thanks to hipsteripsum.me + "polaroid seitan", + "bicycle rights", + "bushwick gastropub" + ); + + xlog $self, "Append some messages and store annotations"; + my %exp; + my $uid = 1; + while (defined $data_by_uid[$uid]) + { + my $data = $data_by_uid[$uid]; + my $msg = $self->make_message("Message $uid"); + $msg->set_annotation($entry, $attrib, $data); + $exp{$uid} = $msg; + $self->set_msg_annotation(undef, $uid, $entry, $attrib, $data); + $uid++; + } + + xlog $self, "Check the annotations are there"; + $self->check_messages(\%exp, keyed_on => 'uid'); + + xlog $self, "Check the annotations are in the DB too"; + my $r = $self->list_annotations(scope => 'message'); + $self->assert_deep_equals([ + { + mboxname => 'user.cassandane', + uid => 1, + entry => $entry, + userid => 'cassandane', + data => $data_by_uid[1] + }, + { + mboxname => 'user.cassandane', + uid => 2, + entry => $entry, + userid => 'cassandane', + data => $data_by_uid[2] + }, + { + mboxname => 'user.cassandane', + uid => 3, + entry => $entry, + userid => 'cassandane', + data => $data_by_uid[3] + } + ], $r); + + $uid = 1; + while (defined $data_by_uid[$uid]) + { + xlog $self, "Delete message $uid"; + $talk->store($uid, '+flags', '(\\Deleted)'); + $talk->expunge(); + + xlog $self, "Check the annotation is gone"; + delete $exp{$uid}; + $self->check_messages(\%exp); + $uid++; + } + + xlog $self, "Check the annotations are still in the DB"; + $r = $self->list_annotations(scope => 'message'); + $self->assert_deep_equals([ + { + mboxname => 'user.cassandane', + uid => 1, + entry => $entry, + userid => 'cassandane', + data => $data_by_uid[1] + }, + { + mboxname => 'user.cassandane', + uid => 2, + entry => $entry, + userid => 'cassandane', + data => $data_by_uid[2] + }, + { + mboxname => 'user.cassandane', + uid => 3, + entry => $entry, + userid => 'cassandane', + data => $data_by_uid[3] + } + ], $r); + + $self->run_delayed_expunge(); + + xlog $self, "Check the annotations are gone from the DB"; + $r = $self->list_annotations(scope => 'message'); + $self->assert_deep_equals([], $r); +} diff --git a/cassandane/tiny-tests/Metadata/folder_delete_mboxa_dmdel b/cassandane/tiny-tests/Metadata/folder_delete_mboxa_dmdel new file mode 100644 index 0000000000..f3b0befe09 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/folder_delete_mboxa_dmdel @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_folder_delete_mboxa_dmdel + :DelayedDelete +{ + my ($self) = @_; + + xlog $self, "test that per-mailbox GETMETADATA annotations are"; + xlog $self, "deleted with the mailbox; delete_mode = delayed (BZ2685)"; + + $self->assert_str_equals('delayed', + $self->{instance}->{config}->get('delete_mode')); + + $self->folder_delete_mboxa_common(); +} diff --git a/cassandane/tiny-tests/Metadata/folder_delete_mboxa_dmimm b/cassandane/tiny-tests/Metadata/folder_delete_mboxa_dmimm new file mode 100644 index 0000000000..5e09327d77 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/folder_delete_mboxa_dmimm @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_folder_delete_mboxa_dmimm + :ImmediateDelete +{ + my ($self) = @_; + + xlog $self, "test that per-mailbox GETMETADATA annotations are"; + xlog $self, "deleted with the mailbox; delete_mode = immediate (BZ2685)"; + + $self->assert_str_equals('immediate', + $self->{instance}->{config}->get('delete_mode')); + + $self->folder_delete_mboxa_common(); +} diff --git a/cassandane/tiny-tests/Metadata/folder_delete_mboxm_dmdel b/cassandane/tiny-tests/Metadata/folder_delete_mboxm_dmdel new file mode 100644 index 0000000000..64e14f4545 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/folder_delete_mboxm_dmdel @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_folder_delete_mboxm_dmdel + :DelayedDelete +{ + my ($self) = @_; + + xlog $self, "test that per-mailbox GETMETADATA annotations are"; + xlog $self, "deleted with the mailbox; delete_mode = delayed (BZ2685)"; + + $self->assert_str_equals('delayed', + $self->{instance}->{config}->get('delete_mode')); + + $self->folder_delete_mboxm_common(); +} diff --git a/cassandane/tiny-tests/Metadata/folder_delete_mboxm_dmimm b/cassandane/tiny-tests/Metadata/folder_delete_mboxm_dmimm new file mode 100644 index 0000000000..381900bc70 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/folder_delete_mboxm_dmimm @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_folder_delete_mboxm_dmimm + :ImmediateDelete +{ + my ($self) = @_; + + xlog $self, "test that per-mailbox GETMETADATA annotations are"; + xlog $self, "deleted with the mailbox; delete_mode = immediate (BZ2685)"; + + $self->assert_str_equals('immediate', + $self->{instance}->{config}->get('delete_mode')); + + $self->folder_delete_mboxm_common(); +} diff --git a/cassandane/tiny-tests/Metadata/folder_delete_msg_dmdel b/cassandane/tiny-tests/Metadata/folder_delete_msg_dmdel new file mode 100644 index 0000000000..7503b20640 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/folder_delete_msg_dmdel @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_folder_delete_msg_dmdel + :DelayedDelete +{ + my ($self) = @_; + + xlog $self, "test that per-message annotations are"; + xlog $self, "deleted with the mailbox; delete_mode = delayed (BZ2685)"; + + $self->assert_str_equals('delayed', + $self->{instance}->{config}->get('delete_mode')); + + $self->folder_delete_msg_common(); +} diff --git a/cassandane/tiny-tests/Metadata/folder_delete_msg_dmimm b/cassandane/tiny-tests/Metadata/folder_delete_msg_dmimm new file mode 100644 index 0000000000..77b984bbec --- /dev/null +++ b/cassandane/tiny-tests/Metadata/folder_delete_msg_dmimm @@ -0,0 +1,16 @@ +#!perl +use Cassandane::Tiny; + +sub test_folder_delete_msg_dmimm + :ImmediateDelete +{ + my ($self) = @_; + + xlog $self, "test that per-message annotations are"; + xlog $self, "deleted with the mailbox; delete_mode = immediate (BZ2685)"; + + $self->assert_str_equals('immediate', + $self->{instance}->{config}->get('delete_mode')); + + $self->folder_delete_msg_common(); +} diff --git a/cassandane/tiny-tests/Metadata/folder_move_partition b/cassandane/tiny-tests/Metadata/folder_move_partition new file mode 100644 index 0000000000..a4cc449f6d --- /dev/null +++ b/cassandane/tiny-tests/Metadata/folder_move_partition @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_folder_move_partition + :Partition2 +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + my $folder = 'user.asd'; + my $entry = '/shared/vendor/cmu/cyrus-imapd/expire'; + my $value = '13'; + + $admintalk->create($folder); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + + # annotation should be set yet + my $res = $admintalk->getmetadata($folder, $entry); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals({ + $entry => undef, + }, $res->{$folder}); + + # set an annotation + $admintalk->setmetadata($folder, $entry, $value); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + + # annotation should be set now + $res = $admintalk->getmetadata($folder, $entry); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals({ + $entry => $value, + }, $res->{$folder}); + + # move the mailbox to the other partition + $admintalk->rename($folder, $folder, 'p2'); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + + # annotation should still be set + $res = $admintalk->getmetadata($folder, $entry); + $self->assert_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals({ + $entry => $value, + }, $res->{$folder}); +} diff --git a/cassandane/tiny-tests/Metadata/getmetadata_depth b/cassandane/tiny-tests/Metadata/getmetadata_depth new file mode 100644 index 0000000000..24964a2dcd --- /dev/null +++ b/cassandane/tiny-tests/Metadata/getmetadata_depth @@ -0,0 +1,78 @@ +#!perl +use Cassandane::Tiny; + +sub test_getmetadata_depth + :AnnotationAllowUndefined +{ + my ($self) = @_; + + xlog $self, "test the GETMETADATA command with DEPTH option"; + + my $imaptalk = $self->{store}->get_client(); + # data thanks to hipsteripsum.me + my $folder = 'INBOX.denim'; + my %entries = ( + '/shared/selvage' => 'locavore', + '/shared/selvage/portland' => 'ennui', + '/shared/selvage/leggings' => 'scenester', + '/shared/selvage/portland/mustache' => 'terry richardson', + '/shared/selvage/portland/mustache/american' => 'messenger bag', + '/shared/selvage/portland/mustache/american/apparel' => 'street art', + ); + my $rootentry = '/shared/selvage'; + my $res; + + xlog $self, "Create folder"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + xlog $self, "Setup metadata"; + foreach my $entry (sort keys %entries) + { + $imaptalk->setmetadata($folder, $entry, $entries{$entry}) + or die "Cannot setmetadata: $@"; + } + + xlog $self, "Getting metadata with no DEPTH"; + $res = getmetadata($imaptalk, $folder, $rootentry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { $rootentry => $entries{$rootentry} } } , $res); + + xlog $self, "Getting metadata with DEPTH 0 in the right place"; + $res = getmetadata($imaptalk, $folder, [ DEPTH => 0 ], $rootentry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { $rootentry => $entries{$rootentry} } } , $res); + + xlog $self, "Getting metadata with DEPTH 1 in the right place"; + my @subset = ( qw(/shared/selvage /shared/selvage/portland /shared/selvage/leggings) ); + $res = getmetadata($imaptalk, $folder, [ DEPTH => 1 ], $rootentry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { map { $_ => $entries{$_} } @subset } }, $res); + + xlog $self, "Getting metadata with DEPTH infinity in the right place"; + $res = getmetadata($imaptalk, $folder, [ DEPTH => 'infinity' ], $rootentry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { %entries } } , $res); + + xlog $self, "Getting metadata with DEPTH 0 in the wrong place"; + $res = getmetadata($imaptalk, $folder, [ DEPTH => 0 ], $rootentry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { $rootentry => $entries{$rootentry} } } , $res); + + xlog $self, "Getting metadata with DEPTH 1 in the wrong place"; + $res = getmetadata($imaptalk, $folder, [ DEPTH => 1 ], $rootentry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { map { $_ => $entries{$_} } @subset } }, $res); + + xlog $self, "Getting metadata with DEPTH infinity in the wrong place"; + $res = getmetadata($imaptalk, $folder, [ DEPTH => 'infinity' ], $rootentry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { %entries } } , $res); +} diff --git a/cassandane/tiny-tests/Metadata/getmetadata_maxsize b/cassandane/tiny-tests/Metadata/getmetadata_maxsize new file mode 100644 index 0000000000..722d93caa9 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/getmetadata_maxsize @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_getmetadata_maxsize +{ + my ($self) = @_; + + xlog $self, "test the GETMETADATA command with the MAXSIZE option"; + + my $imaptalk = $self->{store}->get_client(); + # data thanks to hipsteripsum.me + my $folder = 'INBOX.denim'; + my $entry = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + my $res; + + xlog $self, "Create folder"; + $imaptalk->create($folder) + or die "Cannot create mailbox $folder: $@"; + + $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + + my $uuid = $res->{$folder}{$entry}; + $self->assert_not_null($uuid); + $self->assert($uuid =~ m/^[0-9a-z-]+$/); + + xlog $self, "Getting metadata with no MAXSIZE"; + $res = getmetadata($imaptalk, $folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { $entry => $uuid } } , $res); + + xlog $self, "Getting metadata with a large MAXSIZE in the right place"; + $res = getmetadata($imaptalk, [ MAXSIZE => 2048 ], $folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { $entry => $uuid } } , $res); + + xlog $self, "Getting metadata with a small MAXSIZE in the right place"; + $res = getmetadata($imaptalk, [ MAXSIZE => 8 ], $folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ longentries => length($uuid) } , $res); + + xlog $self, "Getting metadata with a large MAXSIZE in the wrong place"; + $res = getmetadata($imaptalk, $folder, [ MAXSIZE => 2048 ], $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ $folder => { $entry => $uuid } } , $res); + + xlog $self, "Getting metadata with a small MAXSIZE in the wrong place"; + $res = getmetadata($imaptalk, $folder, [ MAXSIZE => 8 ], $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ longentries => length($uuid) } , $res); +} diff --git a/cassandane/tiny-tests/Metadata/getmetadata_multiple_folders b/cassandane/tiny-tests/Metadata/getmetadata_multiple_folders new file mode 100644 index 0000000000..53ced18cd1 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/getmetadata_multiple_folders @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_getmetadata_multiple_folders +{ + my ($self) = @_; + + xlog $self, "test the Cyrus-specific extension to the GETMETADATA"; + xlog $self, "syntax which allows specifying a parenthesised list"; + xlog $self, "of folder names [IRIS-1109]"; + + my $imaptalk = $self->{store}->get_client(); + # data thanks to hipsteripsum.me + my @folders = ( qw(INBOX.denim INBOX.sustainable INBOX.biodiesel.vinyl) ); + my $entry = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + my %uuids; + + xlog $self, "Create folders"; + foreach my $f (@folders) + { + $imaptalk->create($f) + or die "Cannot create mailbox $f: $@"; + + my $res = $imaptalk->getmetadata($f, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + + my $uuid = $res->{$f}{$entry}; + $self->assert_not_null($uuid); + $self->assert($uuid =~ m/^[0-9a-z-]+$/); + $uuids{$f} = $uuid; + } + + xlog $self, "Getting metadata with a list of folder names"; + my @f2; + my %exp; + foreach my $f (@folders) + { + push(@f2, $f); + $exp{$f} = { $entry => $uuids{$f} }; + + my $res = $imaptalk->getmetadata(\@f2, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + + $self->assert_deep_equals(\%exp, $res); + } +} diff --git a/cassandane/tiny-tests/Metadata/mbox_replication_new_mas b/cassandane/tiny-tests/Metadata/mbox_replication_new_mas new file mode 100644 index 0000000000..1e8b725634 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/mbox_replication_new_mas @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_mbox_replication_new_mas + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of mailbox scope annotations"; + xlog $self, "case new_mas: new message appears, on master only"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + my $master_talk = $master_store->get_client(); + my $replica_talk = $replica_store->get_client(); + + my $folder = 'INBOX'; + my $entry = '/private/comment'; + my $value1 = "Hello World"; + my $res; + + xlog $self, "store an annotation"; + $master_talk->setmetadata($folder, $entry, $value1); + $self->assert_str_equals('ok', $master_talk->get_last_completion_response()); + + xlog $self, "Before replication, annotation is present on the master"; + $res = $master_talk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $master_talk->get_last_completion_response()); + $self->assert_deep_equals({ $folder => { $entry => $value1 } }, $res); + + xlog $self, "Before replication, annotation is missing from the replica"; + $res = $replica_talk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $replica_talk->get_last_completion_response()); + $self->assert_deep_equals({ $folder => { $entry => undef } }, $res); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $master_talk = $master_store->get_client(); + $replica_talk = $replica_store->get_client(); + + xlog $self, "After replication, annotation is still present on the master"; + $res = $master_talk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $master_talk->get_last_completion_response()); + $self->assert_deep_equals({ $folder => { $entry => $value1 } }, $res); + + xlog $self, "After replication, annotation is now present on the replica"; + $res = $replica_talk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $replica_talk->get_last_completion_response()); + $self->assert_deep_equals({ $folder => { $entry => $value1 } }, $res); +} diff --git a/cassandane/tiny-tests/Metadata/modseq b/cassandane/tiny-tests/Metadata/modseq new file mode 100644 index 0000000000..0e4c528039 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/modseq @@ -0,0 +1,110 @@ +#!perl +use Cassandane::Tiny; + +# +# Test interaction between RFC4551 modseq and STORE ANNOTATION +# - setting an annotation the message's modseq +# and the folder's highestmodseq +# - deleting an annotation bumps the message's modseq etc +# - modseq of other messages is never affected +# +sub test_modseq +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid modseq)); + + xlog $self, "Append 3 messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{B} = $self->make_message('Message B'); + $msg{C} = $self->make_message('Message C'); + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + xlog $self, "fetch an annotation - should be no values"; + my $hms0 = $self->get_highestmodseq(); + my $res = $talk->fetch('1:*', + ['modseq', 'annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { + modseq => [$hms0-2], + annotation => { $entry => { $attrib => undef } } + }, + 2 => { + modseq => [$hms0-1], + annotation => { $entry => { $attrib => undef } } + }, + 3 => { + modseq => [$hms0], + annotation => { $entry => { $attrib => undef } } + }, + }, + $res); + + xlog $self, "store an annotation"; + $talk->store('1', 'annotation', + [$entry, [$attrib, $value1]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch an annotation - should be updated"; + my $hms1 = $self->get_highestmodseq(); + $self->assert($hms1 > $hms0); + $res = $talk->fetch('1:*', + ['modseq', 'annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { + modseq => [$hms1], + annotation => { $entry => { $attrib => $value1 } } + }, + 2 => { + modseq => [$hms0-1], + annotation => { $entry => { $attrib => undef } } + }, + 3 => { + modseq => [$hms0], + annotation => { $entry => { $attrib => undef } } + }, + }, + $res); + + xlog $self, "delete an annotation"; + $talk->store('1', 'annotation', + [$entry, [$attrib, undef]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch an annotation - should be updated"; + my $hms2 = $self->get_highestmodseq(); + $self->assert($hms2 > $hms1); + $res = $talk->fetch('1:*', + ['modseq', 'annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { + modseq => [$hms2], + annotation => { $entry => { $attrib => undef } } + }, + 2 => { + modseq => [$hms0-1], + annotation => { $entry => { $attrib => undef } } + }, + 3 => { + modseq => [$hms0], + annotation => { $entry => { $attrib => undef } } + }, + }, + $res); +} diff --git a/cassandane/tiny-tests/Metadata/motd b/cassandane/tiny-tests/Metadata/motd new file mode 100644 index 0000000000..c7fa4f81c7 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/motd @@ -0,0 +1,86 @@ +#!perl +use Cassandane::Tiny; + +# +# Test the /shared/motd server annotation. +# +# Note: this needs the Mail::IMAPTalk install to have commit +# "Alert reponse is remainder of line, put that in the response code" +# +sub test_motd +{ + my ($self) = @_; + + xlog $self, "testing /shared/motd"; + + my $imaptalk = $self->{store}->get_client(); + my $res; + my $entry = '/shared/motd'; + my $value1 = "Hello World this is a value"; + + xlog $self, "No ALERT was received when we connected"; + $self->assert($imaptalk->state() == Mail::IMAPTalk::Authenticated); + $self->assert_null($imaptalk->get_response_code('alert')); + + xlog $self, "initial value is NIL"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + + xlog $self, "cannot set the value as ordinary user"; + $imaptalk->setmetadata("", $entry, $value1); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert($imaptalk->get_last_error() =~ m/permission denied/i); + + xlog $self, "can set the value as admin"; + $imaptalk = $self->{adminstore}->get_client(); + $imaptalk->setmetadata("", $entry, $value1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "can get the set value back"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "a new connection will get an ALERT with the motd value"; + $self->{adminstore}->disconnect(); + $imaptalk = $self->{adminstore}->get_client(); + $self->assert($imaptalk->state() == Mail::IMAPTalk::Authenticated); + my $alert = $imaptalk->get_response_code('alert'); + $self->assert_not_null($alert); + $self->assert_str_equals($value1, $alert); + + xlog $self, "the annot gives the same value in the new connection"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "can delete value"; + $imaptalk->setmetadata("", $entry, undef); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => undef } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "a new connection no longer gets an ALERT"; + $self->{adminstore}->disconnect(); + $imaptalk = $self->{adminstore}->get_client(); + $self->assert($imaptalk->state() == Mail::IMAPTalk::Authenticated); + $self->assert_null($imaptalk->get_response_code('alert')); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_exp_bot b/cassandane/tiny-tests/Metadata/msg_replication_exp_bot new file mode 100644 index 0000000000..e857ee5faa --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_exp_bot @@ -0,0 +1,77 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_exp_bot + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case exp_bot: message is expunged, on both ends"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message and store an annotation"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $value1); + $master_exp{A}->set_attribute('uid', 1); + $master_exp{A}->set_annotation($entry, $attrib, $value1); + + xlog $self, "Before first replication, message is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before first replication, message is missing from the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the message"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After first replication, message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After first replication, message is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Delete and expunge the message on the master"; + my $talk = $master_store->get_client(); + $master_store->_select(); + $talk->store('1', '+flags', '(\\Deleted)'); + $talk->expunge(); + + xlog $self, "Delete and expunge the message on the replica"; + $talk = $replica_store->get_client(); + $replica_store->_select(); + $talk->store('1', '+flags', '(\\Deleted)'); + $talk->expunge(); + + delete $master_exp{A}; + delete $replica_exp{A}; + xlog $self, "Before second replication, the message is now missing on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before second replication, the message is now missing on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the expunge"; + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After second replication, the message is still missing on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After second replication, the message is still missing on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_exp_mas b/cassandane/tiny-tests/Metadata/msg_replication_exp_mas new file mode 100644 index 0000000000..59a1736986 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_exp_mas @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_exp_mas + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case exp_mas: message is expunged, on master only"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message and store an annotation"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $value1); + $master_exp{A}->set_attribute('uid', 1); + $master_exp{A}->set_annotation($entry, $attrib, $value1); + + xlog $self, "Before first replication, message is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before first replication, message is missing from the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the message"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After first replication, message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After first replication, message is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Delete and expunge the message on the master"; + my $talk = $master_store->get_client(); + $master_store->_select(); + $talk->store('1', '+flags', '(\\Deleted)'); + $talk->expunge(); + + delete $master_exp{A}; + xlog $self, "Before second replication, the message is now missing on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before second replication, the message is still present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the expunge"; + $self->run_replication(); + $self->check_replication('cassandane'); + + delete $replica_exp{A}; + xlog $self, "After second replication, the message is still missing on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After second replication, the message is now missing on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_exp_rep b/cassandane/tiny-tests/Metadata/msg_replication_exp_rep new file mode 100644 index 0000000000..a16a61e5ec --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_exp_rep @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_exp_rep + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case exp_rep: message is expunged, on replica only"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message and store an annotation"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $value1); + $master_exp{A}->set_attribute('uid', 1); + $master_exp{A}->set_annotation($entry, $attrib, $value1); + + xlog $self, "Before first replication, message is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before first replication, message is missing from the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the message"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After first replication, message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After first replication, message is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Delete and expunge the message on the replica"; + my $talk = $replica_store->get_client(); + $replica_store->_select(); + $talk->store('1', '+flags', '(\\Deleted)'); + $talk->expunge(); + + delete $replica_exp{A}; + xlog $self, "Before second replication, the message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before second replication, the message is now missing on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the expunge"; + $self->run_replication(); + $self->check_replication('cassandane'); + + delete $master_exp{A}; + xlog $self, "After second replication, the message is now missing on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After second replication, the message is still missing on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_mod_bot_msh b/cassandane/tiny-tests/Metadata/msg_replication_mod_bot_msh new file mode 100644 index 0000000000..552d0e2cec --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_mod_bot_msh @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_mod_bot_msh + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case mod_bot_msh: message is modified, on both ends, " . + "modseq higher on master"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $valueA1 = "Hello World"; + my $valueA2 = "and friends"; + my $valueB = "Jeepers"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $master_exp{A}->set_attribute('uid', 1); + + xlog $self, "Before first replication, message is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before first replication, message is missing from the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the message"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After first replication, message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After first replication, message is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Set an annotation twice on the master, once on the replica"; + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $valueA1); + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $valueA2); + $master_exp{A}->set_annotation($entry, $attrib, $valueA2); + $self->set_msg_annotation($replica_store, 1, $entry, $attrib, $valueB); + $replica_exp{A}->set_annotation($entry, $attrib, $valueB); + + xlog $self, "Before second replication, one message annotation is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before second replication, a different message annotation is present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the annotation change"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A}->set_annotation($entry, $attrib, $valueA2); + xlog $self, "After second replication, the message annotation is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After second replication, the message annotation is updated on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_mod_bot_msl b/cassandane/tiny-tests/Metadata/msg_replication_mod_bot_msl new file mode 100644 index 0000000000..f0d9617cc3 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_mod_bot_msl @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_mod_bot_msl + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case mod_bot_msl: message is modified, on both ends, " . + "modseq lower on master"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $valueA = "Hello World"; + my $valueB1 = "Jeepers"; + my $valueB2 = "Creepers"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $master_exp{A}->set_attribute('uid', 1); + + xlog $self, "Before first replication, message is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before first replication, message is missing from the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the message"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After first replication, message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After first replication, message is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Set an annotation once on the master, twice on the replica"; + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $valueA); + $master_exp{A}->set_annotation($entry, $attrib, $valueA); + $self->set_msg_annotation($replica_store, 1, $entry, $attrib, $valueB1); + $self->set_msg_annotation($replica_store, 1, $entry, $attrib, $valueB2); + $replica_exp{A}->set_annotation($entry, $attrib, $valueB2); + + xlog $self, "Before second replication, one message annotation is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before second replication, a different message annotation is present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the annotation change"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $master_exp{A}->set_annotation($entry, $attrib, $valueB2); + xlog $self, "After second replication, the message annotation is updated on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After second replication, the message annotation is still present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_mod_mas b/cassandane/tiny-tests/Metadata/msg_replication_mod_mas new file mode 100644 index 0000000000..5c963f6058 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_mod_mas @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_mod_mas + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case mod_mas: message is modified, on master only"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $master_exp{A}->set_attribute('uid', 1); + + xlog $self, "Before first replication, message is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before first replication, message is missing from the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the message"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After first replication, message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After first replication, message is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Set an annotation on the master"; + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $value1); + $master_exp{A}->set_annotation($entry, $attrib, $value1); + + xlog $self, "Before second replication, the message annotation is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before second replication, the message annotation is missing on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After second replication, the message annotation is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After second replication, the message annotation is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_mod_rep b/cassandane/tiny-tests/Metadata/msg_replication_mod_rep new file mode 100644 index 0000000000..640154ea9a --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_mod_rep @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_mod_rep + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case mod_rep: message is modified, on replica only"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $master_exp{A}->set_attribute('uid', 1); + + xlog $self, "Before first replication, message is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before first replication, message is missing from the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Replicate the message"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After first replication, message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After first replication, message is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Set an annotation on the master"; + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $value1); + $master_exp{A}->set_annotation($entry, $attrib, $value1); + + xlog $self, "Before second replication, the message annotation is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before second replication, the message annotation is missing on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After second replication, the message annotation is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After second replication, the message annotation is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_new_bot_mse_guh b/cassandane/tiny-tests/Metadata/msg_replication_new_bot_mse_guh new file mode 100644 index 0000000000..a526c41827 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_new_bot_mse_guh @@ -0,0 +1,67 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_new_bot_mse_guh + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case new_bot_mse_guh: new messages appear, on both master " . + "and replica, with equal modseqs, higher GUID on master."; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $valueA = "Hello World"; + my $valueB = "Hello Dolly"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message and store an annotation to each store"; + my ($msgB, $msgA) = $self->make_message_pair($replica_store, $master_store); + my %master_exp = ( A => $msgA ); + my %replica_exp = ( B => $msgB ); + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $valueA); + $master_exp{A}->set_attribute('uid', 1); + $master_exp{A}->set_annotation($entry, $attrib, $valueA); + $self->set_msg_annotation($replica_store, 1, $entry, $attrib, $valueB); + $replica_exp{B}->set_attribute('uid', 1); + $replica_exp{B}->set_annotation($entry, $attrib, $valueB); + + xlog $self, "Before replication, only message A is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before replication, only message B is present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, both messages are now present and renumbered on the master"; + $master_exp{B} = $replica_exp{B}->clone(); + $master_exp{B}->set_attribute('uid', 2); + $master_exp{A}->set_attribute('uid', 3); + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After replication, both messages are now present and renumbered on the replica"; + $replica_exp{A} = $master_exp{A}->clone(); + $replica_exp{B}->set_attribute('uid', 2); + $replica_exp{A}->set_attribute('uid', 3); + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); + + # We should have generated a SYNCERROR or two + my $pattern = qr{ + \bSYNCERROR:\sguid\smismatch + (?: \suser\.cassandane\s1\b + | :\smailbox=\suid=<1> + ) + }x; + $self->assert_syslog_matches($self->{instance}, $pattern); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_new_bot_mse_gul b/cassandane/tiny-tests/Metadata/msg_replication_new_bot_mse_gul new file mode 100644 index 0000000000..1e9e4f6b3e --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_new_bot_mse_gul @@ -0,0 +1,67 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_new_bot_mse_gul + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case new_bot_mse_gul: new messages appear, on both master " . + "and replica, with equal modseqs, lower GUID on master."; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $valueA = "Hello World"; + my $valueB = "Hello Dolly"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message and store an annotation to each store"; + my ($msgA, $msgB) = $self->make_message_pair($master_store, $replica_store); + my %master_exp = ( A => $msgA ); + my %replica_exp = ( B => $msgB ); + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $valueA); + $master_exp{A}->set_attribute('uid', 1); + $master_exp{A}->set_annotation($entry, $attrib, $valueA); + $self->set_msg_annotation($replica_store, 1, $entry, $attrib, $valueB); + $replica_exp{B}->set_attribute('uid', 1); + $replica_exp{B}->set_annotation($entry, $attrib, $valueB); + + xlog $self, "Before replication, only message A is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before replication, only message B is present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, both messages are now present and renumbered on the master"; + $master_exp{B} = $replica_exp{B}->clone(); + $master_exp{A}->set_attribute('uid', 2); + $master_exp{B}->set_attribute('uid', 3); + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After replication, both messages are now present and renumbered on the replica"; + $replica_exp{A} = $master_exp{A}->clone(); + $replica_exp{A}->set_attribute('uid', 2); + $replica_exp{B}->set_attribute('uid', 3); + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); + + # We should have generated a SYNCERROR or two + my $pattern = qr{ + \bSYNCERROR:\sguid\smismatch + (?: \suser\.cassandane\s1\b + | :\smailbox=\suid=<1> + ) + }x; + $self->assert_syslog_matches($self->{instance}, $pattern); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_new_mas b/cassandane/tiny-tests/Metadata/msg_replication_new_mas new file mode 100644 index 0000000000..0d003e5eef --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_new_mas @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_new_mas + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case new_mas: new message appears, on master only"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message and store an annotation"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $self->set_msg_annotation($master_store, 1, $entry, $attrib, $value1); + $master_exp{A}->set_attribute('uid', 1); + $master_exp{A}->set_annotation($entry, $attrib, $value1); + + xlog $self, "Before replication, message is present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before replication, message is missing from the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $replica_exp{A} = $master_exp{A}->clone(); + xlog $self, "After replication, message is still present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After replication, message is now present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_new_mas_partial_wwd b/cassandane/tiny-tests/Metadata/msg_replication_new_mas_partial_wwd new file mode 100644 index 0000000000..5b66c776b6 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_new_mas_partial_wwd @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_new_mas_partial_wwd + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing partial replication of message scope annotations"; + xlog $self, "case master to replica: write, write, delete"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + xlog $self, "Append a message"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $master_exp{A}->set_attribute('uid', 1); + + xlog $self, "Run replication"; + $self->run_replication(); + $self->check_msg_annotation_replication($master_store, $replica_store); + + xlog $self, "Write an annotation"; + $self->set_msg_annotation($master_store, 1, '/comment', 'value.priv', 'c1'); + + xlog $self, "Run replication"; + $self->run_replication(); + $self->check_msg_annotation_replication($master_store, $replica_store); + + xlog $self, "Write another few annotations"; + $self->set_msg_annotation($master_store, 1, '/altsubject', 'value.priv', 'a1'); + $self->set_msg_annotation($master_store, 1, '/comment', 'value.shared', 'cs'); + $self->set_msg_annotation($master_store, 1, '/altsubject', 'value.shared', 'as'); + + xlog $self, "Run replication"; + $self->run_replication(); + $self->check_msg_annotation_replication($master_store, $replica_store); + + xlog $self, "Delete the first annotation"; + $self->set_msg_annotation($master_store, 1, '/comment', 'value.priv', ''); + $self->set_msg_annotation($master_store, 1, '/altsubject', 'value.shared', ''); + + xlog $self, "Run replication"; + $self->run_replication(); + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_new_mas_partial_wwsw b/cassandane/tiny-tests/Metadata/msg_replication_new_mas_partial_wwsw new file mode 100644 index 0000000000..6acbb261c7 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_new_mas_partial_wwsw @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_new_mas_partial_wwsw + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing partial replication of message scope annotations"; + xlog $self, "case master to replica: write, write, sync, write"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + xlog $self, "Append a message"; + my %master_exp; + my %replica_exp; + $master_exp{A} = $self->make_message('Message A', store => $master_store); + $master_exp{A}->set_attribute('uid', 1); + + xlog $self, "Run replication"; + $self->run_replication(); + $self->check_msg_annotation_replication($master_store, $replica_store); + + xlog $self, "Write an annotation twice"; + $self->set_msg_annotation($master_store, 1, '/comment', 'value.priv', 'c1'); + $self->set_msg_annotation($master_store, 1, '/comment', 'value.priv', 'c2'); + + xlog $self, "Run replication"; + $self->run_replication(); + $self->check_msg_annotation_replication($master_store, $replica_store); + + xlog $self, "Write another annotation"; + $self->set_msg_annotation($master_store, 1, '/altsubject', 'value.priv', 'a1'); + + xlog $self, "Run replication"; + $self->run_replication(); + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_replication_new_rep b/cassandane/tiny-tests/Metadata/msg_replication_new_rep new file mode 100644 index 0000000000..f668633329 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_replication_new_rep @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_replication_new_rep + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of message scope annotations"; + xlog $self, "case new_rep: new message appears, on replica only"; + + xlog $self, "need a master and replica pair"; + $self->assert_not_null($self->{replica}); + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + $master_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + $replica_store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append a message and store an annotation"; + my %master_exp; + my %replica_exp; + $replica_exp{A} = $self->make_message('Message A', store => $replica_store); + $self->set_msg_annotation($replica_store, 1, $entry, $attrib, $value1); + $replica_exp{A}->set_attribute('uid', 1); + $replica_exp{A}->set_annotation($entry, $attrib, $value1); + + xlog $self, "Before replication, message is missing from the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "Before replication, message is present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $master_exp{A} = $replica_exp{A}->clone(); + xlog $self, "After replication, message is now present on the master"; + $self->check_messages(\%master_exp, store => $master_store); + xlog $self, "After replication, message is still present on the replica"; + $self->check_messages(\%replica_exp, store => $replica_store); + + xlog $self, "Check that annotations in the master and replica DB match"; + $self->check_msg_annotation_replication($master_store, $replica_store); +} diff --git a/cassandane/tiny-tests/Metadata/msg_sort_order b/cassandane/tiny-tests/Metadata/msg_sort_order new file mode 100644 index 0000000000..f5f339ceea --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_sort_order @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_sort_order +{ + my ($self) = @_; + + xlog $self, "testing RFC5257 SORT command ANNOTATION order criterion"; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + # 20 random dictionary words + my @values = ( qw(gradual flips tempe cud flaunt nina crackle congo), + qw(buttons coating byrd arise ayyubid badgers argosy), + qw(sutton dallied belled fondues mimi) ); + # the expected result of sorting those words alphabetically + my @exp_order = ( 15, 12, 13, 14, 18, 9, 11, 10, 8, + 7, 4, 17, 5, 2, 19, 1, 20, 6, 16, 3 ); + + $self->{store}->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append some messages and store annotations"; + my %exp; + for (my $i = 0 ; $i < 20 ; $i++) + { + my $letter = chr(ord('A')+$i); + my $uid = $i+1; + my $value = $values[$i]; + + $exp{$letter} = $self->make_message("Message $letter"); + $self->set_msg_annotation(undef, $uid, $entry, $attrib, $value); + $exp{$letter}->set_attribute('uid', $uid); + $exp{$letter}->set_annotation($entry, $attrib, $value); + } + $self->check_messages(\%exp); + + my $talk = $self->{store}->get_client(); + + xlog $self, "run the SORT command with an ANNOTATION order criterion"; + my $res = $talk->sort("(ANNOTATION $entry $attrib)", 'utf-8', 'all'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals(\@exp_order, $res); +} diff --git a/cassandane/tiny-tests/Metadata/msg_sort_search b/cassandane/tiny-tests/Metadata/msg_sort_search new file mode 100644 index 0000000000..a6f8b79350 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/msg_sort_search @@ -0,0 +1,56 @@ +#!perl +use Cassandane::Tiny; + +sub test_msg_sort_search +{ + my ($self) = @_; + + xlog $self, "testing RFC5257 SORT command ANNOTATION search criterion"; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + # 10 random dictionary words, and 10 carefully chosen ones + my @values = ( qw(deirdre agreed feedback cuspids breeds decreed greedily), + qw(gibbers eakins flash needful yules linseed equine hangman), + qw(hatters ragweed pureed cloaked heedless) ); + # the expected result of sorting the words with 'eed' alphabetically + my @exp_order = ( 2, 5, 6, 3, 7, 20, 13, 11, 18, 17 ); + # the expected result of search for words with 'eed' and uid order + my @exp_search = ( 2, 3, 5, 6, 7, 11, 13, 17, 18, 20 ); + + $self->{store}->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Append some messages and store annotations"; + my %exp; + my $now = DateTime->now->epoch; + for (my $i = 0 ; $i < 20 ; $i++) + { + my $letter = chr(ord('A')+$i); + my $uid = $i+1; + my $value = $values[$i]; + my $date = DateTime->from_epoch(epoch => $now - (20-$i)*60); + + $exp{$letter} = $self->make_message("Message $letter", + date => $date); + $self->set_msg_annotation(undef, $uid, $entry, $attrib, $value); + $exp{$letter}->set_attribute('uid', $uid); + $exp{$letter}->set_annotation($entry, $attrib, $value); + } + $self->check_messages(\%exp); + + my $talk = $self->{store}->get_client(); + + xlog $self, "run the SORT command with an ANNOTATION search criterion"; + my $res = $talk->sort("(DATE)", 'utf-8', + 'ANNOTATION', $entry, $attrib, { Quote => "eed" }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals(\@exp_search, $res); + + xlog $self, "run the SORT command with both ANNOTATION search & order criteria"; + $res = $talk->sort("(ANNOTATION $entry $attrib)", 'utf-8', + 'ANNOTATION', $entry, $attrib, { Quote => "eed" }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals(\@exp_order, $res); +} diff --git a/cassandane/tiny-tests/Metadata/nonexistant_mailbox b/cassandane/tiny-tests/Metadata/nonexistant_mailbox new file mode 100644 index 0000000000..c0c4caa44e --- /dev/null +++ b/cassandane/tiny-tests/Metadata/nonexistant_mailbox @@ -0,0 +1,22 @@ +#!perl +use Cassandane::Tiny; + +sub test_nonexistant_mailbox +{ + my ($self) = @_; + my $imaptalk = $self->{store}->get_client(); + my $entry = '/shared/comment'; + my $folder = 'INBOX.nonesuch'; + # data thanks to hipsteripsum.me + my $value1 = "Farm-to-table"; + + my $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert($imaptalk->get_last_error() =~ m/does not exist/i); + $self->assert_null($res); + + $res = $imaptalk->setmetadata($folder, $entry, $value1); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert($imaptalk->get_last_error() =~ m/does not exist/i); + $self->assert_null($res); +} diff --git a/cassandane/tiny-tests/Metadata/permessage_getset b/cassandane/tiny-tests/Metadata/permessage_getset new file mode 100644 index 0000000000..01bd9b676b --- /dev/null +++ b/cassandane/tiny-tests/Metadata/permessage_getset @@ -0,0 +1,132 @@ +#!perl +use Cassandane::Tiny; + +sub test_permessage_getset +{ + my ($self) = @_; + + xlog $self, "testing getting and setting message scope annotations"; + + my $talk = $self->{store}->get_client(); + + xlog $self, "Append 3 messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{B} = $self->make_message('Message B'); + $msg{C} = $self->make_message('Message C'); + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + my $value2 = "Goodnight\0Irene"; + my $value3 = "Gump"; + + xlog $self, "fetch an annotation - should be no values"; + my $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => undef } } }, + 2 => { annotation => { $entry => { $attrib => undef } } }, + 3 => { annotation => { $entry => { $attrib => undef } } }, + }, + $res); + + xlog $self, "store an annotation"; + $talk->store('1', 'annotation', + [$entry, [$attrib, $value1]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch the annotation again, should see changes"; + $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => $value1 } } }, + 2 => { annotation => { $entry => { $attrib => undef } } }, + 3 => { annotation => { $entry => { $attrib => undef } } }, + }, + $res); + + xlog $self, "store an annotation with an embedded NUL"; + $talk->store('3', 'annotation', + [$entry, [$attrib, $value2]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch the annotation again, should see changes"; + $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => $value1 } } }, + 2 => { annotation => { $entry => { $attrib => undef } } }, + 3 => { annotation => { $entry => { $attrib => $value2 } } }, + }, + $res); + + xlog $self, "store multiple annotations"; + # Note $value3 has no whitespace so we have to + # convince Mail::IMAPTalk to quote it anyway + $talk->store('1:*', 'annotation', + [$entry, [$attrib, { Quote => $value3 }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch the annotation again, should see changes"; + $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => $value3 } } }, + 2 => { annotation => { $entry => { $attrib => $value3 } } }, + 3 => { annotation => { $entry => { $attrib => $value3 } } }, + }, + $res); + + xlog $self, "delete an annotation"; + # Note $value3 has no whitespace so we have to + # convince Mail::IMAPTalk to quote it anyway + $talk->store('2', 'annotation', + [$entry, [$attrib, undef]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch the annotation again, should see changes"; + $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => $value3 } } }, + 2 => { annotation => { $entry => { $attrib => undef } } }, + 3 => { annotation => { $entry => { $attrib => $value3 } } }, + }, + $res); + + xlog $self, "delete all annotations"; + # Note $value3 has no whitespace so we have to + # convince Mail::IMAPTalk to quote it anyway + $talk->store('1:*', 'annotation', + [$entry, [$attrib, undef]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch the annotation again, should see changes"; + $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => undef } } }, + 2 => { annotation => { $entry => { $attrib => undef } } }, + 3 => { annotation => { $entry => { $attrib => undef } } }, + }, + $res); +} diff --git a/cassandane/tiny-tests/Metadata/permessage_unknown b/cassandane/tiny-tests/Metadata/permessage_unknown new file mode 100644 index 0000000000..801d39480e --- /dev/null +++ b/cassandane/tiny-tests/Metadata/permessage_unknown @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_permessage_unknown +{ + my ($self) = @_; + + xlog $self, "testing getting and setting unknown annotations on a message"; + xlog $self, "where this is forbidden by the default config"; + + xlog $self, "Append a message"; + my %msg; + $msg{A} = $self->make_message('Message A'); + + my $entry = '/thisentryisnotdefined'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + xlog $self, "fetch annotation - should be no values"; + my $talk = $self->{store}->get_client(); + my $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => undef } } } + }, + $res); + + xlog $self, "store annotation - should fail"; + $talk->store('1', 'annotation', + [$entry, [$attrib, $value1]]); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + + xlog $self, "fetch the annotation again, should see nothing"; + $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => undef } } } + }, + $res); +} diff --git a/cassandane/tiny-tests/Metadata/permessage_unknown_allowed b/cassandane/tiny-tests/Metadata/permessage_unknown_allowed new file mode 100644 index 0000000000..0adbf1f8dc --- /dev/null +++ b/cassandane/tiny-tests/Metadata/permessage_unknown_allowed @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_permessage_unknown_allowed + :AnnotationAllowUndefined +{ + my ($self) = @_; + + xlog $self, "testing getting and setting unknown annotations on a message"; + xlog $self, "with config allowing this"; + + xlog $self, "Append a message"; + my %msg; + $msg{A} = $self->make_message('Message A'); + + my $entry = '/thisentryisnotdefined'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + + xlog $self, "fetch annotation - should be no values"; + my $talk = $self->{store}->get_client(); + my $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => undef } } } + }, + $res); + + xlog $self, "store annotation - should succeed"; + $talk->store('1', 'annotation', + [$entry, [$attrib, $value1]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch the annotation again, should see the value"; + $res = $talk->fetch('1:*', + ['annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { annotation => { $entry => { $attrib => $value1 } } } + }, + $res); +} diff --git a/cassandane/tiny-tests/Metadata/private b/cassandane/tiny-tests/Metadata/private new file mode 100644 index 0000000000..10c8cec414 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/private @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_private +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "testing private metadata operations"; + + xlog $self, "testing specific entries"; + my $res = $imaptalk->getmetadata('INBOX', {depth => 'infinity'}, '/private'); + my $r = $res->{INBOX}; + $self->assert_not_null($r); + my %specific_entries = ( + '/private/vendor/cmu/cyrus-imapd/squat' => undef, + '/private/vendor/cmu/cyrus-imapd/sieve' => undef, + '/private/vendor/cmu/cyrus-imapd/news2mail' => undef, + '/private/vendor/cmu/cyrus-imapd/expire' => undef, + '/private/thread' => undef, + '/private/sort' => undef, + '/private/comment' => undef, + '/private/checkperiod' => undef, + '/private/check' => undef, + '/private/specialuse' => undef, + '/private' => undef, + ); + my ($maj, $min, $rev) = Cassandane::Instance->get_version(); + # We introduced vendor/cmu/cyrus-imapd/{archive,delete} in 3.1.0 + if ($maj > 3 or ($maj == 3 and $min >= 1)) { + $specific_entries{'/private/vendor/cmu/cyrus-imapd/archive'} = undef; + $specific_entries{'/private/vendor/cmu/cyrus-imapd/delete'} = undef; + } + # We introduced vendor/cmu/cyrus-imapd/sortorder in 3.1.3 + if ($maj > 3 or ($maj == 3 and ($min > 1 or ($min == 1 and $rev >= 3)))) { + $specific_entries{'/private/vendor/cmu/cyrus-imapd/sortorder'} = undef; + } + # We introduced vendor/cmu/cyrus-imapd/search-fuzzy-always in 3.3.0 + if ($maj > 3 or ($maj == 3 and $min >= 3)) { + $specific_entries{'/private/vendor/cmu/cyrus-imapd/search-fuzzy-always'} = undef; + } + + # We introduced vendor/cmu/cyrus-imapd/noexpire_until in 3.9.0 + if ($maj > 3 or ($maj == 3 and $min >= 9)) { + $specific_entries{'/private/vendor/cmu/cyrus-imapd/noexpire_until'} = undef; + } + + $self->assert_deep_equals(\%specific_entries, $r); + + $imaptalk->setmetadata('INBOX', "/private/comment", "This is a comment"); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + my $com = $imaptalk->getmetadata('INBOX', "/private/comment"); + $self->assert_str_equals("This is a comment", $com->{INBOX}{"/private/comment"}); + + # remove it again + $imaptalk->setmetadata('INBOX', "/private/comment", undef); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $com = $imaptalk->getmetadata('INBOX', "/private/comment"); + $self->assert_null($com->{INBOX}{"/private/comment"}); +} diff --git a/cassandane/tiny-tests/Metadata/private_global_annot_replication b/cassandane/tiny-tests/Metadata/private_global_annot_replication new file mode 100644 index 0000000000..6259dc35a2 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/private_global_annot_replication @@ -0,0 +1,105 @@ +#!perl +use Cassandane::Tiny; + +# +# Test the /private/foobar server annotation replicates correctly +# +sub test_private_global_annot_replication + :Replication :SyncLog :AnnotationAllowUndefined + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing /private/foobar"; + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + $self->assert_not_null($self->{replica}); + + my $imaptalk = $self->{master_store}->get_client(); + + my $res; + my $entry = '/private/foobar'; + my $value1 = "Hello World this is a value - with a random annot"; + + xlog $self, "initial value is NIL"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + + xlog $self, "can set the value as ordinary user"; + $imaptalk->setmetadata("", $entry, $value1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "can get the set value back"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + $self->{master_store}->disconnect(); + $imaptalk = $self->{master_store}->get_client(); + + xlog $self, "the annot gives the same value in the new connection"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "replica value is NIL"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + xlog $self, "the annot gives the same value on the replica"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "can delete value"; + $imaptalk = $self->{master_store}->get_client(); + $imaptalk->setmetadata("", $entry, undef); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => undef } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "run replication to clear annot"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + xlog $self, "replica value is NIL"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); +} diff --git a/cassandane/tiny-tests/Metadata/set_specialuse_twice b/cassandane/tiny-tests/Metadata/set_specialuse_twice new file mode 100644 index 0000000000..2d27b39221 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/set_specialuse_twice @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_set_specialuse_twice +{ + my ($self) = @_; + + xlog $self, "testing if we could /private/specialuse twice on a folder"; + + my $imaptalk = $self->{store}->get_client(); + my $entry = '/private/specialuse'; + my $sentry = '/shared/specialuse'; + my $folder1 = 'INBOX.bar'; + my $folder2 = 'INBOX.foo'; + my $specialuse1 = '\Sent \Trash'; + my $specialuse2 = '\Sent \Trash \Junk'; + my $specialuse3 = '\Sent'; + my $specialuse4 = '\Drafts'; + my $specialuse5 = '\Junk \Archive'; + my $specialuse6 = '\Drafts \Archive'; + my $res; + + xlog $self, "Create a folder $folder1"; + $imaptalk->create($folder1) + or die "Cannot create mailbox $folder1: $@"; + + xlog $self, "Create a folder $folder2"; + $imaptalk->create($folder2) + or die "Cannot create mailbox $folder2: $@"; + + $res = $imaptalk->getmetadata($folder1, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + delete $res->{$sentry}; + $self->assert_deep_equals({ + $folder1 => { $entry => undef } + }, $res); + + + xlog $self, "Set $folder1 to be $specialuse1"; + $imaptalk->setmetadata($folder1, $entry, $specialuse1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Set $folder2 to be $specialuse4"; + $imaptalk->setmetadata($folder2, $entry, $specialuse4); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Set $folder1 to $specialuse2, and it should work."; + $imaptalk->setmetadata($folder1, $entry, $specialuse2); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Set $folder1 to $specialuse1, and it should work."; + $imaptalk->setmetadata($folder1, $entry, $specialuse1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Set $folder1 to $specialuse3, and it should work."; + $imaptalk->setmetadata($folder1, $entry, $specialuse2); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Set $folder1 to $specialuse1, and it should work."; + $imaptalk->setmetadata($folder1, $entry, $specialuse1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Set $folder1 to $specialuse4, and it should not work."; + $imaptalk->setmetadata($folder1, $entry, $specialuse4); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + + xlog $self, "Set $folder1 to $specialuse5, and it should work."; + $imaptalk->setmetadata($folder1, $entry, $specialuse5); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Set $folder2 to be $specialuse1"; + $imaptalk->setmetadata($folder2, $entry, $specialuse1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Now set $folder1 to $specialuse6, and it should work."; + $imaptalk->setmetadata($folder1, $entry, $specialuse6); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/Metadata/shared b/cassandane/tiny-tests/Metadata/shared new file mode 100644 index 0000000000..3904b7dc1c --- /dev/null +++ b/cassandane/tiny-tests/Metadata/shared @@ -0,0 +1,104 @@ +#!perl +use Cassandane::Tiny; + +# +# Test the cyrus annotations +# +sub test_shared +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "reading read_only Cyrus annotations"; + my $res = $imaptalk->getmetadata('INBOX', {depth => 'infinity'}, '/shared'); + my $r = $res->{INBOX}; + $self->assert_not_null($r); + + xlog $self, "checking specific entries"; + # Note: lastupdate will be a time string close within the + # last second, but I'm too lazy to check that properly + $self->assert_not_null($r->{'/shared/vendor/cmu/cyrus-imapd/lastupdate'}); + delete $r->{'/shared/vendor/cmu/cyrus-imapd/lastupdate'}; + # Note: uniqueid will be a hash of some information that + # we can't entirely predict + $self->assert_not_null($r->{'/shared/vendor/cmu/cyrus-imapd/uniqueid'}); + delete $r->{'/shared/vendor/cmu/cyrus-imapd/uniqueid'}; + my %specific_entries = ( + '/shared/vendor/cmu/cyrus-imapd/squat' => undef, + '/shared/vendor/cmu/cyrus-imapd/size' => '0', + '/shared/vendor/cmu/cyrus-imapd/sieve' => undef, + '/shared/vendor/cmu/cyrus-imapd/sharedseen' => 'false', + '/shared/vendor/cmu/cyrus-imapd/pop3showafter' => undef, + '/shared/vendor/cmu/cyrus-imapd/pop3newuidl' => 'true', + '/shared/vendor/cmu/cyrus-imapd/partition' => 'default', + '/shared/vendor/cmu/cyrus-imapd/news2mail' => undef, + '/shared/vendor/cmu/cyrus-imapd/lastpop' => undef, + '/shared/vendor/cmu/cyrus-imapd/expire' => undef, + '/shared/vendor/cmu/cyrus-imapd/duplicatedeliver' => 'false', + '/shared/vendor/cmu/cyrus-imapd/userrawquota' => undef, + '/shared/specialuse' => undef, + '/shared/thread' => undef, + '/shared/sort' => undef, + '/shared/specialuse' => undef, + '/shared/comment' => undef, + '/shared/checkperiod' => undef, + '/shared/check' => undef, + '/shared' => undef, + ); + # Note: annotsize/synccrcs new in 3.0 + my ($maj, $min, $rev) = Cassandane::Instance->get_version(); + if ($maj >= 3) { + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/annotsize'} = '0'; + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/synccrcs'} = '0 0'; + } + # We introduced vendor/cmu/cyrus-imapd/{archive,delete} in 3.1.0 + if ($maj > 3 or ($maj == 3 and $min >= 1)) { + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/archive'} = undef; + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/delete'} = undef; + } + # We introduced vendor/cmu/cyrus-imapd/sortorder in 3.1.3 + if ($maj > 3 or ($maj == 3 and ($min > 1 or ($min == 1 and $rev >= 3)))) { + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/sortorder'} = undef; + } + # synccrcs got a new default in 3.1.7, and hasalarms got added + # XXX Not sure how useful it is to keep subdividing our 3.1 tests, we + # XXX expect this unstable series to be a moving target. Once 3.2 forks + # XXX I think we def should collapse all these 3.1s into a single 3.1 + if ($maj > 3 or ($maj == 3 and ($min > 1 or ($min == 1 and $rev >= 7)))) { + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/synccrcs'} = + '0 12345678'; + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/hasalarms'} = 'false'; + } + # foldermodsseq was added in 3.2.0 + if ($maj > 3 or ($maj == 3 and ($min > 1))) { + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/foldermodseq'} = 4; + } + # We introduced vendor/cmu/cyrus-imapd/search-fuzzy-always in 3.3.0 + if ($maj > 3 or ($maj == 3 and $min >= 3)) { + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/search-fuzzy-always'} = undef; + } + + # We introduced vendor/cmu/cyrus-imapd/noexpire_until in 3.9.0 + if ($maj > 3 or ($maj == 3 and $min >= 9)) { + $specific_entries{'/shared/vendor/cmu/cyrus-imapd/noexpire_until'} = undef; + } + + $self->assert_deep_equals(\%specific_entries, $r); + + # individual item fetch: + my $part = $imaptalk->getmetadata('INBOX', "/shared/vendor/cmu/cyrus-imapd/partition"); + $self->assert_str_equals('default', $part->{INBOX}{"/shared/vendor/cmu/cyrus-imapd/partition"}); + + # duplicate deliver should be false + $self->assert_str_equals('false', $res->{INBOX}{"/shared/vendor/cmu/cyrus-imapd/duplicatedeliver"}); + + # set duplicate deliver (as admin) + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->setmetadata('user.cassandane', "/shared/vendor/cmu/cyrus-imapd/duplicatedeliver", 'true'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + # and make sure the change sticks + my $dup = $imaptalk->getmetadata('INBOX', "/shared/vendor/cmu/cyrus-imapd/duplicatedeliver"); + $self->assert_str_equals('true', $dup->{INBOX}{"/shared/vendor/cmu/cyrus-imapd/duplicatedeliver"}); +} diff --git a/cassandane/tiny-tests/Metadata/shared_global_annot_replication b/cassandane/tiny-tests/Metadata/shared_global_annot_replication new file mode 100644 index 0000000000..f48b7a0c28 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/shared_global_annot_replication @@ -0,0 +1,111 @@ +#!perl +use Cassandane::Tiny; + +# +# Test the /shared/foobar server annotation replicates correctly +# +sub test_shared_global_annot_replication + :Replication :SyncLog :AnnotationAllowUndefined + :needs_component_idled +{ + my ($self) = @_; + + xlog $self, "testing /shared/foobar"; + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + $self->assert_not_null($self->{replica}); + + my $imaptalk = $self->{master_store}->get_client(); + + my $res; + my $entry = '/shared/foobar'; + my $value1 = "Hello World this is a value - with a random annot"; + + xlog $self, "initial value is NIL"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + + xlog $self, "cannot set the value as ordinary user"; + $imaptalk->setmetadata("", $entry, $value1); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert($imaptalk->get_last_error() =~ m/permission denied/i); + + xlog $self, "can set the value as admin"; + $imaptalk = $self->{adminstore}->get_client(); + $imaptalk->setmetadata("", $entry, $value1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "can get the set value back"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + $self->{adminstore}->disconnect(); + $imaptalk = $self->{adminstore}->get_client(); + + xlog $self, "the annot gives the same value in the new connection"; + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "replica value is NIL"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); + + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + xlog $self, "the annot gives the same value on the replica"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => $value1 } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "can delete value"; + $imaptalk = $self->{adminstore}->get_client(); + $imaptalk->setmetadata("", $entry, undef); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $expected = { + "" => { $entry => undef } + }; + $self->assert_deep_equals($expected, $res); + + xlog $self, "run replication to clear annot"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + xlog $self, "replica value is NIL"; + $imaptalk = $self->{replica_store}->get_client(); + $res = $imaptalk->getmetadata("", $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals({ + "" => { $entry => undef } + }, $res); +} diff --git a/cassandane/tiny-tests/Metadata/size b/cassandane/tiny-tests/Metadata/size new file mode 100644 index 0000000000..c512844fe3 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/size @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +# +# Test the /shared/vendor/cmu/cyrus-imapd/size annotation +# which reports the total byte count of the RFC822 message +# sizes in the mailbox. +# +sub test_size +{ + my ($self) = @_; + + xlog $self, "testing /shared/vendor/cmu/cyrus-imapd/size"; + + my $imaptalk = $self->{store}->get_client(); + my $res; + my $folder_cass = 'INBOX'; + my $folder_admin = 'user.cassandane'; + $self->{store}->set_folder($folder_cass); + my $entry = '/shared/vendor/cmu/cyrus-imapd/size'; + + xlog $self, "initial value is numeric zero"; + $res = $imaptalk->getmetadata($folder_cass, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder_cass => { $entry => "0" } + }, $res); + + xlog $self, "cannot set the value as ordinary user"; + $imaptalk->setmetadata($folder_cass, $entry, '123'); + $self->assert_str_equals('no', $imaptalk->get_last_completion_response()); + $self->assert($imaptalk->get_last_error() =~ m/permission denied/i); + + xlog $self, "cannot set the value as admin either"; + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->setmetadata($folder_admin, $entry, '123'); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert($admintalk->get_last_error() =~ m/permission denied/i); + + xlog $self, "adding a message bumps the value by the message's size"; + my $expected = 0; + my %msg; + $msg{A} = $self->make_message('Message A'); + $expected += length($msg{A}->as_string()); + + $res = $imaptalk->getmetadata($folder_cass, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder_cass => { $entry => "" . $expected } + }, $res); + + xlog $self, "adding a 2nd message bumps the value by the message's size"; + $msg{B} = $self->make_message('Message B'); + $expected += length($msg{B}->as_string()); + + $res = $imaptalk->getmetadata($folder_cass, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder_cass => { $entry => "" . $expected } + }, $res); + + # TODO: removing a message doesn't reduce the value until (possibly delayed) expunge +} diff --git a/cassandane/tiny-tests/Metadata/specialuse b/cassandane/tiny-tests/Metadata/specialuse new file mode 100644 index 0000000000..468750bad1 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/specialuse @@ -0,0 +1,135 @@ +#!perl +use Cassandane::Tiny; + +# +# Test the /private/specialuse annotation defined by RFC6154. +# +sub test_specialuse +{ + my ($self) = @_; + + xlog $self, "testing /private/specialuse"; + + my $imaptalk = $self->{store}->get_client(); + my $res; + my $entry = '/private/specialuse'; + my $sentry = '/shared/specialuse'; + my @testcases = ( + # Cyrus has no virtual folders, so cannot do \All + { + folder => 'a', + specialuse => '\All', + result => 'no' + }, + { + folder => 'b', + specialuse => '\Archive', + result => 'ok' + }, + { + folder => 'c', + specialuse => '\Drafts', + result => 'ok' + }, + # Cyrus has no virtual folders, so cannot do \Flagged + { + folder => 'd', + specialuse => '\Flagged', + result => 'no' + }, + { + folder => 'e', + specialuse => '\Junk', + result => 'ok' + }, + { + folder => 'f', + specialuse => '\Sent', + result => 'ok' + }, + { + folder => 'g', + specialuse => '\Trash', + result => 'ok' + }, + # Tokens not defined in the RFC are rejected + { + folder => 'h', + specialuse => '\Nonesuch', + result => 'no' + }, + ); + + xlog $self, "First create all the folders"; + foreach my $tc (@testcases) + { + $imaptalk->create("INBOX.$tc->{folder}") + or die "Cannot create mailbox INBOX.$tc->{folder}: $@"; + } + + foreach my $tc (@testcases) + { + my $folder = "INBOX.$tc->{folder}"; + + xlog $self, "initial value for $folder is NIL"; + $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + delete $res->{$sentry}; # may return a shared entry as well... + $self->assert_deep_equals({ + $folder => { $entry => undef } + }, $res); + + xlog $self, "can set $folder to $tc->{specialuse}"; + $imaptalk->setmetadata($folder, $entry, $tc->{specialuse}); + $self->assert_str_equals($tc->{result}, $imaptalk->get_last_completion_response()); + + xlog $self, "can get the set value back"; + $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + delete $res->{$sentry}; # may return a shared entry as well... + my $expected = { + $folder => { $entry => ($tc->{result} eq 'ok' ? $tc->{specialuse} : undef) } + }; + $self->assert_deep_equals($expected, $res); + } + + xlog $self, "can get same values in a new connection"; + $self->{store}->disconnect(); + $imaptalk = $self->{store}->get_client(); + + foreach my $tc (@testcases) + { + my $folder = "INBOX.$tc->{folder}"; + + $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + delete $res->{$sentry}; # may return a shared entry as well... + my $expected = { + $folder => { $entry => ($tc->{result} eq 'ok' ? $tc->{specialuse} : undef) } + }; + $self->assert_deep_equals($expected, $res); + } + + xlog $self, "can delete values"; + foreach my $tc (@testcases) + { + next unless ($tc->{result} eq 'ok'); + my $folder = "INBOX.$tc->{folder}"; + + $imaptalk->setmetadata($folder, $entry, undef); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + $res = $imaptalk->getmetadata($folder, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + delete $res->{$sentry}; # may return a shared entry as well... + my $expected = { + $folder => { $entry => undef } + }; + $self->assert_deep_equals($expected, $res); + } + +} diff --git a/cassandane/tiny-tests/Metadata/unchangedsince b/cassandane/tiny-tests/Metadata/unchangedsince new file mode 100644 index 0000000000..31adb850da --- /dev/null +++ b/cassandane/tiny-tests/Metadata/unchangedsince @@ -0,0 +1,174 @@ +#!perl +use Cassandane::Tiny; + +# +# Test UNCHANGEDSINCE modifier; RFC4551 section 3.2. +# - changing an annotation with current modseq equal to the +# UNCHANGEDSINCE value +# - updates the annotation +# - updates modseq +# - sends an untagged FETCH response +# - the FETCH response has the new modseq +# - returns an OK response +# - the UID does not appear in the MODIFIED response code +# - ditto less than +# - changing an annotation with current modseq greater than the +# UNCHANGEDSINCE value +# - doesn't update the annotation +# - doesn't update modseq +# - sent no FETCH untagged response +# - returns an OK response +# - but reports the UID in the MODIFIED response code +# +sub test_unchangedsince +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $talk->uid()); + $self->{store}->set_fetch_attributes(qw(uid modseq)); + + xlog $self, "Append 3 messages"; + my %msg; + $msg{A} = $self->make_message('Message A'); + $msg{B} = $self->make_message('Message B'); + $msg{C} = $self->make_message('Message C'); + my $hms0 = $self->get_highestmodseq(); + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $value1 = "Hello World"; + my $value2 = "Janis Joplin"; + my $value3 = "Phantom of the Opera"; + + my %fetched; + my $modified; + my %handlers = + ( + fetch => sub + { + my ($response, $rr, $id) = @_; + + # older versions of Mail::IMAPTalk don't have + # the 3rd argument. We can't test properly in + # those circumstances. + $self->assert_not_null($id); + + $fetched{$id} = $rr; + }, + modified => sub + { + my ($response, $rr) = @_; + # we should not get more than one of these ever + $self->assert_null($modified); + $modified = $rr; + } + ); + + # Note: Mail::IMAPTalk::store() doesn't support modifiers + # so we have to resort to the lower level interface. + + xlog $self, "setting an annotation with current modseq == UNCHANGEDSINCE"; + %fetched = (); + $modified = undef; + $talk->_imap_cmd('store', 1, \%handlers, + '1', ['unchangedsince', $hms0-2], + 'annotation', [$entry, [$attrib, $value1]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch an annotation - should be updated"; + my $hms1 = $self->get_highestmodseq(); + $self->assert($hms1 > $hms0); + my $res = $talk->fetch('1:*', + ['modseq', 'annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { + modseq => [$hms1], + annotation => { $entry => { $attrib => $value1 } } + }, + 2 => { + modseq => [$hms0-1], + annotation => { $entry => { $attrib => undef } } + }, + 3 => { + modseq => [$hms0], + annotation => { $entry => { $attrib => undef } } + }, + }, + $res); + + xlog $self, "setting an annotation with current modseq < UNCHANGEDSINCE"; + %fetched = (); + $modified = undef; + $talk->_imap_cmd('store', 1, \%handlers, + '1', ['unchangedsince', $hms1+1], + 'annotation', [$entry, [$attrib, $value2]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "fetch an annotation - should be updated"; + my $hms2 = $self->get_highestmodseq(); + $self->assert($hms2 > $hms1); + $res = $talk->fetch('1:*', + ['modseq', 'annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { + modseq => [$hms2], + annotation => { $entry => { $attrib => $value2 } } + }, + 2 => { + modseq => [$hms0-1], + annotation => { $entry => { $attrib => undef } } + }, + 3 => { + modseq => [$hms0], + annotation => { $entry => { $attrib => undef } } + }, + }, + $res); + + xlog $self, "setting an annotation with current modseq > UNCHANGEDSINCE"; + %fetched = (); + $modified = undef; + $talk->_imap_cmd('store', 1, \%handlers, + '1', ['unchangedsince', $hms2-1], + 'annotation', [$entry, [$attrib, $value3]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "didn't update modseq?"; + my $hms3 = $self->get_highestmodseq(); + $self->assert($hms3 == $hms2); + xlog $self, "fetch an annotation - should not be updated"; + $res = $talk->fetch('1:*', + ['modseq', 'annotation', [$entry, $attrib]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_not_null($res); + $self->assert_deep_equals( + { + 1 => { + # unchanged + modseq => [$hms2], + annotation => { $entry => { $attrib => $value2 } } + }, + 2 => { + modseq => [$hms0-1], + annotation => { $entry => { $attrib => undef } } + }, + 3 => { + modseq => [$hms0], + annotation => { $entry => { $attrib => undef } } + }, + }, + $res); + xlog $self, "reports the UID in the MODIFIED response code?"; + $self->assert_not_null($modified); + $self->assert_deep_equals($modified, [1]); + xlog $self, "sent no FETCH untagged response?"; + $self->assert_num_equals(0, scalar keys %fetched); +} diff --git a/cassandane/tiny-tests/Metadata/uniqueid b/cassandane/tiny-tests/Metadata/uniqueid new file mode 100644 index 0000000000..3591a7b4e8 --- /dev/null +++ b/cassandane/tiny-tests/Metadata/uniqueid @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_uniqueid + :ImmediateDelete +{ + my ($self) = @_; + + xlog $self, "testing /shared/vendor/cmu/cyrus-imapd/uniqueid"; + + my $imaptalk = $self->{store}->get_client(); + my $res; + # data thanks to hipsteripsum.me + my @folders = ( qw(INBOX.etsy INBOX.etsy + INBOX.sartorial + INBOX.dreamcatcher.keffiyeh) ); + my @uuids; + my %uuids_seen; + my $entry = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + + xlog $self, "create the folders"; + foreach my $f (@folders) + { + $imaptalk->create($f) + or die "Cannot create mailbox $f: $@"; + $res = $imaptalk->getmetadata($f, $entry); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_not_null($res); + my $uuid = $res->{$f}{$entry}; + $self->assert_not_null($uuid); + $self->assert($uuid =~ m/^[0-9a-z-]+$/); + $imaptalk->delete($f) + or die "Cannot delete mailbox $f: $@"; + push(@uuids, $uuid); + # all the uniqueids must be unique (duh) + $self->assert(!defined $uuids_seen{$uuid}); + $uuids_seen{$uuid} = 1; + } + + # Do the logging in a 2nd pass in the hope of maximising + # our chances of getting all the creates in one second + for (my $i = 0 ; $i < scalar(@folders) ; $i++) + { + xlog $self, "uniqueid of " . $folders[$i] . " was \"" . $uuids[$i] . "\""; + } +} diff --git a/cassandane/tiny-tests/Quota/bug3735 b/cassandane/tiny-tests/Quota/bug3735 new file mode 100644 index 0000000000..5016be2372 --- /dev/null +++ b/cassandane/tiny-tests/Quota/bug3735 @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_bug3735 + :Bug3735 +{ + my ($self) = @_; + $self->{instance}->create_user("a"); + $self->{instance}->create_user("ab"); + $self->_set_quotaroot('user.a'); + $self->_set_limits(storage => 12345); + $self->_set_quotaroot('user.ab'); + $self->_set_limits(storage => 12345); + + my $filename = $self->{instance}->{basedir} . "/bug3735.out"; + + $self->{instance}->run_command({ + cyrus => 1, + redirects => { stdout => $filename }, + }, 'quota', "user.a"); + + open RESULTS, '<', $filename + or die "Cannot open $filename for reading: $!"; + my @res = ; + close RESULTS; + + $self->assert(grep { m/user\.ab/ } @res); +} diff --git a/cassandane/tiny-tests/Quota/bz3529 b/cassandane/tiny-tests/Quota/bz3529 new file mode 100644 index 0000000000..daed0a286a --- /dev/null +++ b/cassandane/tiny-tests/Quota/bz3529 @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_bz3529 +{ + my ($self) = @_; + + xlog $self, "testing annot storage quota when setting annots on multiple"; + xlog $self, "messages in a single STORE command, using quotalegacy backend."; + + # double check that some other part of Cassandane didn't + # accidentally futz with the expected quota db backend + my $backend = $self->{instance}->{config}->get('quota_db'); + $self->assert_str_equals('quotalegacy', $backend) + if defined $backend; # the default value is also ok + + $self->_set_quotaroot('user.cassandane'); + my $talk = $self->{store}->get_client(); + + xlog $self, "set ourselves a basic limit"; + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => 0); + + xlog $self, "make some messages to hang annotations on"; +# $self->{store}->set_folder($folder); + my $uid = 1; + my %msgs; + for (1..20) + { + $msgs{$uid} = $self->make_message("Message $uid"); + $msgs{$uid}->set_attribute('uid', $uid); + $uid++; + } + + my $data = $self->make_random_data(30); + $talk->store('1:*', 'annotation', ['/comment', ['value.priv', { Quote => $data }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + my $expected = ($uid-1) * length($data); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + # delete annotations + $talk->store('1:*', 'annotation', ['/comment', ['value.priv', undef]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->_check_usages($self->res_annot_storage => 0); +} diff --git a/cassandane/tiny-tests/Quota/deleted_storage b/cassandane/tiny-tests/Quota/deleted_storage new file mode 100644 index 0000000000..c46bbe77f8 --- /dev/null +++ b/cassandane/tiny-tests/Quota/deleted_storage @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_deleted_storage +{ + my ($self) = @_; + + xlog $self, "test DELETED and DELETED-STORAGE STATUS items"; + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_limits(storage => 100000); + $self->_check_usages(storage => 0); + my $talk = $self->{store}->get_client(); + + # append some messages + my $expected = 0; + for (1..10) + { + my $msg = $self->make_message("Message $_", + extra_lines => 10 + rand(5000)); + my $len = length($msg->as_string()); + $expected += $len; + xlog $self, "added $len bytes of message"; + $self->_check_usages(storage => int($expected/1024)); + } + + # delete messages + $talk->select("INBOX"); + $talk->store('1:*', '+flags.silent', '(\\deleted)'); + + # check deleted[-storage] status items + my $res = $talk->status('INBOX', '(messages size deleted deleted-storage)'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_num_equals(10, $res->{'messages'}); + $self->assert_num_equals(10, $res->{'deleted'}); + $self->assert_num_equals($expected, $res->{'size'}); + $self->assert_num_equals($expected, $res->{'deleted-storage'}); + + $talk->close(); + + # check deleted[-storage] status items + $res = $talk->status('INBOX', '(messages size deleted deleted-storage)'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_num_equals(0, $res->{'messages'}); + $self->assert_num_equals(0, $res->{'deleted'}); + $self->assert_num_equals(0, $res->{'size'}); + $self->assert_num_equals(0, $res->{'deleted-storage'}); + + $self->_check_usages(storage => 0); +} diff --git a/cassandane/tiny-tests/Quota/exceeding_message b/cassandane/tiny-tests/Quota/exceeding_message new file mode 100644 index 0000000000..bc1a6460a3 --- /dev/null +++ b/cassandane/tiny-tests/Quota/exceeding_message @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_exceeding_message +{ + my ($self) = @_; + + xlog $self, "test exceeding the MESSAGE quota limit"; + + my $talk = $self->{store}->get_client(); + + xlog $self, "set a low limit"; + $self->_set_quotaroot('user.cassandane'); + $self->_set_limits(message => 10); + $self->_check_usages(message => 0); + + xlog $self, "adding messages to get just below the limit"; + my %msgs; + for (1..10) + { + $msgs{$_} = $self->make_message("Message $_"); + } + xlog $self, "check that the messages are all in the mailbox"; + $self->check_messages(\%msgs); + xlog $self, "check that the usage is just below the limit"; + $self->_check_usages(message => 10); + + xlog $self, "add a message that exceeds the limit"; + my $overmsg = eval { $self->make_message("Message 11") }; + # As opposed to storage checking, which is currently done after receiving t + # (LITERAL) mail, message count checking is performed right away. This earl + # NO response while writing the LITERAL triggered a die in early versions + # of IMAPTalk, leaving the completion response undefined. + my $ex = $@; + if ($ex) { + $self->assert($ex =~ m/over quota/i); + } + else { + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/over quota/i); + } + + xlog $self, "check that the exceeding message is not in the mailbox"; + $self->_check_usages(message => 10); + $self->check_messages(\%msgs); +} diff --git a/cassandane/tiny-tests/Quota/exceeding_storage b/cassandane/tiny-tests/Quota/exceeding_storage new file mode 100644 index 0000000000..1bd5893dba --- /dev/null +++ b/cassandane/tiny-tests/Quota/exceeding_storage @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_exceeding_storage +{ + my ($self) = @_; + + xlog $self, "test exceeding the STORAGE quota limit"; + + my $talk = $self->{store}->get_client(); + + xlog $self, "set a low limit"; + $self->_set_quotaroot('user.cassandane'); + $self->_set_limits(storage => 210); + $self->_check_usages(storage => 0); + + xlog $self, "adding messages to get just below the limit"; + my %msgs; + my $slack = 200 * 1024; + my $n = 1; + my $expected = 0; + while ($slack > 1000) + { + my $nlines = int(($slack - 640) / 23); + $nlines = 1000 if ($nlines > 1000); + + my $msg = $self->make_message("Message $n", + extra_lines => $nlines); + my $len = length($msg->as_string()); + $slack -= $len; + $expected += $len; + xlog $self, "added $len bytes of message"; + $msgs{$n} = $msg; + $n++; + } + xlog $self, "check that the messages are all in the mailbox"; + $self->check_messages(\%msgs); + xlog $self, "check that the usage is just below the limit"; + $self->_check_usages(storage => int($expected/1024)); + $self->_check_smmap('cassandane', 'OK'); + + xlog $self, "add a message that exceeds the limit"; + my $nlines = int(($slack - 640) / 23) * 2; + $nlines = 500 if ($nlines < 500); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + my $overmsg = eval { $self->make_message("Message $n", extra_lines => $nlines) }; + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/over quota/i); + + xlog $self, "check that the exceeding message is not in the mailbox"; + $self->check_messages(\%msgs); + + xlog $self, "check that the quota usage is still the same"; + $self->_check_usages(storage => int($expected/1024)); + $self->_check_smmap('cassandane', 'OK'); +} diff --git a/cassandane/tiny-tests/Quota/move_near_limit b/cassandane/tiny-tests/Quota/move_near_limit new file mode 100644 index 0000000000..9f5cb12d2d --- /dev/null +++ b/cassandane/tiny-tests/Quota/move_near_limit @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_move_near_limit +{ + my ($self) = @_; + + xlog $self, "test move near the STORAGE quota limit"; + + my $talk = $self->{store}->get_client(); + + xlog $self, "set a low limit"; + $self->_set_quotaroot('user.cassandane'); + $self->_set_limits(storage => 210); + $self->_check_usages(storage => 0); + + xlog $self, "adding messages to get just below the limit"; + my %msgs; + my $slack = 200 * 1024; + my $n = 1; + my $expected = 0; + while ($slack > 1000) + { + my $nlines = int(($slack - 640) / 23); + $nlines = 1000 if ($nlines > 1000); + + my $msg = $self->make_message("Message $n", + extra_lines => $nlines); + my $len = length($msg->as_string()); + $slack -= $len; + $expected += $len; + xlog $self, "added $len bytes of message"; + $msgs{$n} = $msg; + $n++; + } + xlog $self, "check that the messages are all in the mailbox"; + $self->check_messages(\%msgs); + xlog $self, "check that the usage is just below the limit"; + $self->_check_usages(storage => int($expected/1024)); + $self->_check_smmap('cassandane', 'OK'); + + xlog $self, "add a message that exceeds the limit"; + my $nlines = int(($slack - 640) / 23) * 2; + $nlines = 500 if ($nlines < 500); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + my $overmsg = eval { $self->make_message("Message $n", extra_lines => $nlines) }; + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/over quota/i); + + $talk->create("INBOX.target"); + + xlog $self, "try to copy the messages"; + $talk->copy("1:*", "INBOX.target"); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/over quota/i); + + xlog $self, "move the messages"; + $talk->move("1:*", "INBOX.target"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); +} diff --git a/cassandane/tiny-tests/Quota/num_folders_delete_delayed b/cassandane/tiny-tests/Quota/num_folders_delete_delayed new file mode 100644 index 0000000000..bc194b8c45 --- /dev/null +++ b/cassandane/tiny-tests/Quota/num_folders_delete_delayed @@ -0,0 +1,24 @@ +#!perl +use Cassandane::Tiny; + +sub test_num_folders_delete_delayed + :DelayedDelete +{ + my ($self) = @_; + $self->_set_quotaroot('user.cassandane'); + $self->_set_limits(storage => 12345, $self->res_mailbox => 500); + + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + $self->_check_usages(storage => 0, $self->res_mailbox => 2); + + $talk->create("INBOX.another"); + + $self->_check_usages(storage => 0, $self->res_mailbox => 3); + + $talk->delete("INBOX.another"); + + $self->_check_usages(storage => 0, $self->res_mailbox => 2); +} diff --git a/cassandane/tiny-tests/Quota/num_folders_delete_immediate b/cassandane/tiny-tests/Quota/num_folders_delete_immediate new file mode 100644 index 0000000000..0467c6cbf4 --- /dev/null +++ b/cassandane/tiny-tests/Quota/num_folders_delete_immediate @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_num_folders_delete_immediate +{ + my ($self) = @_; + $self->_set_quotaroot('user.cassandane'); + $self->_set_limits(storage => 12345, $self->res_mailbox => 500); + + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + $self->_check_usages(storage => 0, $self->res_mailbox => 2); + + $talk->create("INBOX.another"); + + $self->_check_usages(storage => 0, $self->res_mailbox => 3); + + $talk->delete("INBOX.another"); + + $self->_check_usages(storage => 0, $self->res_mailbox => 2); +} diff --git a/cassandane/tiny-tests/Quota/num_folders_rename b/cassandane/tiny-tests/Quota/num_folders_rename new file mode 100644 index 0000000000..59ac66e832 --- /dev/null +++ b/cassandane/tiny-tests/Quota/num_folders_rename @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_num_folders_rename +{ + my ($self) = @_; + $self->_set_quotaroot('user.cassandane'); + $self->_set_limits(storage => 12345, $self->res_mailbox => 500); + + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + $self->_check_usages(storage => 0, $self->res_mailbox => 2); + + $talk->create("INBOX.another"); + + $self->_check_usages(storage => 0, $self->res_mailbox => 3); + + $talk->rename("INBOX.another", "INBOX.out"); + + $self->_check_usages(storage => 0, $self->res_mailbox => 3); +} diff --git a/cassandane/tiny-tests/Quota/overquota b/cassandane/tiny-tests/Quota/overquota new file mode 100644 index 0000000000..706b384b84 --- /dev/null +++ b/cassandane/tiny-tests/Quota/overquota @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_overquota +{ + my ($self) = @_; + + xlog $self, "test account which is over STORAGE quota limit"; + + my $talk = $self->{store}->get_client(); + + xlog $self, "set a low limit"; + $self->_set_quotaroot('user.cassandane'); + $self->_set_limits(storage => 210); + $self->_check_usages(storage => 0); + + xlog $self, "adding messages to get just below the limit"; + my %msgs; + my $slack = 200 * 1024; + my $n = 1; + my $expected = 0; + while ($slack > 1000) + { + my $nlines = int(($slack - 640) / 23); + $nlines = 1000 if ($nlines > 1000); + + my $msg = $self->make_message("Message $n", + extra_lines => $nlines); + my $len = length($msg->as_string()); + $slack -= $len; + $expected += $len; + xlog $self, "added $len bytes of message"; + $msgs{$n} = $msg; + $n++; + } + xlog $self, "check that the messages are all in the mailbox"; + $self->check_messages(\%msgs); + xlog $self, "check that the usage is just below the limit"; + $self->_check_usages(storage => int($expected/1024)); + $self->_check_smmap('cassandane', 'OK'); + + xlog $self, "reduce the quota limit"; + $self->_set_limits(storage => 100); + + xlog $self, "check that usage is unchanged"; + $self->_check_usages(storage => int($expected/1024)); + xlog $self, "check that smmap reports over quota"; + $self->_check_smmap('cassandane', 'TEMP'); + + xlog $self, "try to add another message"; + my $overmsg = eval { $self->make_message("Message $n") }; + my $ex = $@; + if ($ex) { + $self->assert($ex =~ m/over quota/i); + } + else { + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert($talk->get_last_error() =~ m/over quota/i); + } + + xlog $self, "check that the exceeding message is not in the mailbox"; + $self->check_messages(\%msgs); + + xlog $self, "check that the quota usage is still unchanged"; + $self->_check_usages(storage => int($expected/1024)); + $self->_check_smmap('cassandane', 'TEMP'); + + my $delmsg = delete $msgs{1}; + my $dellen = length($delmsg->as_string()); + xlog $self, "delete the first message ($dellen bytes)"; + $talk->select("INBOX"); + $talk->store('1', '+flags', '(\\deleted)'); + $talk->close(); + + xlog $self, "check that the deleted message is no longer in the mailbox"; + $self->check_messages(\%msgs); + + xlog $self, "check that the usage has gone down"; + $expected -= $dellen; + $self->_check_usages(storage => int($expected/1024)); + + xlog $self, "check that we are still over quota"; + $self->_check_smmap('cassandane', 'TEMP'); +} diff --git a/cassandane/tiny-tests/Quota/quota_d b/cassandane/tiny-tests/Quota/quota_d new file mode 100644 index 0000000000..3914c048a8 --- /dev/null +++ b/cassandane/tiny-tests/Quota/quota_d @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_quota_d + :UnixHierarchySep :AltNamespace :VirtDomains +{ + my ($self) = @_; + + my @users = qw( + alice@foo.com + bob@foo.com + chris@bar.com + dave@qux.com + ); + + my $admintalk = $self->{adminstore}->get_client(); + + foreach my $user (@users) { + $admintalk->create("user/$user"); + $self->_set_limits( + quotaroot => "user/$user", + storage => 100000, + message => 50000, + $self->res_annot_storage => 10000, + ); + + my $svc = $self->{instance}->get_service('imap'); + my $userstore = $svc->create_store(username => $user); + my $usertalk = $userstore->get_client(); + + foreach my $submbox ('Drafts', 'Junk', 'Sent', 'Trash') { + xlog $self, "creating $submbox..."; + $usertalk->create($submbox); + $self->assert_str_equals('ok', + $usertalk->get_last_completion_response()); + } + + foreach my $mbox (qw(INBOX Drafts Sent Junk Trash)) { + $usertalk->select($mbox); + foreach (1..3) { + $self->make_message("msg $_ in $mbox", store => $userstore); + } + } + } + + xlog $self, "run quota"; + my $outfile = $self->{instance}->{basedir} . '/quota.out'; + $self->{instance}->run_command( + { cyrus => 1, + redirects => { + stderr => $outfile, + stdout => $outfile, + }, + }, + 'quota'); + + # should have reported quotas for all users + my $content = slurp_file($outfile); + foreach my $user (@users) { + $self->assert_matches(qr{$user}, $content); + } + + xlog $self, "run quota -d foo.com"; + $outfile = $self->{instance}->{basedir} . '/quota_d.out'; + $self->{instance}->run_command( + { cyrus => 1, + redirects => { + stderr => $outfile, + stdout => $outfile, + }, + }, + 'quota', '-d', 'foo.com'); + + # should not report quotas for users in other domains! + $content = slurp_file($outfile); + $self->assert_matches(qr{alice\@foo.com}, $content); + $self->assert_matches(qr{bob\@foo.com}, $content); + $self->assert_does_not_match(qr{chris\@bar.com}, $content); + $self->assert_does_not_match(qr{dave\@qux.com}, $content); +} diff --git a/cassandane/tiny-tests/Quota/quota_f b/cassandane/tiny-tests/Quota/quota_f new file mode 100644 index 0000000000..58afc1774c --- /dev/null +++ b/cassandane/tiny-tests/Quota/quota_f @@ -0,0 +1,121 @@ +#!perl +use Cassandane::Tiny; + +sub test_quota_f +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "set ourselves a basic usage quota"; + $self->_set_limits( + quotaroot => 'user.cassandane', + storage => 100000, + message => 50000, + $self->res_annot_storage => 10000, + ); + $self->_check_usages( + quotaroot => 'user.cassandane', + storage => 0, + message => 0, + $self->res_annot_storage => 0, + ); + + xlog $self, "create some messages to use various quota resources"; + $self->{instance}->create_user("quotafuser"); + $self->_set_limits( + quotaroot => 'user.quotafuser', + storage => 100000, + message => 50000, + $self->res_annot_storage => 10000, + ); + $self->{adminstore}->set_folder("user.quotafuser"); + my $quotafuser_expected_storage = 0; + my $quotafuser_expected_message = 0; + my $quotafuser_expected_annotation_storage = 0; + for (1..3) { + my $msg = $self->make_message("QuotaFUser $_", store => $self->{adminstore}, extra_lines => 17000); + $quotafuser_expected_storage += length($msg->as_string()); + $quotafuser_expected_message++; + } + my $annotation = $self->make_random_data(10); + $quotafuser_expected_annotation_storage += length($annotation); + $admintalk->setmetadata('user.quotafuser', '/private/comment', { Quote => $annotation }); + + my $cassandane_expected_storage = 0; + my $cassandane_expected_message = 0; + my $cassandane_expected_annotation_storage = 0; + for (1..10) { + my $msg = $self->make_message("Cassandane $_", extra_lines => 5000); + $cassandane_expected_storage += length($msg->as_string()); + $cassandane_expected_message++; + } + $annotation = $self->make_random_data(3); + $cassandane_expected_annotation_storage += length($annotation); + $admintalk->setmetadata('user.cassandane', '/private/comment', { Quote => $annotation }); + + xlog $self, "check usages"; + $self->_check_usages( + quotaroot => 'user.quotafuser', + storage => int($quotafuser_expected_storage/1024), + message => $quotafuser_expected_message, + $self->res_annot_storage => int($quotafuser_expected_annotation_storage/1024), + ); + $self->_check_usages( + quotaroot => 'user.cassandane', + storage => int($cassandane_expected_storage/1024), + message => $cassandane_expected_message, + $self->res_annot_storage => int($cassandane_expected_annotation_storage/1024), + ); + + xlog $self, "create a bogus quota file"; + $self->_zap_quota(quotaroot => 'user.quotafuser'); + + xlog $self, "check usages"; + $self->_check_usages( + quotaroot => 'user.quotafuser', + storage => 0, + message => 0, + $self->res_annot_storage => 0, + ); + $self->_check_usages( + quotaroot => 'user.cassandane', + storage => int($cassandane_expected_storage/1024), + message => $cassandane_expected_message, + $self->res_annot_storage => int($cassandane_expected_annotation_storage/1024), + ); + + xlog $self, "find and add the quota"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f'); + + xlog $self, "check usages"; + $self->_check_usages( + quotaroot => 'user.quotafuser', + storage => int($quotafuser_expected_storage/1024), + message => $quotafuser_expected_message, + $self->res_annot_storage => int($quotafuser_expected_annotation_storage/1024), + ); + $self->_check_usages( + quotaroot => 'user.cassandane', + storage => int($cassandane_expected_storage/1024), + message => $cassandane_expected_message, + $self->res_annot_storage => int($cassandane_expected_annotation_storage/1024), + ); + + xlog $self, "re-run the quota utility"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f'); + + xlog $self, "check usages"; + $self->_check_usages( + quotaroot => 'user.quotafuser', + storage => int($quotafuser_expected_storage/1024), + message => $quotafuser_expected_message, + $self->res_annot_storage => int($quotafuser_expected_annotation_storage/1024), + ); + $self->_check_usages( + quotaroot => 'user.cassandane', + storage => int($cassandane_expected_storage/1024), + message => $cassandane_expected_message, + $self->res_annot_storage => int($cassandane_expected_annotation_storage/1024), + ); +} diff --git a/cassandane/tiny-tests/Quota/quota_f_nested_qr b/cassandane/tiny-tests/Quota/quota_f_nested_qr new file mode 100644 index 0000000000..0d09590bda --- /dev/null +++ b/cassandane/tiny-tests/Quota/quota_f_nested_qr @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_quota_f_nested_qr + :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Test that quota -f correctly calculates the STORAGE quota"; + xlog $self, "with a nested quotaroot and a folder whose name sorts after"; + xlog $self, "the nested quotaroot [Bug 3621]"; + + my $inbox = "user.cassandane"; + # These names are significant - we need subfolders both before and + # after the subfolder on which we will set the nested quotaroot + my @folders = ( $inbox, "$inbox.aaa", "$inbox.nnn", "$inbox.zzz" ); + + xlog $self, "add messages to use some STORAGE quota"; + my %exp; + my $n = 5; + foreach my $f (@folders) + { + $self->{store}->set_folder($f); + for (1..$n) { + my $msg = $self->make_message("$f $_", + extra_lines => 10 + rand(5000)); + $exp{$f} += length($msg->as_string()); + } + $n += 5; + xlog $self, "Expect " . $exp{$f} . " on " . $f; + } + + xlog $self, "set a quota on inbox"; + $self->_set_limits(quotaroot => $inbox, storage => 100000); + + xlog $self, "should have correct STORAGE quota"; + my $ex0 = $exp{$inbox} + $exp{"$inbox.aaa"} + $exp{"$inbox.nnn"} + $exp{"$inbox.zzz"}; + $self->_check_usages(quotaroot => $inbox, storage => int($ex0/1024)); + + xlog $self, "set a quota on inbox.nnn - a nested quotaroot"; + $self->_set_limits(quotaroot => "$inbox.nnn", storage => 200000); + + xlog $self, "should have correct STORAGE quota for both roots"; + my $ex1 = $exp{$inbox} + $exp{"$inbox.aaa"} + $exp{"$inbox.zzz"}; + my $ex2 = $exp{"$inbox.nnn"}; + $self->_check_usages(quotaroot => $inbox, storage => int($ex1/1024)); + $self->_check_usages(quotaroot => "$inbox.nnn", storage => int($ex2/1024)); + + xlog $self, "create a bogus quota file"; + $self->_zap_quota(quotaroot => $inbox); + $self->_zap_quota(quotaroot => "$inbox.nnn"); + $self->_check_usages(quotaroot => $inbox, storage => 0); + $self->_check_usages(quotaroot => "$inbox.nnn", storage => 0); + + xlog $self, "run quota -f to find and add the quota"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f'); + + xlog $self, "check that STORAGE quota is restored for both roots"; + $self->_check_usages(quotaroot => $inbox, storage => int($ex1/1024)); + $self->_check_usages(quotaroot => "$inbox.nnn", storage => int($ex2/1024)); + + xlog $self, "run quota -f again"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f'); + + xlog $self, "check that STORAGE quota is still correct for both roots"; + $self->_check_usages(quotaroot => $inbox, storage => int($ex1/1024)); + $self->_check_usages(quotaroot => "$inbox.nnn", storage => int($ex2/1024)); +} diff --git a/cassandane/tiny-tests/Quota/quota_f_no_improved_mboxlist_sort b/cassandane/tiny-tests/Quota/quota_f_no_improved_mboxlist_sort new file mode 100644 index 0000000000..7f60430ea2 --- /dev/null +++ b/cassandane/tiny-tests/Quota/quota_f_no_improved_mboxlist_sort @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +# https://github.com/cyrusimap/cyrus-imapd/issues/2877 +sub test_quota_f_no_improved_mboxlist_sort + :unixHierarchySep :AltNamespace :VirtDomains :NoStartInstances +{ + my ($self) = @_; + + my $user = 'user1@example.com'; + my @otherusers = ( + 'user0@example.com', + 'user1-z@example.com', + 'user2@example.com', + ); + + $self->{instance}->{config}->set('improved_mboxlist_sort', 'no'); + $self->_start_instances(); + + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->create("user/$user"); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + $admintalk->setacl("user/$user", $user, 'lrswipkxtecdan'); + $self->assert_str_equals('ok', + $admintalk->get_last_completion_response()); + + xlog $self, "set ourselves a basic usage quota"; + $self->_set_limits( + quotaroot => "user/$user", + storage => 100000, + message => 50000, + $self->res_annot_storage => 10000, + ); + $self->_check_usages( + quotaroot => "user/$user", + storage => 0, + message => 0, + $self->res_annot_storage => 0, + ); + + # create some other users to tickle sort-order issues? + foreach my $x (@otherusers) { + $admintalk->create("user/$x"); + $self->_set_limits( + quotaroot => "user/$x", + storage => 100000, + message => 50000, + $self->res_annot_storage => 10000, + ); + } + + my $svc = $self->{instance}->get_service('imap'); + my $userstore = $svc->create_store(username => $user); + my $usertalk = $userstore->get_client(); + + foreach my $submbox ('Drafts', 'Junk', 'Sent', 'Trash') { + xlog $self, "creating $submbox..."; + $usertalk->create($submbox); + $self->assert_str_equals('ok', + $usertalk->get_last_completion_response()); + } + + $usertalk->list("", "*"); + + foreach my $mbox (qw(INBOX Drafts Sent Junk Trash)) { + $usertalk->select($mbox); + foreach (1..3) { + $self->make_message("msg $_ in $mbox", store => $userstore); + } + } + + xlog $self, "run quota -d"; + $self->{instance}->run_command({ cyrus => 1 }, + 'quota', '-d', 'example.com'); + + xlog $self, "run quota -d -f"; + my $outfile = $self->{instance}->{basedir} . '/quota.out'; + my @data = $self->{instance}->run_command({ + cyrus => 1, + redirects => { + stderr => $outfile, + stdout => $outfile, + }, + }, 'quota', '-f', '-d', 'example.com'); + + my $str = slurp_file($outfile); + xlog $self, $str; + + #example.com!user.user1.Junk: quota root example.com!user.user1 --> (none) + $self->assert_does_not_match(qr{ quota root \S+ --> \(none\)}, $str); +} diff --git a/cassandane/tiny-tests/Quota/quota_f_prefix b/cassandane/tiny-tests/Quota/quota_f_prefix new file mode 100644 index 0000000000..66ab345cd4 --- /dev/null +++ b/cassandane/tiny-tests/Quota/quota_f_prefix @@ -0,0 +1,172 @@ +#!perl +use Cassandane::Tiny; + +sub test_quota_f_prefix +{ + my ($self) = @_; + + xlog $self, "Testing prefix matches with quota -f [IRIS-1029]"; + + my $admintalk = $self->{adminstore}->get_client(); + + # surround with other users too + $self->{instance}->create_user("aabefore", + subdirs => [ qw(subdir subdir2) ]); + + $self->{instance}->create_user("zzafter", + subdirs => [ qw(subdir subdir2) ]); + + $self->{instance}->create_user("base", + subdirs => [ qw(subdir subdir2) ]); + $self->_set_limits(quotaroot => 'user.base', storage => 1000000); + my $exp_base = 0; + + xlog $self, "Adding messages to user.base"; + $self->{adminstore}->set_folder("user.base"); + for (1..10) { + my $msg = $self->make_message("base $_", + store => $self->{adminstore}, + extra_lines => 5000+rand(50000)); + $exp_base += length($msg->as_string()); + } + + xlog $self, "Adding messages to user.base.subdir2"; + $self->{adminstore}->set_folder("user.base.subdir2"); + for (1..10) { + my $msg = $self->make_message("base subdir2 $_", + store => $self->{adminstore}, + extra_lines => 5000+rand(50000)); + $exp_base += length($msg->as_string()); + } + + $self->{instance}->create_user("baseplus", + subdirs => [ qw(subdir) ]); + $self->_set_limits(quotaroot => 'user.baseplus', storage => 1000000); + my $exp_baseplus = 0; + + xlog $self, "Adding messages to user.baseplus"; + $self->{adminstore}->set_folder("user.baseplus"); + for (1..10) { + my $msg = $self->make_message("baseplus $_", + store => $self->{adminstore}, + extra_lines => 5000+rand(50000)); + $exp_baseplus += length($msg->as_string()); + } + + xlog $self, "Adding messages to user.baseplus.subdir"; + $self->{adminstore}->set_folder("user.baseplus.subdir"); + for (1..10) { + my $msg = $self->make_message("baseplus subdir $_", + store => $self->{adminstore}, + extra_lines => 5000+rand(50000)); + $exp_baseplus += length($msg->as_string()); + } + + xlog $self, "Check that the quotas were updated as expected"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($exp_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($exp_baseplus/1024)); + + xlog $self, "Run quota -f"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f'); + + xlog $self, "Check that the quotas were unchanged by quota -f"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($exp_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($exp_baseplus/1024)); + + my $bogus_base = $exp_base + 20000 + rand(30000); + my $bogus_baseplus = $exp_baseplus + 50000 + rand(80000); + xlog $self, "Write incorrect values to the quota db"; + $self->_zap_quota(quotaroot => 'user.base', + useds => { storage => $bogus_base }); + $self->_zap_quota(quotaroot => 'user.baseplus', + useds => { storage => $bogus_baseplus }); + + xlog $self, "Check that the quotas are now bogus"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($bogus_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($bogus_baseplus/1024)); + + xlog $self, "Run quota -f with no prefix"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f'); + + xlog $self, "Check that the quotas were all fixed"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($exp_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($exp_baseplus/1024)); + + xlog $self, "Write incorrect values to the quota db"; + $self->_zap_quota(quotaroot => "user.base", + useds => { storage => $bogus_base }); + $self->_zap_quota(quotaroot => "user.baseplus", + useds => { storage => $bogus_baseplus }); + + xlog $self, "Check that the quotas are now bogus"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($bogus_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($bogus_baseplus/1024)); + + xlog $self, "Run quota -f on user.base only"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f', 'user.base'); + + xlog $self, "Check that only the user.base and user.baseplus quotas were fixed"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($exp_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($exp_baseplus/1024)); + + xlog $self, "Write incorrect values to the quota db"; + $self->_zap_quota(quotaroot => "user.base", + useds => { storage => $bogus_base }); + $self->_zap_quota(quotaroot => "user.baseplus", + useds => { storage => $bogus_baseplus }); + + xlog $self, "Check that the quotas are now bogus"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($bogus_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($bogus_baseplus/1024)); + + xlog $self, "Run quota -f on user.baseplus only"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f', 'user.baseplus'); + + xlog $self, "Check that only the user.baseplus quotas were fixed"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($bogus_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($exp_baseplus/1024)); + + xlog $self, "Write incorrect values to the quota db"; + $self->_zap_quota(quotaroot => "user.base", + useds => { storage => $bogus_base }); + $self->_zap_quota(quotaroot => "user.baseplus", + useds => { storage => $bogus_baseplus }); + + xlog $self, "Check that the quotas are now bogus"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($bogus_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($bogus_baseplus/1024)); + + xlog $self, "Run quota -f -u on user base "; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f', '-u', 'base'); + + xlog $self, "Check that only the user base quotas were fixed"; + $self->_check_usages(quotaroot => 'user.base', + storage => int($exp_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($bogus_baseplus/1024)); + + xlog $self, "Run a final quota -f to fix up everything"; + $self->{instance}->run_command({ cyrus => 1 }, 'quota', '-f'); + $self->_check_usages(quotaroot => 'user.base', + storage => int($exp_base/1024)); + $self->_check_usages(quotaroot => 'user.baseplus', + storage => int($exp_baseplus/1024)); +} diff --git a/cassandane/tiny-tests/Quota/quota_f_unixhs b/cassandane/tiny-tests/Quota/quota_f_unixhs new file mode 100644 index 0000000000..193f2a0021 --- /dev/null +++ b/cassandane/tiny-tests/Quota/quota_f_unixhs @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_quota_f_unixhs + :UnixHierarchySep +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "set ourselves a basic usage quota"; + $self->_set_limits( + quotaroot => 'user/cassandane', + storage => 100000, + message => 50000, + $self->res_annot_storage => 10000, + ); + $self->_check_usages( + quotaroot => 'user/cassandane', + storage => 0, + message => 0, + $self->res_annot_storage => 0, + ); + + xlog $self, "run quota -f"; + my @data = $self->{instance}->run_command({ + cyrus => 1, + redirects => { stdout => $self->{instance}{basedir} . '/quota.out' }, + }, 'quota', '-f'); + + my $str = slurp_file($self->{instance}{basedir} . '/quota.out'); + + $self->assert_matches(qr{STORAGE user/cassandane}, $str); +} diff --git a/cassandane/tiny-tests/Quota/quota_f_vs_update b/cassandane/tiny-tests/Quota/quota_f_vs_update new file mode 100644 index 0000000000..0d8ac3a057 --- /dev/null +++ b/cassandane/tiny-tests/Quota/quota_f_vs_update @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +# Test races between quota -f and updates to mailboxes +sub test_quota_f_vs_update + :NoAltNameSpace +{ + my ($self) = @_; + + my $basefolder = "user.cassandane"; + my @folders = qw(a b c d e); + my $msg; + my $expected; + + xlog $self, "Set up a large but limited quota"; + $self->_set_quotaroot($basefolder); + $self->_set_limits(storage => 1000000); + $self->_check_usages(storage => 0); + my $talk = $self->{store}->get_client(); + + xlog $self, "Create some sub folders"; + for my $f (@folders) + { + $talk->create("$basefolder.$f") || die "Failed $@"; + $self->{store}->set_folder("$basefolder.$f"); + $msg = $self->make_message("Cassandane $f", + extra_lines => 2000+rand(5000)); + $expected += length($msg->as_string()); + } + # unselect so quota -f can lock the mailboxes + $talk->unselect(); + + xlog $self, "Check that we have some quota usage"; + $self->_check_usages(storage => int($expected/1024)); + + xlog $self, "Start a quota -f scan"; + $self->{instance}->quota_Z_go($basefolder); + $self->{instance}->quota_Z_go("$basefolder.a"); + $self->{instance}->quota_Z_go("$basefolder.b"); + my (@bits) = $self->{instance}->run_command({ cyrus => 1, background => 1 }, + 'quota', '-Z', '-f', $basefolder); + + # waiting for quota -f to ensure that + # a) the -Z mechanism is working and + # b) quota -f has at least initialised and started scanning. + $self->{instance}->quota_Z_wait("$basefolder.b"); + + # quota -f is now waiting to be allowed to proceed to "c" + + xlog $self, "Mailbox update behind the scan"; + $self->{store}->set_folder("$basefolder.b"); + $msg = $self->make_message("Cassandane b UPDATE", + extra_lines => 2000+rand(3000)); + $expected += length($msg->as_string()); + + xlog $self, "Mailbox update in front of the scan"; + $self->{store}->set_folder("$basefolder.d"); + $msg = $self->make_message("Cassandane d UPDATE", + extra_lines => 2000+rand(3000)); + $expected += length($msg->as_string()); + + xlog $self, "Let quota -f continue and finish"; + $self->{instance}->quota_Z_go("$basefolder.c"); + $self->{instance}->quota_Z_go("$basefolder.d"); + $self->{instance}->quota_Z_go("$basefolder.e"); + $self->{instance}->quota_Z_wait("$basefolder.e"); + $self->{instance}->reap_command(@bits); + + xlog $self, "Check that we have the correct quota usage"; + $self->_check_usages(storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/quotarename b/cassandane/tiny-tests/Quota/quotarename new file mode 100644 index 0000000000..2b32244bb0 --- /dev/null +++ b/cassandane/tiny-tests/Quota/quotarename @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +# +# Test renames +# +sub test_quotarename +{ + my ($self) = @_; + + my $admintalk = $self->{adminstore}->get_client(); + my $talk = $self->{store}->get_client(); + + # Right - let's set ourselves a basic usage quota + $self->_set_quotaroot('user.cassandane'); + $self->_set_limits( + storage => 100000, + message => 50000, + $self->res_annot_storage => 10000, + ); + $self->_check_usages( + storage => 0, + message => 0, + $self->res_annot_storage => 0, + ); + + my $expected_storage = 0; + my $expected_message = 0; + my $expected_annotation_storage = 0; + my $uid = 1; + for (1..10) { + my $msg = $self->make_message("Message $_", extra_lines => 5000); + $expected_storage += length($msg->as_string()); + $expected_message++; + + my $annotation = $self->make_random_data(1); + $expected_annotation_storage += length($annotation); + $talk->store('' . $uid, 'annotation', ['/comment', ['value.priv', { Quote => $annotation }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $uid++; + } + + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + $self->{store}->set_folder("INBOX.sub"); + $talk->select($self->{store}->{folder}) || die; + my $expected_storage_more = $expected_storage; + my $expected_message_more = $expected_message; + my $expected_annotation_storage_more = $expected_annotation_storage; + $uid = 1; + for (1..10) { + + my $msg = $self->make_message("Message $_", + extra_lines => 10 + rand(5000)); + $expected_storage_more += length($msg->as_string()); + $expected_message_more++; + + my $annotation = $self->make_random_data(1); + $expected_annotation_storage_more += length($annotation); + $talk->store('' . $uid, 'annotation', ['/comment', ['value.priv', { Quote => $annotation }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $uid++; + } + $self->{store}->set_folder("INBOX"); + $talk->select($self->{store}->{folder}) || die; + + $self->_check_usages( + storage => int($expected_storage_more/1024), + message => $expected_message_more, + $self->res_annot_storage => int($expected_annotation_storage_more/1024), + ); + + $talk->rename("INBOX.sub", "INBOX.othersub") || die; + $talk->select("INBOX.othersub") || die; + + # usage should be the same after a rename + $self->_check_usages( + storage => int($expected_storage_more/1024), + message => $expected_message_more, + $self->res_annot_storage => int($expected_annotation_storage_more/1024), + ); + + $talk->delete("INBOX.othersub") || die; + + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); +} diff --git a/cassandane/tiny-tests/Quota/reconstruct b/cassandane/tiny-tests/Quota/reconstruct new file mode 100644 index 0000000000..cc4c2e9ffd --- /dev/null +++ b/cassandane/tiny-tests/Quota/reconstruct @@ -0,0 +1,126 @@ +#!perl +use Cassandane::Tiny; + +sub test_reconstruct +{ + my ($self) = @_; + + xlog $self, "test resources usage calculated when reconstructing an index"; + + $self->_set_quotaroot('user.cassandane'); + my $folder = 'INBOX'; + my $fentry = '/private/comment'; + my $mentry1 = '/comment'; + my $mentry2 = '/altsubject'; + my $mattrib = 'value.priv'; + + my $store = $self->{store}; + $store->set_fetch_attributes('uid', + "annotation ($mentry1 $mattrib)", + "annotation ($mentry2 $mattrib)"); + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "set ourselves a basic limit"; + $self->_set_limits( + storage => 100000, + message => 50000, + $self->res_annot_storage => 100000, + ); + $self->_check_usages( + storage => 0, + message => 0, + $self->res_annot_storage => 0, + ); + my $expected_annotation_storage = 0; + my $expected_storage = 0; + my $expected_message = 0; + + xlog $self, "store annotations"; + my $data = $self->make_random_data(10); + $expected_annotation_storage += length($data); + $talk->setmetadata($folder, $fentry, { Quote => $data }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "add some messages"; + my $uid = 1; + my %exp; + for (1..10) + { + my $msg = $self->make_message("Message $_", + extra_lines => 10 + rand(5000)); + $exp{$uid} = $msg; + my $data1 = $self->make_random_data(7); + my $data2 = $self->make_random_data(3); + $msg->set_attribute('uid', $uid); + $msg->set_annotation($mentry1, $mattrib, $data1); + $msg->set_annotation($mentry2, $mattrib, $data2); + $talk->store('' . $uid, 'annotation', + [$mentry1, [$mattrib, { Quote => $data1 }], + $mentry2, [$mattrib, { Quote => $data2 }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $expected_annotation_storage += (length($data1) + length($data2)); + $expected_storage += length($msg->as_string()); + $expected_message++; + $uid++; + } + + xlog $self, "Check the messages are all there"; + $self->check_messages(\%exp); + + xlog $self, "Check the mailbox annotation is still there"; + my $res = $talk->getmetadata($folder, $fentry); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => $data } + }, $res); + + xlog $self, "Check the quota usage is as expected"; + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); + + $self->{store}->disconnect(); + $self->{adminstore}->disconnect(); + $talk = undef; + $admintalk = undef; + + xlog $self, "Moving the cyrus.index file out of the way"; + my $datadir = $self->{instance}->folder_to_directory('user.cassandane'); + my $cyrus_index = "$datadir/cyrus.index"; + $self->assert_file_test($cyrus_index, '-f'); + rename($cyrus_index, $cyrus_index . '.NOT') + or die "Cannot rename $cyrus_index: $!"; + + xlog $self, "Running reconstruct"; + $self->{instance}->run_command({ cyrus => 1 }, + 'reconstruct', 'user.cassandane'); + xlog $self, "Running quota -f"; + $self->{instance}->run_command({ cyrus => 1 }, + 'quota', '-f', "user.cassandane"); + + $talk = $store->get_client(); + + xlog $self, "Check the messages are still all there"; + $self->check_messages(\%exp); + + xlog $self, "Check the mailbox annotation is still there"; + $res = $talk->getmetadata($folder, $fentry); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => $data } + }, $res); + + xlog $self, "Check the quota usage is still as expected"; + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); + + # We should have generated a SYNCERROR or two + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: opening index/); +} diff --git a/cassandane/tiny-tests/Quota/reconstruct_orphans b/cassandane/tiny-tests/Quota/reconstruct_orphans new file mode 100644 index 0000000000..f871b50296 --- /dev/null +++ b/cassandane/tiny-tests/Quota/reconstruct_orphans @@ -0,0 +1,142 @@ +#!perl +use Cassandane::Tiny; + +sub test_reconstruct_orphans +{ + my ($self) = @_; + + xlog $self, "test resources usage calculated when reconstructing an index"; + xlog $self, "with messages disappearing, resulting in orphan annotations"; + + $self->_set_quotaroot('user.cassandane'); + my $folder = 'INBOX'; + my $fentry = '/private/comment'; + my $mentry1 = '/comment'; + my $mentry2 = '/altsubject'; + my $mattrib = 'value.priv'; + + my $store = $self->{store}; + $store->set_fetch_attributes('uid', + "annotation ($mentry1 $mattrib)", + "annotation ($mentry2 $mattrib)"); + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + xlog $self, "set ourselves a basic limit"; + $self->_set_limits( + storage => 100000, + message => 50000, + $self->res_annot_storage => 100000, + ); + $self->_check_usages( + storage => 0, + message => 0, + $self->res_annot_storage => 0, + ); + my $expected_annotation_storage = 0; + my $expected_storage = 0; + my $expected_message = 0; + + xlog $self, "store annotations"; + my $data = $self->make_random_data(10); + $expected_annotation_storage += length($data); + $talk->setmetadata($folder, $fentry, { Quote => $data }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "add some messages"; + my $uid = 1; + my %exp; + for (1..10) + { + my $msg = $self->make_message("Message $_", + extra_lines => 10 + rand(5000)); + $exp{$uid} = $msg; + my $data1 = $self->make_random_data(7); + my $data2 = $self->make_random_data(3); + $msg->set_attribute('uid', $uid); + $msg->set_annotation($mentry1, $mattrib, $data1); + $msg->set_annotation($mentry2, $mattrib, $data2); + $talk->store('' . $uid, 'annotation', + [$mentry1, [$mattrib, { Quote => $data1 }], + $mentry2, [$mattrib, { Quote => $data2 }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $expected_annotation_storage += (length($data1) + length($data2)); + $expected_storage += length($msg->as_string()); + $expected_message++; + $uid++; + } + + xlog $self, "Check the messages are all there"; + $self->check_messages(\%exp); + + xlog $self, "Check the mailbox annotation is still there"; + my $res = $talk->getmetadata($folder, $fentry); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => $data } + }, $res); + + xlog $self, "Check the quota usage is as expected"; + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); + + $self->{store}->disconnect(); + $self->{adminstore}->disconnect(); + $talk = undef; + $admintalk = undef; + + xlog $self, "Moving the cyrus.index file out of the way"; + my $datadir = $self->{instance}->folder_to_directory('user.cassandane'); + my $cyrus_index = "$datadir/cyrus.index"; + $self->assert_file_test($cyrus_index, '-f'); + rename($cyrus_index, $cyrus_index . '.NOT') + or die "Cannot rename $cyrus_index: $!"; + + xlog $self, "Delete a couple of messages"; + foreach $uid (2, 7) + { + xlog $self, "Deleting uid $uid"; + unlink("$datadir/$uid."); + + my $msg = delete $exp{$uid}; + my $data1 = $msg->get_annotation($mentry1, $mattrib); + my $data2 = $msg->get_annotation($mentry2, $mattrib); + + $expected_annotation_storage -= (length($data1) + length($data2)); + $expected_storage -= length($msg->as_string()); + $expected_message--; + } + + xlog $self, "Running reconstruct"; + $self->{instance}->run_command({ cyrus => 1 }, + 'reconstruct', 'user.cassandane'); + xlog $self, "Running quota -f"; + $self->{instance}->run_command({ cyrus => 1 }, + 'quota', '-f', "user.cassandane"); + + $talk = $store->get_client(); + + xlog $self, "Check the messages are still all there"; + $self->check_messages(\%exp); + + xlog $self, "Check the mailbox annotation is still there"; + $res = $talk->getmetadata($folder, $fentry); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $folder => { $fentry => $data } + }, $res); + + xlog $self, "Check the quota usage is still as expected"; + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); + + # We should have generated a SYNCERROR or two + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: opening index/); +} diff --git a/cassandane/tiny-tests/Quota/rename_withannot b/cassandane/tiny-tests/Quota/rename_withannot new file mode 100644 index 0000000000..4f11a915cf --- /dev/null +++ b/cassandane/tiny-tests/Quota/rename_withannot @@ -0,0 +1,150 @@ +#!perl +use Cassandane::Tiny; + +sub test_rename_withannot +{ + my ($self) = @_; + my ($cyrus_version) = Cassandane::Instance->get_version(); + + xlog $self, "test resources usage survives rename"; + + $self->_set_quotaroot('user.cassandane'); + my $src = 'INBOX.src'; + my $dest = 'INBOX.dest'; + my $fentry = '/private/comment'; + my $mentry1 = '/comment'; + my $mentry2 = '/altsubject'; + my $mattrib = 'value.priv'; + my $vendsize = "/shared/vendor/cmu/cyrus-imapd/size"; + my $vendannot = "/shared/vendor/cmu/cyrus-imapd/annotsize"; + + my $store = $self->{store}; + $store->set_fetch_attributes('uid', + "annotation ($mentry1 $mattrib)", + "annotation ($mentry2 $mattrib)"); + my $talk = $store->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + $talk->create($src) || die "Failed to create subfolder"; + $store->set_folder($src); + + xlog $self, "set ourselves a basic limit"; + $self->_set_limits( + storage => 100000, + message => 50000, + $self->res_annot_storage => 100000, + ); + $self->_check_usages( + storage => 0, + message => 0, + $self->res_annot_storage => 0, + ); + my $expected_annotation_storage = 0; + my $expected_storage = 0; + my $expected_message = 0; + + xlog $self, "store annotations"; + my $data = $self->make_random_data(10); + $expected_annotation_storage += length($data); + $talk->setmetadata($src, $fentry, { Quote => $data }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "add some messages"; + my $uid = 1; + my %exp; + for (1..10) + { + my $msg = $self->make_message("Message $_", + extra_lines => 10 + rand(5000)); + $exp{$uid} = $msg; + my $data1 = $self->make_random_data(7); + my $data2 = $self->make_random_data(3); + $msg->set_attribute('uid', $uid); + $msg->set_annotation($mentry1, $mattrib, $data1); + $msg->set_annotation($mentry2, $mattrib, $data2); + $talk->store('' . $uid, 'annotation', + [$mentry1, [$mattrib, { Quote => $data1 }], + $mentry2, [$mattrib, { Quote => $data2 }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $expected_annotation_storage += (length($data1) + length($data2)); + $expected_storage += length($msg->as_string()); + $expected_message++; + $uid++; + } + + my $res; + + xlog $self, "Check the messages are all there"; + $self->check_messages(\%exp); + + xlog $self, "check that the used size matches"; + $res = $talk->getmetadata($src, $vendsize); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $src => { $vendsize => $expected_storage }, + }, $res); + + if ($cyrus_version >= 3) { + xlog $self, "check that the annot size matches"; + $res = $talk->getmetadata($src, $vendannot); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $src => { $vendannot => $expected_annotation_storage }, + }, $res); + } + + xlog $self, "Check the mailbox annotation is still there"; + $res = $talk->getmetadata($src, $fentry); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $src => { $fentry => $data } + }, $res); + + xlog $self, "Check the quota usage is as expected"; + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); + + xlog $self, "rename $src to $dest"; + $talk->rename($src, $dest); + $store->set_folder($dest); + + xlog $self, "Check the messages are all there"; + $self->check_messages(\%exp); + + xlog $self, "Check the old mailbox annotation is not there"; + $res = $talk->getmetadata($src, $fentry); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + + xlog $self, "Check the new mailbox annotation is there"; + $res = $talk->getmetadata($dest, $fentry); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $dest => { $fentry => $data } + }, $res); + + xlog $self, "check that the used size still matches"; + $res = $talk->getmetadata($dest, $vendsize); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $dest => { $vendsize => $expected_storage }, + }, $res); + + if ($cyrus_version >= 3) { + xlog $self, "check that the annot size still matches"; + $res = $talk->getmetadata($dest, $vendannot); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $self->assert_deep_equals({ + $dest => { $vendannot => $expected_annotation_storage }, + }, $res); + } + + xlog $self, "Check the quota usage is still as expected"; + $self->_check_usages( + storage => int($expected_storage/1024), + message => $expected_message, + $self->res_annot_storage => int($expected_annotation_storage/1024), + ); +} diff --git a/cassandane/tiny-tests/Quota/replication_annotstorage b/cassandane/tiny-tests/Quota/replication_annotstorage new file mode 100644 index 0000000000..f1c770ee1d --- /dev/null +++ b/cassandane/tiny-tests/Quota/replication_annotstorage @@ -0,0 +1,110 @@ +#!perl +use Cassandane::Tiny; + +# Magic: the word 'replication' in the name enables a replica +sub test_replication_annotstorage + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of X-ANNOTATION-STORAGE quota"; + + my $folder = "user.cassandane"; + my $mastertalk = $self->{master_adminstore}->get_client(); + my $replicatalk = $self->{replica_adminstore}->get_client(); + + my @res; + + xlog $self, "checking there are no initial quotas"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('no', $mastertalk->get_last_completion_response()); + $self->assert($mastertalk->get_last_error() =~ m/Quota root does not exist/i); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('no', $replicatalk->get_last_completion_response()); + $self->assert($replicatalk->get_last_error() =~ m/Quota root does not exist/i); + + xlog $self, "set an X-ANNOTATION-STORAGE quota on the master"; + $mastertalk->setquota($folder, "(" . $self->res_annot_storage . " 12345)"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals([$self->res_annot_storage, 0, 12345], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals([$self->res_annot_storage, 0, 12345], \@res); + + xlog $self, "change the X-ANNOTATION-STORAGE quota on the master"; + $mastertalk->setquota($folder, "(" . $self->res_annot_storage. " 67890)"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals([$self->res_annot_storage, 0, 67890], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals([$self->res_annot_storage, 0, 67890], \@res); + + xlog $self, "add an annotation to use some quota"; + my $data = $self->make_random_data(13); + my $msg = $self->make_message("Message A", store => $self->{master_store}); + $mastertalk->store('1', 'annotation', ['/comment', ['value.priv', { Quote => $data }]]); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); +## This doesn't work because per-mailbox annots are not +## replicated when sync_client is run in -u mode...sigh +# $mastertalk->setmetadata($folder, '/private/comment', { Quote => $data }); +# $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + my $used = int(length($data)/1024); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check the annotation used some quota on the master"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals([ + $self->res_annot_storage, $used, 67890 + ], \@res); + + xlog $self, "check the annotation used some quota on the replica"; + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals([ + $self->res_annot_storage, $used, 67890 + ], \@res); + + xlog $self, "clear the X-ANNOTATION-STORAGE quota on the master"; + $mastertalk->setquota($folder, "()"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); +} diff --git a/cassandane/tiny-tests/Quota/replication_message b/cassandane/tiny-tests/Quota/replication_message new file mode 100644 index 0000000000..018a65c9e3 --- /dev/null +++ b/cassandane/tiny-tests/Quota/replication_message @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +# Magic: the word 'replication' in the name enables a replica +sub test_replication_message + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of MESSAGE quota"; + + my $mastertalk = $self->{master_adminstore}->get_client(); + my $replicatalk = $self->{replica_adminstore}->get_client(); + + my $folder = "user.cassandane"; + my @res; + + xlog $self, "checking there are no initial quotas"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('no', $mastertalk->get_last_completion_response()); + $self->assert($mastertalk->get_last_error() =~ m/Quota root does not exist/i); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('no', $replicatalk->get_last_completion_response()); + $self->assert($replicatalk->get_last_error() =~ m/Quota root does not exist/i); + + xlog $self, "set a STORAGE quota on the master"; + $mastertalk->setquota($folder, "(message 12345)"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals(['MESSAGE', 0, 12345], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals(['MESSAGE', 0, 12345], \@res); + + xlog $self, "change the MESSAGE quota on the master"; + $mastertalk->setquota($folder, "(message 67890)"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals(['MESSAGE', 0, 67890], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals(['MESSAGE', 0, 67890], \@res); + + xlog $self, "clear the MESSAGE quota on the master"; + $mastertalk->setquota($folder, "()"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); +} diff --git a/cassandane/tiny-tests/Quota/replication_storage b/cassandane/tiny-tests/Quota/replication_storage new file mode 100644 index 0000000000..5be76544df --- /dev/null +++ b/cassandane/tiny-tests/Quota/replication_storage @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +# Magic: the word 'replication' in the name enables a replica +sub test_replication_storage + :needs_component_replication +{ + my ($self) = @_; + + xlog $self, "testing replication of STORAGE quota"; + + my $mastertalk = $self->{master_adminstore}->get_client(); + my $replicatalk = $self->{replica_adminstore}->get_client(); + + my $folder = "user.cassandane"; + my @res; + + xlog $self, "checking there are no initial quotas"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('no', $mastertalk->get_last_completion_response()); + $self->assert($mastertalk->get_last_error() =~ m/Quota root does not exist/i); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('no', $replicatalk->get_last_completion_response()); + $self->assert($replicatalk->get_last_error() =~ m/Quota root does not exist/i); + + xlog $self, "set a STORAGE quota on the master"; + $mastertalk->setquota($folder, "(storage 12345)"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals(['STORAGE', 0, 12345], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals(['STORAGE', 0, 12345], \@res); + + xlog $self, "change the STORAGE quota on the master"; + $mastertalk->setquota($folder, "(storage 67890)"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals(['STORAGE', 0, 67890], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals(['STORAGE', 0, 67890], \@res); + + xlog $self, "clear the STORAGE quota on the master"; + $mastertalk->setquota($folder, "()"); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + + xlog $self, "run replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + $mastertalk = $self->{master_adminstore}->get_client(); + $replicatalk = $self->{replica_adminstore}->get_client(); + + xlog $self, "check that the new quota is at both ends"; + @res = $mastertalk->getquota($folder); + $self->assert_str_equals('ok', $mastertalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); + @res = $replicatalk->getquota($folder); + $self->assert_str_equals('ok', $replicatalk->get_last_completion_response()); + $self->assert_deep_equals([], \@res); +} diff --git a/cassandane/tiny-tests/Quota/storage_convquota_delayed b/cassandane/tiny-tests/Quota/storage_convquota_delayed new file mode 100644 index 0000000000..193803a8c7 --- /dev/null +++ b/cassandane/tiny-tests/Quota/storage_convquota_delayed @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_storage_convquota_delayed + :min_version_3_3 :Conversations :ConversationsQuota :DelayedDelete +{ + my ($self) = @_; + + xlog $self, "test increasing usage of the STORAGE quota resource as messages are added"; + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_limits(storage => 100000); + $self->_check_usages(storage => 0); + my $talk = $self->{store}->get_client(); + + my $KEY = "/shared/vendor/cmu/cyrus-imapd/userrawquota"; + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + # append some messages + $self->{store}->set_folder("INBOX"); + my $msg = $self->make_message("Message 1", + extra_lines => 10 + rand(5000)); + my $size1 = length($msg->as_string()); + + $self->{store}->set_folder("INBOX.sub"); + my $msg2 = $self->make_message("Message 2", + extra_lines => 10 + rand(5000)); + my $size2 = length($msg2->as_string()); + + my $data1 = $talk->getmetadata("INBOX", $KEY); + my ($rawusage1) = $data1->{'INBOX'}{$KEY} =~ m/STORAGE (\d+)/; + + $self->_check_usages(storage => int(($size1+$size2)/1024)); + $self->assert_num_equals(int(($size1+$size2)/1024), $rawusage1); + + $talk->select("INBOX"); + $talk->copy("1", "INBOX.sub"); + + my $data2 = $talk->getmetadata("INBOX", $KEY); + my ($rawusage2) = $data2->{'INBOX'}{$KEY} =~ m/STORAGE (\d+)/; + + # quota usage hasn't changed, because we don't get double-charged + $self->_check_usages(storage => int(($size1+$size2)/1024)); + # but raw usage has gone up by another copy of message 1 + $self->assert_num_equals(int(($size1+$size2+$size1)/1024), $rawusage2); + + $talk->delete("INBOX.sub"); + + my $data3 = $talk->getmetadata("INBOX", $KEY); + my ($rawusage3) = $data3->{'INBOX'}{$KEY} =~ m/STORAGE (\d+)/; + + # we just lost all copies of message2 + $self->_check_usages(storage => int($size1/1024)); + # and also the second copy of message1, so just size1 left + $self->assert_num_equals(int($size1/1024), $rawusage3); +} diff --git a/cassandane/tiny-tests/Quota/storage_convquota_immediate b/cassandane/tiny-tests/Quota/storage_convquota_immediate new file mode 100644 index 0000000000..5730818953 --- /dev/null +++ b/cassandane/tiny-tests/Quota/storage_convquota_immediate @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_storage_convquota_immediate + :min_version_3_3 :Conversations :ConversationsQuota :ImmediateDelete +{ + my ($self) = @_; + + xlog $self, "test increasing usage of the STORAGE quota resource as messages are added"; + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_limits(storage => 100000); + $self->_check_usages(storage => 0); + my $talk = $self->{store}->get_client(); + + my $KEY = "/shared/vendor/cmu/cyrus-imapd/userrawquota"; + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + # append some messages + $self->{store}->set_folder("INBOX"); + my $msg = $self->make_message("Message 1", + extra_lines => 10 + rand(5000)); + my $size1 = length($msg->as_string()); + + $self->{store}->set_folder("INBOX.sub"); + my $msg2 = $self->make_message("Message 2", + extra_lines => 10 + rand(5000)); + my $size2 = length($msg2->as_string()); + + my $data1 = $talk->getmetadata("INBOX", $KEY); + my ($rawusage1) = $data1->{'INBOX'}{$KEY} =~ m/STORAGE (\d+)/; + + $self->_check_usages(storage => int(($size1+$size2)/1024)); + $self->assert_num_equals(int(($size1+$size2)/1024), $rawusage1); + + $talk->select("INBOX"); + $talk->copy("1", "INBOX.sub"); + + my $data2 = $talk->getmetadata("INBOX", $KEY); + my ($rawusage2) = $data2->{'INBOX'}{$KEY} =~ m/STORAGE (\d+)/; + + # quota usage hasn't changed, because we don't get double-charged + $self->_check_usages(storage => int(($size1+$size2)/1024)); + # but raw usage has gone up by another copy of message 1 + $self->assert_num_equals(int(($size1+$size2+$size1)/1024), $rawusage2); + + $talk->delete("INBOX.sub"); + + my $data3 = $talk->getmetadata("INBOX", $KEY); + my ($rawusage3) = $data3->{'INBOX'}{$KEY} =~ m/STORAGE (\d+)/; + + # we just lost all copies of message2 + $self->_check_usages(storage => int($size1/1024)); + # and also the second copy of message1, so just size1 left + $self->assert_num_equals(int($size1/1024), $rawusage3); +} diff --git a/cassandane/tiny-tests/Quota/using_annotstorage_mbox b/cassandane/tiny-tests/Quota/using_annotstorage_mbox new file mode 100644 index 0000000000..0c8a801591 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_annotstorage_mbox @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_annotstorage_mbox +{ + my ($self) = @_; + + xlog $self, "test setting X-ANNOTATION-STORAGE quota resource after"; + xlog $self, "per-mailbox annotations are added"; + + $self->_set_quotaroot('user.cassandane'); + my $talk = $self->{store}->get_client(); + + xlog $self, "set ourselves a basic limit"; + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => 0); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + xlog $self, "store annotations"; + my %expecteds = (); + my $expected = 0; + foreach my $folder ("INBOX", "INBOX.sub") + { + $expecteds{$folder} = 0; + $self->{store}->set_folder($folder); + my $data = ''; + while ($expecteds{$folder} <= 60*1024) + { + my $moredata = $self->make_random_data(5); + $data .= $moredata; + $talk->setmetadata($self->{store}->{folder}, '/private/comment', { Quote => $data }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $expecteds{$folder} += length($moredata); + $expected += length($moredata); + xlog $self, "EXPECTING $expected on $folder"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + } + } + + # delete subfolder + xlog $self, "Deleting a folder"; + $talk->delete("INBOX.sub") || die "Failed to delete subfolder"; + $expected -= delete($expecteds{"INBOX.sub"}); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + # delete remaining annotations + $self->{store}->set_folder("INBOX"); + $talk->setmetadata($self->{store}->{folder}, '/private/comment', undef); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $expected -= delete($expecteds{"INBOX"}); + $self->assert_num_equals(0, $expected); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_annotstorage_mbox_late b/cassandane/tiny-tests/Quota/using_annotstorage_mbox_late new file mode 100644 index 0000000000..5786370da6 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_annotstorage_mbox_late @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_annotstorage_mbox_late +{ + my ($self) = @_; + + xlog $self, "test increasing usage of the X-ANNOTATION-STORAGE quota"; + xlog $self, "resource as per-mailbox annotations are added"; + + $self->_set_quotaroot('user.cassandane'); + my $talk = $self->{store}->get_client(); + + $self->_check_no_quota(); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + xlog $self, "store annotations"; + my %expecteds = (); + my $expected = 0; + foreach my $folder ("INBOX", "INBOX.sub") + { + $expecteds{$folder} = 0; + $self->{store}->set_folder($folder); + my $data = ''; + while ($expecteds{$folder} <= 60*1024) + { + my $moredata = $self->make_random_data(5); + $data .= $moredata; + $talk->setmetadata($self->{store}->{folder}, '/private/comment', { Quote => $data }); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $expecteds{$folder} += length($moredata); + $expected += length($moredata); + } + } + + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + # delete subfolder + $talk->delete("INBOX.sub") || die "Failed to delete subfolder"; + $expected -= delete($expecteds{"INBOX.sub"}); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + # delete remaining annotations + $self->{store}->set_folder("INBOX"); + $talk->setmetadata($self->{store}->{folder}, '/private/comment', undef); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $expected -= delete($expecteds{"INBOX"}); + $self->assert_num_equals(0, $expected); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_annotstorage_msg b/cassandane/tiny-tests/Quota/using_annotstorage_msg new file mode 100644 index 0000000000..401ac27231 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_annotstorage_msg @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_annotstorage_msg +{ + my ($self) = @_; + + xlog $self, "test setting X-ANNOTATION-STORAGE quota resource after"; + xlog $self, "per-message annotations are added"; + + $self->_set_quotaroot('user.cassandane'); + my $talk = $self->{store}->get_client(); + + xlog $self, "set ourselves a basic limit"; + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => 0); + + $talk->create("INBOX.sub1") || die "Failed to create subfolder"; + $talk->create("INBOX.sub2") || die "Failed to create subfolder"; + + xlog $self, "make some messages to hang annotations on"; + my %expecteds = (); + my $expected = 0; + foreach my $folder ("INBOX", "INBOX.sub1", "INBOX.sub2") + { + $self->{store}->set_folder($folder); + $expecteds{$folder} = 0; + my $uid = 1; + for (1..5) + { + $self->make_message("Message $uid"); + + my $data = $self->make_random_data(10); + $talk->store('' . $uid, 'annotation', ['/comment', ['value.priv', { Quote => $data }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $uid++; + $expecteds{$folder} += length($data); + $expected += length($data); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + } + } + + xlog $self, "delete subfolder sub1"; + $talk->delete("INBOX.sub1") || die "Failed to delete subfolder"; + $expected -= delete($expecteds{"INBOX.sub1"}); + + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "delete messages in sub2"; + $talk->select("INBOX.sub2"); + $talk->store('1:*', '+flags', '(\\deleted)'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $talk->expunge(); + + $expected -= delete($expecteds{"INBOX.sub2"}); + + xlog $self, "Unlike STORAGE, X-ANNOTATION-STORAGE quota is reduced immediately"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + $self->run_delayed_expunge(); + $talk = $self->{store}->get_client(); + + xlog $self, "X-ANNOTATION-STORAGE quota should not have changed during delayed expunge"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "delete annotations on INBOX"; + $talk->select("INBOX"); + $talk->store('1:*', 'annotation', ['/comment', ['value.priv', undef]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $talk->close(); + $expected -= delete($expecteds{"INBOX"}); + $self->assert_num_equals(0, $expected); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_dedel b/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_dedel new file mode 100644 index 0000000000..05578344e4 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_dedel @@ -0,0 +1,102 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_annotstorage_msg_copy_dedel + :DelayedDelete +{ + my ($self) = @_; + + xlog $self, "testing X-ANNOTATION-STORAGE quota usage as messages are COPYd"; + xlog $self, "and original folder is deleted, delete_mode=delayed version"; + xlog $self, "(BZ3527)"; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $from_folder = 'INBOX.from'; + my $to_folder = 'INBOX.to'; + + xlog $self, "Check the delete mode is \"delayed\""; + my $delete_mode = $self->{instance}->{config}->get('delete_mode'); + $self->assert_str_equals('delayed', $delete_mode); + + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => 0); + my $talk = $self->{store}->get_client(); + + my $store = $self->{store}; + $store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Create subfolders to copy from and to"; + $talk = $store->get_client(); + $talk->create($from_folder) + or die "Cannot create mailbox $from_folder: $@"; + $talk->create($to_folder) + or die "Cannot create mailbox $to_folder: $@"; + + $store->set_folder($from_folder); + + xlog $self, "Append some messages and store annotations"; + my %exp; + my $expected = 0; + my $uid = 1; + for (1..20) + { + my $data = $self->make_random_data(10); + my $msg = $self->make_message("Message $uid"); + $msg->set_attribute('uid', $uid); + $msg->set_annotation($entry, $attrib, $data); + $exp{$uid} = $msg; + $talk->store('' . $uid, 'annotation', [$entry, [$attrib, { Quote => $data }]]); + $expected += length($data); + $uid++; + } + + xlog $self, "Check the annotations are there"; + $self->check_messages(\%exp); + xlog $self, "Check the quota usage is correct"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "COPY the messages"; + $talk = $store->get_client(); + $talk->copy('1:*', $to_folder); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Messages are now in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Check the quota usage is now doubled"; + $self->_check_usages($self->res_annot_storage => int(2*$expected/1024)); + + xlog $self, "Messages are still in the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Delete the origin folder"; + $talk = $store->get_client(); + $talk->unselect(); + $talk->delete($from_folder) + or die "Cannot delete folder $from_folder: $@"; + + xlog $self, "Messages are still in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + # Note that, unlike with delayed expunge, with delayed delete the + # annotations are deleted immediately and so the negative delta to + # quota is applied immediately. Whether this is sensible is a + # different question. + + xlog $self, "Check the quota usage is back to single"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + $self->run_delayed_expunge(); + + xlog $self, "Check the quota usage is still back to single"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_deimm b/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_deimm new file mode 100644 index 0000000000..9e0a274f80 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_deimm @@ -0,0 +1,92 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_annotstorage_msg_copy_deimm + :ImmediateDelete +{ + my ($self) = @_; + + xlog $self, "testing X-ANNOTATION-STORAGE quota usage as messages are COPYd"; + xlog $self, "and original folder is deleted, delete_mode=immediate version"; + xlog $self, "(BZ3527)"; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $from_folder = 'INBOX.from'; + my $to_folder = 'INBOX.to'; + + xlog $self, "Check the delete mode is \"immediate\""; + my $delete_mode = $self->{instance}->{config}->get('delete_mode'); + $self->assert_str_equals('immediate', $delete_mode); + + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => 0); + my $talk = $self->{store}->get_client(); + + my $store = $self->{store}; + $store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Create subfolders to copy from and to"; + $talk = $store->get_client(); + $talk->create($from_folder) + or die "Cannot create mailbox $from_folder: $@"; + $talk->create($to_folder) + or die "Cannot create mailbox $to_folder: $@"; + + $store->set_folder($from_folder); + + xlog $self, "Append some messages and store annotations"; + my %exp; + my $expected = 0; + my $uid = 1; + for (1..20) + { + my $data = $self->make_random_data(10); + my $msg = $self->make_message("Message $uid"); + $msg->set_attribute('uid', $uid); + $msg->set_annotation($entry, $attrib, $data); + $exp{$uid} = $msg; + $talk->store('' . $uid, 'annotation', [$entry, [$attrib, { Quote => $data }]]); + $expected += length($data); + $uid++; + } + + xlog $self, "Check the annotations are there"; + $self->check_messages(\%exp); + xlog $self, "Check the quota usage is correct"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "COPY the messages"; + $talk = $store->get_client(); + $talk->copy('1:*', $to_folder); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Messages are now in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Check the quota usage is now doubled"; + $self->_check_usages($self->res_annot_storage => int(2*$expected/1024)); + + xlog $self, "Messages are still in the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Delete the origin folder"; + $talk = $store->get_client(); + $talk->unselect(); + $talk->delete($from_folder) + or die "Cannot delete folder $from_folder: $@"; + + xlog $self, "Messages are still in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Check the quota usage is back to single"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_exdel b/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_exdel new file mode 100644 index 0000000000..570358c3b5 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_exdel @@ -0,0 +1,103 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_annotstorage_msg_copy_exdel + :DelayedExpunge +{ + my ($self) = @_; + + xlog $self, "testing X-ANNOTATION-STORAGE quota usage as messages are COPYd"; + xlog $self, "and original messages are deleted, expunge_mode=delayed version"; + xlog $self, "(BZ3527)"; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $from_folder = 'INBOX.from'; + my $to_folder = 'INBOX.to'; + + xlog $self, "Check the expunge mode is \"delayed\""; + my $expunge_mode = $self->{instance}->{config}->get('expunge_mode'); + $self->assert_str_equals('delayed', $expunge_mode); + + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => 0); + my $talk = $self->{store}->get_client(); + + my $store = $self->{store}; + $store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Create subfolders to copy from and to"; + $talk = $store->get_client(); + $talk->create($from_folder) + or die "Cannot create mailbox $from_folder: $@"; + $talk->create($to_folder) + or die "Cannot create mailbox $to_folder: $@"; + + $store->set_folder($from_folder); + + xlog $self, "Append some messages and store annotations"; + my %exp; + my $expected = 0; + my $uid = 1; + for (1..20) + { + my $data = $self->make_random_data(10); + my $msg = $self->make_message("Message $uid"); + $msg->set_attribute('uid', $uid); + $msg->set_annotation($entry, $attrib, $data); + $exp{$uid} = $msg; + $talk->store('' . $uid, 'annotation', [$entry, [$attrib, { Quote => $data }]]); + $expected += length($data); + $uid++; + } + + xlog $self, "Check the annotations are there"; + $self->check_messages(\%exp); + xlog $self, "Check the quota usage is correct"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "COPY the messages"; + $talk = $store->get_client(); + $talk->copy('1:*', $to_folder); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Messages are now in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Check the quota usage is now doubled"; + $self->_check_usages($self->res_annot_storage => int(2*$expected/1024)); + + xlog $self, "Messages are still in the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Delete the messages from the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $talk = $store->get_client(); + $talk->store('1:*', '+flags', '(\\Deleted)'); + $talk->expunge(); + + xlog $self, "Messages are gone from the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $self->check_messages({}); + + xlog $self, "Messages are still in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Check the quota usage has reduced again"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + $self->run_delayed_expunge(); + + xlog $self, "Check the quota usage is still the same"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_eximm b/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_eximm new file mode 100644 index 0000000000..c28ef1ffdc --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_annotstorage_msg_copy_eximm @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_annotstorage_msg_copy_eximm + :ImmediateExpunge +{ + my ($self) = @_; + + xlog $self, "testing X-ANNOTATION-STORAGE quota usage as messages are COPYd"; + xlog $self, "and original messages are deleted, expunge_mode=immediate version"; + xlog $self, "(BZ3527)"; + + my $entry = '/comment'; + my $attrib = 'value.priv'; + my $from_folder = 'INBOX.from'; + my $to_folder = 'INBOX.to'; + + xlog $self, "Check the expunge mode is \"immediate\""; + my $expunge_mode = $self->{instance}->{config}->get('expunge_mode'); + $self->assert_str_equals('immediate', $expunge_mode); + + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => 0); + my $talk = $self->{store}->get_client(); + + my $store = $self->{store}; + $store->set_fetch_attributes('uid', "annotation ($entry $attrib)"); + + xlog $self, "Create subfolders to copy from and to"; + $talk = $store->get_client(); + $talk->create($from_folder) + or die "Cannot create mailbox $from_folder: $@"; + $talk->create($to_folder) + or die "Cannot create mailbox $to_folder: $@"; + + $store->set_folder($from_folder); + + xlog $self, "Append some messages and store annotations"; + my %exp; + my $expected = 0; + my $uid = 1; + for (1..20) + { + my $data = $self->make_random_data(10); + my $msg = $self->make_message("Message $uid"); + $msg->set_attribute('uid', $uid); + $msg->set_annotation($entry, $attrib, $data); + $exp{$uid} = $msg; + $talk->store('' . $uid, 'annotation', [$entry, [$attrib, { Quote => $data }]]); + $expected += length($data); + $uid++; + } + + xlog $self, "Check the annotations are there"; + $self->check_messages(\%exp); + xlog $self, "Check the quota usage is correct"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "COPY the messages"; + $talk = $store->get_client(); + $talk->copy('1:*', $to_folder); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Messages are now in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Check the quota usage is now doubled"; + $self->_check_usages($self->res_annot_storage => int(2*$expected/1024)); + + xlog $self, "Messages are still in the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Delete the messages from the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $talk = $store->get_client(); + $talk->store('1:*', '+flags', '(\\Deleted)'); + $talk->expunge(); + + xlog $self, "Messages are gone from the origin folder"; + $store->set_folder($from_folder); + $store->_select(); + $self->check_messages({}); + + xlog $self, "Messages are still in the destination folder"; + $store->set_folder($to_folder); + $store->_select(); + $self->check_messages(\%exp); + + xlog $self, "Check the quota usage is back to single"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_annotstorage_msg_late b/cassandane/tiny-tests/Quota/using_annotstorage_msg_late new file mode 100644 index 0000000000..e0e56efcf3 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_annotstorage_msg_late @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_annotstorage_msg_late +{ + my ($self) = @_; + + xlog $self, "test increasing usage of the X-ANNOTATION-STORAGE quota"; + xlog $self, "resource as per-message annotations are added"; + + $self->_set_quotaroot('user.cassandane'); + my $talk = $self->{store}->get_client(); + + $self->_check_no_quota(); + + $talk->create("INBOX.sub1") || die "Failed to create subfolder"; + $talk->create("INBOX.sub2") || die "Failed to create subfolder"; + + xlog $self, "make some messages to hang annotations on"; + my %expecteds = (); + my $expected = 0; + foreach my $folder ("INBOX", "INBOX.sub1", "INBOX.sub2") + { + $self->{store}->set_folder($folder); + $expecteds{$folder} = 0; + my $uid = 1; + for (1..5) + { + $self->make_message("Message $uid"); + + my $data = $self->make_random_data(10); + $talk->store('' . $uid, 'annotation', ['/comment', ['value.priv', { Quote => $data }]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $uid++; + $expecteds{$folder} += length($data); + $expected += length($data); + } + } + + $self->_set_limits($self->res_annot_storage => 100000); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "delete subfolder sub1"; + $talk->delete("INBOX.sub1") || die "Failed to delete subfolder"; + $expected -= delete($expecteds{"INBOX.sub1"}); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "delete messages in sub2"; + $talk->select("INBOX.sub2"); + $talk->store('1:*', '+flags', '(\\deleted)'); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $talk->expunge(); + + xlog $self, "X-ANNOTATION-STORAGE quota goes down immediately"; + $expected -= delete($expecteds{"INBOX.sub2"}); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + $self->run_delayed_expunge(); + $talk = $self->{store}->get_client(); + + xlog $self, "X-ANNOTATION-STORAGE quota should have been unchanged by expunge"; + $self->_check_usages($self->res_annot_storage => int($expected/1024)); + + xlog $self, "delete annotations on INBOX"; + $talk->select("INBOX"); + $talk->store('1:*', 'annotation', ['/comment', ['value.priv', undef]]); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + $expected -= delete($expecteds{"INBOX"}); + $self->assert_num_equals(0, $expected); + $self->_check_usages($self->res_annot_storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_message b/cassandane/tiny-tests/Quota/using_message new file mode 100644 index 0000000000..070af3d169 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_message @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_message +{ + my ($self) = @_; + + xlog $self, "test increasing usage of the MESSAGE quota resource as messages are added"; + + $self->_set_quotaroot('user.cassandane'); + my $talk = $self->{store}->get_client(); + + xlog $self, "set ourselves a basic limit"; + $self->_set_limits(message => 50000); + $self->_check_usages(message => 0); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + # append some messages + my %expecteds = (); + my $expected = 0; + foreach my $folder ("INBOX", "INBOX.sub") + { + $expecteds{$folder} = 0; + $self->{store}->set_folder($folder); + + for (1..10) + { + my $msg = $self->make_message("Message $_"); + $expecteds{$folder}++; + $expected++; + $self->_check_usages(message => $expected); + } + } + + # delete subfolder + $talk->delete("INBOX.sub") || die "Failed to delete subfolder"; + $expected -= $expecteds{"INBOX.sub"}; + $self->_check_usages(message => $expected); + + # delete messages + $talk->select("INBOX"); + $talk->store('1:*', '+flags', '(\\deleted)'); + $talk->close(); + $expected -= delete($expecteds{"INBOX"}); + $self->assert_num_equals(0, $expected); + $self->_check_usages(message => $expected); +} diff --git a/cassandane/tiny-tests/Quota/using_message_late b/cassandane/tiny-tests/Quota/using_message_late new file mode 100644 index 0000000000..7611140b7f --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_message_late @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_message_late +{ + my ($self) = @_; + + xlog $self, "test setting MESSAGE quota resource after messages are added"; + + $self->_set_quotaroot('user.cassandane'); + my $talk = $self->{store}->get_client(); + $self->_check_no_quota(); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + # append some messages + my %expecteds = (); + my $expected = 0; + foreach my $folder ("INBOX", "INBOX.sub") + { + $expecteds{$folder} = 0; + $self->{store}->set_folder($folder); + + for (1..10) + { + my $msg = $self->make_message("Message $_"); + $expecteds{$folder}++; + $expected++; + } + } + + $self->_set_limits(message => 50000); + $self->_check_usages(message => $expected); + + # delete subfolder + $talk->delete("INBOX.sub") || die "Failed to delete subfolder"; + $expected -= $expecteds{"INBOX.sub"}; + $self->_check_usages(message => $expected); + + # delete messages + $talk->select("INBOX"); + $talk->store('1:*', '+flags', '(\\deleted)'); + $talk->close(); + $expected -= delete($expecteds{"INBOX"}); + $self->assert_num_equals(0, $expected); + $self->_check_usages(message => $expected); +} diff --git a/cassandane/tiny-tests/Quota/using_storage b/cassandane/tiny-tests/Quota/using_storage new file mode 100644 index 0000000000..e968995a60 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_storage @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_storage +{ + my ($self) = @_; + + xlog $self, "test increasing usage of the STORAGE quota resource as messages are added"; + $self->_set_quotaroot('user.cassandane'); + xlog $self, "set ourselves a basic limit"; + $self->_set_limits(storage => 100000); + $self->_check_usages(storage => 0); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + # append some messages + my %expecteds = (); + my $expected = 0; + foreach my $folder ("INBOX", "INBOX.sub") + { + $expecteds{$folder} = 0; + $self->{store}->set_folder($folder); + + for (1..10) + { + my $msg = $self->make_message("Message $_", + extra_lines => 10 + rand(5000)); + my $len = length($msg->as_string()); + $expecteds{$folder} += $len; + $expected += $len; + xlog $self, "added $len bytes of message"; + $self->_check_usages(storage => int($expected/1024)); + } + } + + # delete subfolder + $talk->delete("INBOX.sub") || die "Failed to delete subfolder"; + $expected -= delete($expecteds{"INBOX.sub"}); + $self->_check_usages(storage => int($expected/1024)); + $self->_check_smmap('cassandane', 'OK'); + + # delete messages + $talk->select("INBOX"); + $talk->store('1:*', '+flags', '(\\deleted)'); + $talk->close(); + $expected -= delete($expecteds{"INBOX"}); + $self->assert_num_equals(0, $expected); + $self->_check_usages(storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Quota/using_storage_late b/cassandane/tiny-tests/Quota/using_storage_late new file mode 100644 index 0000000000..8b5cccd234 --- /dev/null +++ b/cassandane/tiny-tests/Quota/using_storage_late @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_using_storage_late +{ + my ($self) = @_; + + xlog $self, "test setting STORAGE quota resource after messages are added"; + + $self->_set_quotaroot('user.cassandane'); + $self->_check_no_quota(); + my $talk = $self->{store}->get_client(); + + $talk->create("INBOX.sub") || die "Failed to create subfolder"; + + # append some messages + my %expecteds = (); + my $expected = 0; + foreach my $folder ("INBOX", "INBOX.sub") + { + $expecteds{$folder} = 0; + $self->{store}->set_folder($folder); + + for (1..10) + { + my $msg = $self->make_message("Message $_", + extra_lines => 10 + rand(5000)); + my $len = length($msg->as_string()); + $expecteds{$folder} += $len; + $expected += $len; + xlog $self, "added $len bytes of message"; + } + } + + $self->_set_limits(storage => 100000); + $self->_check_usages(storage => int($expected/1024)); + $self->_check_smmap('cassandane', 'OK'); + + # delete subfolder + $talk->delete("INBOX.sub") || die "Failed to delete subfolder"; + $expected -= delete($expecteds{"INBOX.sub"}); + $self->_check_usages(storage => int($expected/1024)); + + # delete messages + $talk->select("INBOX"); + $talk->store('1:*', '+flags', '(\\deleted)'); + $talk->close(); + $expected -= delete($expecteds{"INBOX"}); + $self->assert_num_equals(0, $expected); + $self->_check_usages(storage => int($expected/1024)); +} diff --git a/cassandane/tiny-tests/Replication/alternate_globalannots b/cassandane/tiny-tests/Replication/alternate_globalannots new file mode 100644 index 0000000000..7ee8206d2c --- /dev/null +++ b/cassandane/tiny-tests/Replication/alternate_globalannots @@ -0,0 +1,24 @@ +#!perl +use Cassandane::Tiny; + +# trying to reproduce error reported in https://git.cyrus.foundation/T228 +sub test_alternate_globalannots + :NoStartInstances :needs_component_replication +{ + my ($self) = @_; + + # first, set a different annotation_db_path on the master server + my $annotation_db_path = $self->{instance}->get_basedir() + . "/conf/non-default-annotations.db"; + $self->{instance}->{config}->set('annotation_db_path' => $annotation_db_path); + + # now we can start the instances + $self->_start_instances(); + + # A replication will automatically occur when the instances are started, + # in order to make sure the cassandane user exists on both hosts. + # So if we get here without crashing, replication works. + xlog $self, "initial replication was successful"; + + $self->assert(1); +} diff --git a/cassandane/tiny-tests/Replication/append b/cassandane/tiny-tests/Replication/append new file mode 100644 index 0000000000..3b561b10cd --- /dev/null +++ b/cassandane/tiny-tests/Replication/append @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of messages APPENDed to the master +# +sub test_append + :needs_component_replication +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + xlog $self, "generating messages A..D"; + my %exp; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{B} = $self->make_message("Message B", store => $master_store); + $exp{C} = $self->make_message("Message C", store => $master_store); + $exp{D} = $self->make_message("Message D", store => $master_store); + + xlog $self, "Before replication, the master should have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "Before replication, the replica should have no messages"; + $self->check_messages({}, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, the master should still have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should now have all four messages"; + $self->check_messages(\%exp, store => $replica_store); +} diff --git a/cassandane/tiny-tests/Replication/appendmulti_diskfull b/cassandane/tiny-tests/Replication/appendmulti_diskfull new file mode 100644 index 0000000000..4d20d3add6 --- /dev/null +++ b/cassandane/tiny-tests/Replication/appendmulti_diskfull @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +# +# Test handling of replication when append fails due to disk error +# +sub test_appendmulti_diskfull + :CSyncReplication :NoStartInstances :min_version_3_5 + :needs_component_replication +{ + my ($self) = @_; + + my $canary = << 'EOF'; +From: Fred J. Bloggs +To: Sarah Jane Smith +Subject: this is just to say +X-Cassandane-Unique: canary + +I have eaten +the canary +that was in +the coal mine + +and which +you were probably +saving +for emergencies + +Forgive me +it was delicious +so tweet +and so coaled +EOF + $canary =~ s/\n/\r\n/g; + my $canaryguid = "f2eaa91974c50ec3cfb530014362e92efb06a9ba"; + + $self->{replica}->{config}->set('debug_writefail_guid' => $canaryguid); + $self->_start_instances(); + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my %exp; + $exp{1} = $self->make_message("msg 1", uid => 1, store => $master_store); + $exp{2} = $self->make_message("msg 2", uid => 2, store => $master_store); + $exp{3} = Cassandane::Message->new(raw => $canary, + attrs => { UID => 3 }), + $self->_save_message($exp{3}, $master_store); + $exp{4} = $self->make_message("msg 4", uid => 4, store => $master_store); + + xlog $self, "messages should be on master only"; + $self->check_messages(\%exp, keyed_on => 'uid', store => $master_store); + $self->check_messages({}, keyed_on => 'uid', store => $replica_store); + + xlog $self, "running replication..."; + eval { + $self->run_replication(); + }; + my $e = $@; + + # sync_client should have exited with an error + $self->assert($e); + $self->assert_matches(qr/child\sprocess\s + \(binary\ssync_client\spid\s\d+\)\s + exited\swith\scode/x, + $e->to_string()); + + # sync_client should have logged the BAD response + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: received bad response/); + + if ($self->{instance}->{have_syslog_replacement}) { + # sync server should have logged the write error + my @lines = $self->{replica}->getsyslog(); + $self->assert_matches(qr{IOERROR:\sfailed\sto\supload\sfile + (?:\s\(simulated\))?:\sguid=<$canaryguid> + }x, + "@lines"); + + # contents of message 4 should not appear on the wire (or logs) as + # junk commands! we need sync_server specifically for this (and not + # a replication-aware imapd), because only sync_server logs bad + # commands. + $self->assert_does_not_match(qr/IOERROR:\sreceived\sbad\scommand:\s + command=/x, + "@lines"); + } +} diff --git a/cassandane/tiny-tests/Replication/appendone_diskfull b/cassandane/tiny-tests/Replication/appendone_diskfull new file mode 100644 index 0000000000..5deb2374d6 --- /dev/null +++ b/cassandane/tiny-tests/Replication/appendone_diskfull @@ -0,0 +1,73 @@ +#!perl +use Cassandane::Tiny; + +# +# Test handling of replication when append fails due to disk error +# +sub test_appendone_diskfull + :NoStartInstances :min_version_3_5 :needs_component_replication +{ + my ($self) = @_; + + my $canary = << 'EOF'; +From: Fred J. Bloggs +To: Sarah Jane Smith +Subject: this is just to say +X-Cassandane-Unique: canary + +I have eaten +the canary +that was in +the coal mine + +and which +you were probably +saving +for emergencies + +Forgive me +it was delicious +so tweet +and so coaled +EOF + $canary =~ s/\n/\r\n/g; + my $canaryguid = "f2eaa91974c50ec3cfb530014362e92efb06a9ba"; + + $self->{replica}->{config}->set('debug_writefail_guid' => $canaryguid); + $self->_start_instances(); + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my %exp; + $exp{1} = Cassandane::Message->new(raw => $canary, + attrs => { UID => 1 }), + $self->_save_message($exp{1}, $master_store); + + xlog $self, "message should be on master only"; + $self->check_messages(\%exp, keyed_on => 'uid', store => $master_store); + $self->check_messages({}, keyed_on => 'uid', store => $replica_store); + + xlog $self, "running replication..."; + eval { + $self->run_replication(); + }; + my $e = $@; + + # sync_client should have exited with an error + $self->assert($e); + $self->assert_matches(qr/child\sprocess\s + \(binary\ssync_client\spid\s\d+\)\s + exited\swith\scode/x, + $e->to_string()); + + # sync_client should have logged the BAD response + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: received bad response/); + + # sync server should have logged the write error + $self->assert_syslog_matches($self->{replica}, + qr{IOERROR:\sfailed\sto\supload\sfile + (?:\s\(simulated\))?:\sguid=<$canaryguid> + }x); +} diff --git a/cassandane/tiny-tests/Replication/clean_remote_shutdown_while_rolling b/cassandane/tiny-tests/Replication/clean_remote_shutdown_while_rolling new file mode 100644 index 0000000000..045fca3322 --- /dev/null +++ b/cassandane/tiny-tests/Replication/clean_remote_shutdown_while_rolling @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_clean_remote_shutdown_while_rolling + :CSyncReplication :SyncLog :min_version_3_5 + :needs_component_replication +{ + my ($self) = @_; + + my $mtalk = $self->{master_store}->get_client(); + + $mtalk->create('INBOX.a.b'); + + # get a rolling sync_client started + # XXX can't just run_replication bc it expects sync_client to finish + my @cmd = qw( sync_client -v -v -o -R ); + my $sync_client_pid = $self->{instance}->run_command( + { + cyrus => 1, + background => 1, + handlers => { + exited_abnormally => sub { + my ($child, $code) = @_; + xlog "child process $child->{binary}\[$child->{pid}\]" + . " exited with code $code"; + return $code; + }, + }, + }, + @cmd); + + # make sure sync_client has time to connect in the first place + sleep 3; + + # stop the replica + $self->{replica}->stop(); + + # make more changes on master + $mtalk = $self->{master_store}->get_client(); + $mtalk->create('INBOX.a.b.c'); + + # give sync_client another moment to wake up and see the new log entry + sleep 3; + + # by now it should have noticed the disconnected replica, and either + # shut itself down cleanly, or IOERRORed + + # it should have exited already, but signal it if it hasn't, and + # do the cleanup + my $ec = $self->{instance}->stop_command($sync_client_pid); + + # if it exited itself, this will be zero. if it hung around until + # signalled, 75. + $self->assert_equals(0, $ec); + + # should not be errors in syslog! +} diff --git a/cassandane/tiny-tests/Replication/connect_once b/cassandane/tiny-tests/Replication/connect_once new file mode 100644 index 0000000000..36e0ba4bad --- /dev/null +++ b/cassandane/tiny-tests/Replication/connect_once @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_connect_once + :CSyncReplication :min_version_3_9 + :needs_component_replication +{ + my ($self) = @_; + + # stop the replica + $self->{replica}->stop(); + + # start a sync_client, which won't be able to connect + # n.b. can't just run_replication bc it expects sync_client to finish + my $errfile = "$self->{instance}->{basedir}/stderr.out"; + my @cmd = qw( sync_client -v -v -o -m user.cassandane ); + my $sync_client_pid = $self->{instance}->run_command( + { + cyrus => 1, + background => 1, + handlers => { + exited_abnormally => sub { + my ($child, $code) = @_; + xlog "child process $child->{binary}\[$child->{pid}\]" + . " exited with code $code"; + return $code; + }, + }, + redirects => { stderr => $errfile }, + }, + @cmd); + + # give sync_client time to fail to connect + sleep 10; + + # clean up whatever's left of it + my $ec = $self->{instance}->stop_command($sync_client_pid); + + # if it exited itself due to being unable to connect, this will be 1. + # if it was shut down by stop_command, 75 + $self->assert_not_equals(75, $ec); + $self->assert_equals(1, $ec); + + my $output = slurp_file($errfile); + $self->assert_matches(qr/Can not connect to server/, $output); + $self->assert_does_not_match(qr/retrying in \d+ seconds/, $output); +} diff --git a/cassandane/tiny-tests/Replication/delete_longname b/cassandane/tiny-tests/Replication/delete_longname new file mode 100644 index 0000000000..a02a2fa635 --- /dev/null +++ b/cassandane/tiny-tests/Replication/delete_longname @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_delete_longname + :AllowMoves :Replication :SyncLog :DelayedDelete :min_version_3_3 + :needs_component_replication +{ + my ($self) = @_; + + my $mtalk = $self->{master_store}->get_client(); + + #define MAX_MAILBOX_NAME 490 + my $name = "INBOX.this is a really long name 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1.2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2.3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3.foo"; + my ($success) = $mtalk->create($name); + die "Failed to create" unless $success; + + # replicate and check initial state + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + # reconnect + $mtalk = $self->{master_store}->get_client(); + + $mtalk->delete($name); + + $self->run_replication(rolling => 1, inputfile => $synclogfname) if -f $synclogfname; + + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/Replication/full_rename b/cassandane/tiny-tests/Replication/full_rename new file mode 100644 index 0000000000..f7fd016d85 --- /dev/null +++ b/cassandane/tiny-tests/Replication/full_rename @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of mailbox only after a rename +# +sub test_full_rename + :NoAltNameSpace :needs_component_replication :AllowMoves :Replication :SyncLog :DelayedDelete +{ + my ($self) = @_; + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + xlog $self, "SYNC LOG FNAME $synclogfname"; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $mastertalk = $master_store->get_client(); + my $replicatalk = $replica_store->get_client(); + + $mastertalk->create("INBOX.sub"); + $master_store->set_folder("INBOX.sub"); + + xlog $self, "append some messages"; + my %exp; + my $N = 1; + for (1..$N) + { + my $msg = $self->make_message("Message $_", store => $master_store); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp, $master_store); + + xlog $self, "run initial replication"; + $self->run_replication(); + #$self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + $self->check_replication('cassandane'); + + xlog $self, "rename user"; + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->rename("user.cassandane", "user.dest"); + + $self->{instance}->getsyslog(); + $self->{replica}->getsyslog(); + + $self->run_replication(user => 'dest'); + $self->check_replication('dest'); + + xlog $self, "Rename again"; + $admintalk = $self->{adminstore}->get_client(); + $admintalk->rename("user.dest", "user.cassandane"); + + # replication works again + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/Replication/intermediate_rename b/cassandane/tiny-tests/Replication/intermediate_rename new file mode 100644 index 0000000000..a8fe8bcec8 --- /dev/null +++ b/cassandane/tiny-tests/Replication/intermediate_rename @@ -0,0 +1,29 @@ +#!perl +use Cassandane::Tiny; + +sub test_intermediate_rename + :AllowMoves :Replication :SyncLog :DelayedDelete :min_version_3_3 + :needs_component_replication +{ + my ($self) = @_; + + my $mtalk = $self->{master_store}->get_client(); + + $mtalk->create('INBOX.a.b'); + + # replicate and check initial state + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + # reconnect + $mtalk = $self->{master_store}->get_client(); + + $mtalk->create('INBOX.a'); + $mtalk->rename('INBOX.a', 'INBOX.new'); + + #$self->run_replication(rolling => 1, inputfile => $synclogfname); + $self->run_replication(); + + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/Replication/intermediate_upgrade b/cassandane/tiny-tests/Replication/intermediate_upgrade new file mode 100644 index 0000000000..3d42995aec --- /dev/null +++ b/cassandane/tiny-tests/Replication/intermediate_upgrade @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_intermediate_upgrade + :AllowMoves :Replication :SyncLog :DelayedDelete :min_version_3_3 + :needs_component_replication +{ + my ($self) = @_; + + my $mtalk = $self->{master_store}->get_client(); + + $mtalk->create('INBOX.a.b'); + + # replicate and check initial state + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + $self->run_replication(rolling => 1, inputfile => $synclogfname); + unlink($synclogfname); + + # reconnect + $mtalk = $self->{master_store}->get_client(); + + $mtalk->create('INBOX.a'); + + $self->run_replication(rolling => 1, inputfile => $synclogfname) if -f $synclogfname; + + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/Replication/mboxgroups b/cassandane/tiny-tests/Replication/mboxgroups new file mode 100644 index 0000000000..e3cd1c2c1a --- /dev/null +++ b/cassandane/tiny-tests/Replication/mboxgroups @@ -0,0 +1,116 @@ +#!perl +use Cassandane::Tiny; + +sub test_mboxgroups + :needs_component_replication :Mboxgroups :ReverseACLs +{ + my ($self) = @_; + + my $user = 'brandnew'; + $self->{instance}->create_user($user); + + my $mastersvc = $self->{instance}->get_service('imap'); + my $masterstore = $mastersvc->create_store(username => $user); + my $mastertalk = $masterstore->get_client(); + + my $adminstore = $mastersvc->create_store(username => 'admin'); + my $admintalk = $adminstore->get_client(); + + $admintalk->_imap_cmd('SETUSERGROUP', 0, '', 'cassandane', 'group:shared'); + $admintalk->_imap_cmd('SETUSERGROUP', 0, '', 'brandnew', 'group:shared'); + + $admintalk->setacl("user.cassandane", "group:shared", "lrs"); + + $mastertalk->create("INBOX.Test") || die; + $mastertalk->create("INBOX.Test.Sub") || die; + $mastertalk->create("INBOX.Test Foo") || die; + + my $ldata = $mastertalk->list("", "*"); + $self->assert_deep_equals($ldata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX' + ], + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX.Test' + ], + [ + [ + '\\HasNoChildren' + ], + '.', + 'INBOX.Test.Sub' + ], + [ + [ + '\\HasNoChildren' + ], + '.', + 'INBOX.Test Foo' + ], + [ + [ + '\\HasNoChildren' + ], + '.', + 'Other Users.cassandane' + ], + ]); + + # run replication + $self->run_replication(user => $user); + $self->run_replication(user => 'cassandane'); + $self->check_replication($user); + $self->check_replication('cassandane'); + + # verify replica store can see folder + my $replicasvc = $self->{replica}->get_service('imap'); + my $replicastore = $replicasvc->create_store(username => $user); + my $replicatalk = $replicastore->get_client(); + + my $rdata = $replicatalk->list("", "*"); + $self->assert_deep_equals($rdata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX' + ], + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX.Test' + ], + [ + [ + '\\HasNoChildren' + ], + '.', + 'INBOX.Test.Sub' + ], + [ + [ + '\\HasNoChildren' + ], + '.', + 'INBOX.Test Foo' + ], + [ + [ + '\\HasNoChildren' + ], + '.', + 'Other Users.cassandane' + ], + ]); +} diff --git a/cassandane/tiny-tests/Replication/replication_mailbox_new_enough b/cassandane/tiny-tests/Replication/replication_mailbox_new_enough new file mode 100644 index 0000000000..9fe886d8f4 --- /dev/null +++ b/cassandane/tiny-tests/Replication/replication_mailbox_new_enough @@ -0,0 +1,20 @@ +#!perl +use Cassandane::Tiny; + +# this test is too tricky to get working on uuid mailboxes +sub test_replication_mailbox_new_enough + :max_version_3_4 :needs_component_replication +{ + my ($self) = @_; + + my $user = 'cassandane'; + my $exit_code = 0; + + # successfully replicate a mailbox new enough to contain guids + my $mailbox10 = $self->{instance}->install_old_mailbox($user, 10); + $self->run_replication(mailbox => $mailbox10); + + # successfully replicate a mailbox new enough to contain guids + my $mailbox12 = $self->{instance}->install_old_mailbox($user, 12); + $self->run_replication(mailbox => $mailbox12); +} diff --git a/cassandane/tiny-tests/Replication/replication_mailbox_too_old b/cassandane/tiny-tests/Replication/replication_mailbox_too_old new file mode 100644 index 0000000000..24e35e2d4a --- /dev/null +++ b/cassandane/tiny-tests/Replication/replication_mailbox_too_old @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +# this test is too tricky to get working on uuid mailboxes +sub test_replication_mailbox_too_old + :max_version_3_4 :needs_component_replication +{ + my ($self) = @_; + + my $user = 'cassandane'; + my $exit_code; + + my $master_instance = $self->{instance}; + my $replica_instance = $self->{replica}; + + # logs will all be in the master instance, because that's where + # sync_client runs from. + my $log_base = "$master_instance->{basedir}/$self->{_name}"; + + # add a version9 mailbox to the replica only, and try to replicate. + # replication will fail, because the initial GET USER will barf + # upon encountering the old mailbox. + $replica_instance->install_old_mailbox($user, 9); + my $log_firstreject = "$log_base-firstreject.stderr"; + $exit_code = 0; + $self->run_replication( + user => $user, + handlers => { + exited_abnormally => sub { (undef, $exit_code) = @_; }, + }, + redirects => { stderr => $log_firstreject }, + ); + $self->assert_equals(1, $exit_code); + $self->assert(qr/USER received NO response: IMAP_MAILBOX_NOTSUPPORTED/, + slurp_file($log_firstreject)); + + # add the version9 mailbox to the master, and try to replicate. + # mailbox will be found and rejected locally, and replication will + # fail. + $master_instance->install_old_mailbox($user, 9); + my $log_localreject = "$log_base-localreject.stderr"; + $exit_code = 0; + $self->run_replication( + user => $user, + handlers => { + exited_abnormally => sub { (undef, $exit_code) = @_; }, + }, + redirects => { stderr => $log_localreject }, + ); + $self->assert_equals(1, $exit_code); + $self->assert(qr/Operation is not supported on mailbox/, + slurp_file($log_localreject)); + + # upgrade the version9 mailbox on the master, and try to replicate. + # replication will fail, because the initial GET USER will barf + # upon encountering the old mailbox. + $master_instance->run_command({ cyrus => 1 }, qw(reconstruct -V max -u), $user); + my $log_remotereject = "$log_base-remotereject.stderr"; + $exit_code = 0; + $self->run_replication( + user => $user, + handlers => { + exited_abnormally => sub { (undef, $exit_code) = @_; }, + }, + redirects => { stderr => $log_remotereject }, + ); + $self->assert_equals(1, $exit_code); + $self->assert(qr/USER received NO response: IMAP_MAILBOX_NOTSUPPORTED/, + slurp_file($log_remotereject)); + + # upgrade the version9 mailbox on the replica, and try to replicate. + # replication will succeed because both ends are capable of replication. + $replica_instance->run_command({ cyrus => 1 }, qw(reconstruct -V max -u), $user); + $exit_code = 0; + $self->run_replication( + user => $user, + handlers => { + exited_abnormally => sub { (undef, $exit_code) = @_; }, + }, + ); + $self->assert_equals(0, $exit_code); +} diff --git a/cassandane/tiny-tests/Replication/replication_repair_zero_msgs b/cassandane/tiny-tests/Replication/replication_repair_zero_msgs new file mode 100644 index 0000000000..0577f21c16 --- /dev/null +++ b/cassandane/tiny-tests/Replication/replication_repair_zero_msgs @@ -0,0 +1,24 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication_repair_zero_msgs + :needs_component_replication +{ + my ($self) = @_; + + my $mastertalk = $self->{master_store}->get_client(); + my $replicatalk = $self->{replica_store}->get_client(); + + # raise the modseq on the master end + $mastertalk->setmetadata("INBOX", "/shared/comment", "foo"); + $mastertalk->setmetadata("INBOX", "/shared/comment", ""); + $mastertalk->setmetadata("INBOX", "/shared/comment", "foo"); + $mastertalk->setmetadata("INBOX", "/shared/comment", ""); + + my $msg = $self->make_message("to be deleted", store => $self->{replica_store}); + + $replicatalk->store($msg->{attrs}->{uid}, '+flags', '(\\deleted)'); + $replicatalk->expunge(); + + $self->run_replication(user => 'cassandane'); +} diff --git a/cassandane/tiny-tests/Replication/replication_with_modified_seen_flag b/cassandane/tiny-tests/Replication/replication_with_modified_seen_flag new file mode 100644 index 0000000000..63692fcaa8 --- /dev/null +++ b/cassandane/tiny-tests/Replication/replication_with_modified_seen_flag @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_replication_with_modified_seen_flag + :needs_component_replication +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + $master_store->set_fetch_attributes(qw(uid flags)); + + my $replica_store = $self->{replica_store}; + $replica_store->set_fetch_attributes(qw(uid flags)); + + + xlog $self, "generating messages A & B"; + my %exp; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{A}->set_attributes(id => 1, uid => 1, flags => []); + $exp{B} = $self->make_message("Message B", store => $master_store); + $exp{B}->set_attributes(id => 2, uid => 2, flags => []); + + xlog $self, "Before replication: Ensure that master has two messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "Before replication: Ensure that replica has no messages"; + $self->check_messages({}, store => $replica_store); + + xlog $self, "Run Replication!"; + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication: Ensure that master has two messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication: Ensure replica now has two messages"; + $self->check_messages(\%exp, store => $replica_store); + + xlog $self, "Set \\Seen on Message B"; + my $mtalk = $master_store->get_client(); + $master_store->_select(); + $mtalk->store('2', '+flags', '(\\Seen)'); + $exp{B}->set_attributes(flags => ['\\Seen']); + $mtalk->unselect(); + xlog $self, "Before replication: Ensure that master has two messages and flags are set"; + $self->check_messages(\%exp, store => $master_store); + + xlog $self, "Before replication: Ensure that replica does not have the \\Seen flag set on Message B"; + my $rtalk = $replica_store->get_client(); + $replica_store->_select(); + my $res = $rtalk->fetch("2", "(flags)"); + my $flags = $res->{2}->{flags}; + $self->assert(not grep { $_ eq "\\Seen"} @$flags); + + xlog $self, "Run Replication!"; + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication: Ensure that replica does have the \\Seen flag set on Message B"; + $rtalk = $replica_store->get_client(); + $replica_store->_select(); + $res = $rtalk->fetch("2", "(flags)"); + $flags = $res->{2}->{flags}; + $self->assert(grep { $_ eq "\\Seen"} @$flags); + + xlog $self, "Clear \\Seen flag on Message B on master."; + $mtalk = $master_store->get_client(); + $master_store->_select(); + $mtalk->store('2', '-flags', '(\\Seen)'); + + xlog $self, "Run Replication!"; + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication: Check both master and replica has no \\Seen flag on Message C"; + $mtalk = $master_store->get_client(); + $master_store->_select(); + $res = $mtalk->fetch("2", "(flags)"); + $flags = $res->{2}->{flags}; + $self->assert(not grep { $_ eq "\\Seen"} @$flags); + + $rtalk = $replica_store->get_client(); + $replica_store->_select(); + $res = $rtalk->fetch("3", "(flags)"); + $flags = $res->{3}->{flags}; + $self->assert(not grep { $_ eq "\\Seen"} @$flags); +} diff --git a/cassandane/tiny-tests/Replication/reset_on_master b/cassandane/tiny-tests/Replication/reset_on_master new file mode 100644 index 0000000000..5b7d3980ad --- /dev/null +++ b/cassandane/tiny-tests/Replication/reset_on_master @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +# this is testing a bug where DELETED namespace lookup in mboxlist_mboxtree +# wasn't correctly looking only for children of that name, so it would try +# to delete the wrong user's mailbox. +sub test_reset_on_master + :DelayedDelete :min_version_3_3 :needs_component_replication +{ + my ($self) = @_; + $self->{instance}->create_user("user2"); + + my $mastersvc = $self->{instance}->get_service('imap'); + my $astore = $mastersvc->create_store(username => "user2"); + my $atalk = $astore->get_client(); + + xlog "Creating some users with some deleted mailboxes"; + $atalk->create("INBOX.hi"); + $atalk->create("INBOX.no"); + $atalk->delete("INBOX.hi"); + + $self->run_replication(user => "user2"); + + # reset user2 + $self->{instance}->run_command({cyrus => 1}, 'sync_reset', '-f', "user2"); + + my $file = $self->{instance}->{basedir} . "/sync.log"; + open(FH, ">", $file); + print FH "UNMAILBOX user.user2.hi\n"; + print FH "MAILBOX user.user2.hi\n"; + print FH "UNMAILBOX user.user2.no\n"; + print FH "MAILBOX user.user2.no\n"; + print FH "MAILBOX user.cassandane\n"; + close(FH); + + $self->{instance}->getsyslog(); + $self->{replica}->getsyslog(); + xlog $self, "Run replication from a file with just the mailbox name in it"; + $self->run_replication(inputfile => $file, rolling => 1); + + my $pattern = qr{ + \bSYNCNOTICE:\sattempt\sto\sUNMAILBOX\swithout\sa\stombstone + (?: \suser\.user2\.no\b + | :\smailbox= + ) + }x; + $self->assert_syslog_matches($self->{instance}, $pattern); +} diff --git a/cassandane/tiny-tests/Replication/rolling_retry_wait_limit b/cassandane/tiny-tests/Replication/rolling_retry_wait_limit new file mode 100644 index 0000000000..7786feadd9 --- /dev/null +++ b/cassandane/tiny-tests/Replication/rolling_retry_wait_limit @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_rolling_retry_wait_limit + :CSyncReplication :NoStartInstances :min_version_3_5 + :needs_component_replication +{ + my ($self) = @_; + my $maxwait = 20; + + $self->{instance}->{config}->set( + 'sync_log' => 1, + 'sync_reconnect_maxwait' => "${maxwait}s", + ); + $self->_start_instances(); + + # stop the replica + $self->{replica}->stop(); + + # get a rolling sync_client started, which won't be able to connect + # XXX can't just run_replication bc it expects sync_client to finish + my $errfile = "$self->{instance}->{basedir}/stderr.out"; + my @cmd = qw( sync_client -v -v -R ); + my $sync_client_pid = $self->{instance}->run_command( + { + cyrus => 1, + background => 1, + handlers => { + exited_abnormally => sub { + my ($child, $code) = @_; + xlog "child process $child->{binary}\[$child->{pid}\]" + . " exited with code $code"; + return $code; + }, + }, + redirects => { stderr => $errfile }, + }, + @cmd); + + # wait around for a while to give sync_client time to go through its + # reconnect loop a few times. first will be 15, then 20, then 20, + # then 20 (but we'll kill it 5s in) + sleep 60; + + # grant mercy + my $ec = $self->{instance}->stop_command($sync_client_pid); + + # if it exited itself, this will be zero. if it hung around until + # signalled, 75. + $self->assert_equals(75, $ec); + + # check stderr for "retrying in ... seconds" lines, making sure none + # exceed our limit + my $output = slurp_file($errfile); + my @waits = $output =~ m/retrying in (\d+) seconds/g; + $self->assert_num_lte($maxwait, $_) for @waits; + $self->assert_deep_equals([ 15, 20, 20, 20 ], \@waits); +} diff --git a/cassandane/tiny-tests/Replication/shared_folder b/cassandane/tiny-tests/Replication/shared_folder new file mode 100644 index 0000000000..e4e73e937d --- /dev/null +++ b/cassandane/tiny-tests/Replication/shared_folder @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of messages APPENDed to the master +# +sub test_shared_folder + :Replication :SyncLog :needs_component_replication :NoAltNamespace +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $mastersvc = $self->{instance}->get_service('imap'); + my $adminstore = $mastersvc->create_store(username => 'admin'); + my $admintalk = $adminstore->get_client(); + + my $synclogfname = "$self->{instance}->{basedir}/conf/sync/log"; + + xlog $self, "creating shared folder"; + + $admintalk->create('shared.folder'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $admintalk->setacl('shared.folder', 'cassandane' => 'lrswipkxtecdn'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + + $self->run_replication(rolling => 1, inputfile => $synclogfname); + + $master_store->set_folder('shared.folder'); + $replica_store->set_folder('shared.folder'); + + xlog $self, "generating messages A..D"; + my %exp; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{B} = $self->make_message("Message B", store => $master_store); + $exp{C} = $self->make_message("Message C", store => $master_store); + $exp{D} = $self->make_message("Message D", store => $master_store); + + xlog $self, "Before replication, the master should have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "Before replication, the replica should have no messages"; + $self->check_messages({}, store => $replica_store); + + $self->run_replication(rolling => 1, inputfile => $synclogfname); + + xlog $self, "After replication, the master should still have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should now have all four messages"; + $self->check_messages(\%exp, store => $replica_store); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication b/cassandane/tiny-tests/Replication/sieve_replication new file mode 100644 index 0000000000..c26a0bedc2 --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication + :needs_component_sieve :needs_component_replication +{ + my ($self) = @_; + + my $user = 'cassandane'; + my $scriptname = 'test1'; + my $scriptcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + + # first, verify that sieve script does not exist on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install sieve script on master + $self->{instance}->install_sieve_script($scriptcontent, name=>$scriptname); + + # then, verify that sieve script exists on master but not on replica + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, run replication, + $self->run_replication(); + $self->check_replication('cassandane'); + + # then, verify that sieve script exists on both master and replica + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_delete_unactivate b/cassandane/tiny-tests/Replication/sieve_replication_delete_unactivate new file mode 100644 index 0000000000..fb64246f8c --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_delete_unactivate @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_delete_unactivate + :needs_component_sieve :needs_component_replication +{ + my ($self) = @_; + + my $user = 'cassandane'; + my $scriptname = 'test1'; + my $scriptcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + + # first, verify that sieve script does not exist on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install sieve script on replica only + $self->{replica}->install_sieve_script($scriptcontent, name=>$scriptname); + + # then, verify that sieve script exists on replica only + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user, $scriptname); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); + + # then, run replication, + $self->run_replication(); + $self->check_replication('cassandane'); + + # then, verify that sieve script no longer exists on either + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_noactive($self->{instance}, $user, $scriptname); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 1); + $self->assert_sieve_noactive($self->{replica}, $user, $scriptname); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_delete_unactivate_unixhs b/cassandane/tiny-tests/Replication/sieve_replication_delete_unactivate_unixhs new file mode 100644 index 0000000000..b0095da1f4 --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_delete_unactivate_unixhs @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_delete_unactivate_unixhs + :needs_component_sieve :UnixHierarchySep + :needs_component_replication +{ + my ($self) = @_; + + my $user = 'some.body'; + my $scriptname = 'test1'; + my $scriptcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + $self->{instance}->create_user($user); + + # first, verify that sieve script does not exist on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install sieve script on replica only + $self->{replica}->install_sieve_script($scriptcontent, + name=>$scriptname, + username=>$user); + + # then, verify that sieve script exists on replica only + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user, $scriptname); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); + + # then, run replication, + $self->run_replication(user=>$user); + $self->check_replication($user); + + # then, verify that sieve script no longer exists on either + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_noactive($self->{instance}, $user, $scriptname); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 1); + $self->assert_sieve_noactive($self->{replica}, $user, $scriptname); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_different b/cassandane/tiny-tests/Replication/sieve_replication_different new file mode 100644 index 0000000000..23da3dc677 --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_different @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_different + :needs_component_sieve :needs_component_replication +{ + my ($self) = @_; + + my $user = 'cassandane'; + my $script1name = 'test1'; + my $script1content = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + + my $script2name = 'test2'; + my $script2content = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "more testing"; +} +EOF + + # first, verify that neither script exists on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $script1name, 0); + $self->assert_sieve_not_exists($self->{instance}, $user, $script2name, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $script1name, 0); + $self->assert_sieve_not_exists($self->{replica}, $user, $script2name, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install different sieve script on master and replica + $self->{instance}->install_sieve_script($script1content, name=>$script1name); + $self->{replica}->install_sieve_script($script2content, name=>$script2name); + + # then, verify that each sieve script exists on one only + $self->assert_sieve_exists($self->{instance}, $user, $script1name, 0); + $self->assert_sieve_active($self->{instance}, $user, $script1name); + $self->assert_sieve_not_exists($self->{instance}, $user, $script2name, 0); + + $self->assert_sieve_exists($self->{replica}, $user, $script2name, 0); + $self->assert_sieve_active($self->{replica}, $user, $script2name); + $self->assert_sieve_not_exists($self->{replica}, $user, $script1name, 0); + + # then, run replication, + # the one that exists on master only will be replicated + # the one that exists on replica only will be deleted + $self->run_replication(); + $self->check_replication('cassandane'); + + # then, verify that scripts are in expected state + $self->assert_sieve_exists($self->{instance}, $user, $script1name, 1); + $self->assert_sieve_active($self->{instance}, $user, $script1name); + $self->assert_sieve_not_exists($self->{instance}, $user, $script2name, 1); + + $self->assert_sieve_exists($self->{replica}, $user, $script1name, 1); + $self->assert_sieve_active($self->{replica}, $user, $script1name); + $self->assert_sieve_not_exists($self->{replica}, $user, $script2name, 1); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_different_unixhs b/cassandane/tiny-tests/Replication/sieve_replication_different_unixhs new file mode 100644 index 0000000000..6bb545c7eb --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_different_unixhs @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_different_unixhs + :needs_component_sieve :UnixHierarchySep + :needs_component_replication +{ + my ($self) = @_; + + my $user = 'some.body'; + my $script1name = 'test1'; + my $script1content = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + + my $script2name = 'test2'; + my $script2content = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "more testing"; +} +EOF + $self->{instance}->create_user($user); + + # first, verify that neither script exists on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $script1name, 0); + $self->assert_sieve_not_exists($self->{instance}, $user, $script2name, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $script1name, 0); + $self->assert_sieve_not_exists($self->{replica}, $user, $script2name, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install different sieve script on master and replica + $self->{instance}->install_sieve_script($script1content, + name=>$script1name, + username=>$user); + $self->{replica}->install_sieve_script($script2content, + name=>$script2name, + username=>$user); + + # then, verify that each sieve script exists on one only + $self->assert_sieve_exists($self->{instance}, $user, $script1name, 0); + $self->assert_sieve_active($self->{instance}, $user, $script1name); + $self->assert_sieve_not_exists($self->{instance}, $user, $script2name, 0); + + $self->assert_sieve_exists($self->{replica}, $user, $script2name, 0); + $self->assert_sieve_active($self->{replica}, $user, $script2name); + $self->assert_sieve_not_exists($self->{replica}, $user, $script1name, 0); + + # then, run replication, + # the one that exists on master only will be replicated + # the one that exists on replica only will be deleted + $self->run_replication(user=>$user); + $self->check_replication($user); + + # then, verify that scripts are in expected state + $self->assert_sieve_exists($self->{instance}, $user, $script1name, 1); + $self->assert_sieve_active($self->{instance}, $user, $script1name); + $self->assert_sieve_not_exists($self->{instance}, $user, $script2name, 1); + + $self->assert_sieve_exists($self->{replica}, $user, $script1name, 1); + $self->assert_sieve_active($self->{replica}, $user, $script1name); + $self->assert_sieve_not_exists($self->{replica}, $user, $script2name, 1); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_exists b/cassandane/tiny-tests/Replication/sieve_replication_exists new file mode 100644 index 0000000000..4998608134 --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_exists @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_exists + :needs_component_sieve :needs_component_replication +{ + my ($self) = @_; + + my $user = 'cassandane'; + my $scriptname = 'test1'; + my $scriptcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + + # first, verify that sieve script does not exist on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install sieve script on both master and replica + $self->{instance}->install_sieve_script($scriptcontent, name=>$scriptname); + $self->{replica}->install_sieve_script($scriptcontent, name=>$scriptname); + + # then, verify that sieve script exists on both + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); + + # then, run replication, + $self->run_replication(); + $self->check_replication('cassandane'); + + # then, verify that sieve script still exists on both master and replica + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_exists_unixhs b/cassandane/tiny-tests/Replication/sieve_replication_exists_unixhs new file mode 100644 index 0000000000..6b20ab1187 --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_exists_unixhs @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_exists_unixhs + :needs_component_sieve :UnixHierarchySep + :needs_component_replication +{ + my ($self) = @_; + + my $user = 'some.body'; + my $scriptname = 'test1'; + my $scriptcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + $self->{instance}->create_user($user); + + # first, verify that sieve script does not exist on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install sieve script on both master and replica + $self->{instance}->install_sieve_script($scriptcontent, + name=>$scriptname, + username=>$user); + $self->{replica}->install_sieve_script($scriptcontent, + name=>$scriptname, + username=>$user); + + # then, verify that sieve script exists on both + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); + + # then, run replication, + $self->run_replication(user=>$user); + $self->check_replication($user); + + # then, verify that sieve script still exists on both master and replica + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_stale b/cassandane/tiny-tests/Replication/sieve_replication_stale new file mode 100644 index 0000000000..939bfea49e --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_stale @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_stale + :needs_component_sieve :needs_component_replication +{ + my ($self) = @_; + + my $user = 'cassandane'; + my $scriptname = 'test1'; + my $scriptoldcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + + my $scriptnewcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "more testing"; +} +EOF + + # first, verify that script does not exist on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install "old" script on replica... + $self->{replica}->install_sieve_script($scriptoldcontent, name=>$scriptname); + + # ... and "new" script on master, a little later + sleep 2; + $self->{instance}->install_sieve_script($scriptnewcontent, name=>$scriptname); + + # then, verify that different sieve script content exists at each end + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + $self->assert_sieve_matches($self->{instance}, $user, $scriptname, + $scriptnewcontent); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); + $self->assert_sieve_matches($self->{replica}, $user, $scriptname, + $scriptoldcontent); + + # then, run replication, + # the one that exists on replica is different to and older than the one + # on master, so it will be replaced with the one from master + $self->run_replication(); + $self->check_replication('cassandane'); + + # then, verify that scripts are in expected state + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + $self->assert_sieve_matches($self->{instance}, $user, $scriptname, + $scriptnewcontent); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); + $self->assert_sieve_matches($self->{replica}, $user, $scriptname, + $scriptnewcontent); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_stale_unixhs b/cassandane/tiny-tests/Replication/sieve_replication_stale_unixhs new file mode 100644 index 0000000000..2c36d4c1bc --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_stale_unixhs @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_stale_unixhs + :needs_component_sieve :UnixHierarchySep + :needs_component_replication +{ + my ($self) = @_; + + my $user = 'some.body'; + my $scriptname = 'test1'; + my $scriptoldcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + + my $scriptnewcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "more testing"; +} +EOF + $self->{instance}->create_user($user); + + # first, verify that script does not exist on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install "old" script on replica... + $self->{replica}->install_sieve_script($scriptoldcontent, + name=>$scriptname, + username=>$user); + + # ... and "new" script on master, a little later + sleep 2; + $self->{instance}->install_sieve_script($scriptnewcontent, + name=>$scriptname, + username=>$user); + + # then, verify that different sieve script content exists at each end + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + $self->assert_sieve_matches($self->{instance}, $user, $scriptname, + $scriptnewcontent); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); + $self->assert_sieve_matches($self->{replica}, $user, $scriptname, + $scriptoldcontent); + + # then, run replication, + # the one that exists on replica is different to and older than the one + # on master, so it will be replaced with the one from master + $self->run_replication(user=>$user); + $self->check_replication($user); + + # then, verify that scripts are in expected state + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + $self->assert_sieve_matches($self->{instance}, $user, $scriptname, + $scriptnewcontent); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); + $self->assert_sieve_matches($self->{replica}, $user, $scriptname, + $scriptnewcontent); +} diff --git a/cassandane/tiny-tests/Replication/sieve_replication_unixhs b/cassandane/tiny-tests/Replication/sieve_replication_unixhs new file mode 100644 index 0000000000..476057150c --- /dev/null +++ b/cassandane/tiny-tests/Replication/sieve_replication_unixhs @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_replication_unixhs + :needs_component_sieve :UnixHierarchySep + :needs_component_replication +{ + my ($self) = @_; + + my $user = 'some.body'; + my $scriptname = 'test1'; + my $scriptcontent = <<'EOF'; +require ["reject","fileinto"]; +if address :is :all "From" "autoreject@example.org" +{ + reject "testing"; +} +EOF + $self->{instance}->create_user($user); + + # first, verify that sieve script does not exist on master or replica + $self->assert_sieve_not_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{instance}, $user); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, install sieve script on master + $self->{instance}->install_sieve_script($scriptcontent, + name=>$scriptname, + username=>$user); + + # then, verify that sieve script exists on master but not on replica + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 0); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + + $self->assert_sieve_not_exists($self->{replica}, $user, $scriptname, 0); + $self->assert_sieve_noactive($self->{replica}, $user); + + # then, run replication, + $self->run_replication(user=>$user); + $self->check_replication($user); + + # then, verify that sieve script exists on both master and replica + $self->assert_sieve_exists($self->{instance}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{instance}, $user, $scriptname); + + $self->assert_sieve_exists($self->{replica}, $user, $scriptname, 1); + $self->assert_sieve_active($self->{replica}, $user, $scriptname); +} diff --git a/cassandane/tiny-tests/Replication/splitbrain b/cassandane/tiny-tests/Replication/splitbrain new file mode 100644 index 0000000000..023425427f --- /dev/null +++ b/cassandane/tiny-tests/Replication/splitbrain @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of messages APPENDed to the master +# +sub test_splitbrain + :needs_component_replication +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + xlog $self, "generating messages A..D"; + my %exp; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{B} = $self->make_message("Message B", store => $master_store); + $exp{C} = $self->make_message("Message C", store => $master_store); + $exp{D} = $self->make_message("Message D", store => $master_store); + + xlog $self, "Before replication, the master should have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "Before replication, the replica should have no messages"; + $self->check_messages({}, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, the master should still have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should now have all four messages"; + $self->check_messages(\%exp, store => $replica_store); + + my %mexp = %exp; + my %rexp = %exp; + + $mexp{E} = $self->make_message("Message E", store => $master_store); + $rexp{F} = $self->make_message("Message F", store => $replica_store); + + # uid is 5 at both ends + $rexp{F}->set_attribute(uid => 5); + + xlog $self, "No replication, the master should have its 5 messages"; + $self->check_messages(\%mexp, store => $master_store); + xlog $self, "No replication, the replica should have the other 5 messages"; + $self->check_messages(\%rexp, store => $replica_store); + + $self->run_replication(); + + # replication will generate a couple of SYNCERRORS in syslog + my $pattern = qr{ + \bSYNCERROR:\sguid\smismatch + (?: \suser\.cassandane\s5\b + | :\smailbox=\suid=<5> + ) + }x; + $self->assert_syslog_matches($self->{instance}, $pattern); + + $self->check_replication('cassandane'); + + + %exp = (%mexp, %rexp); + # we could calculate 6 and 7 by sorting from GUID, but easiest is to ignore UIDs + $exp{E}->set_attribute(uid => undef); + $exp{F}->set_attribute(uid => undef); + xlog $self, "After replication, the master should have all 6 messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should have all 6 messages"; + $self->check_messages(\%exp, store => $replica_store); +} diff --git a/cassandane/tiny-tests/Replication/splitbrain_bothexpunge b/cassandane/tiny-tests/Replication/splitbrain_bothexpunge new file mode 100644 index 0000000000..0158527313 --- /dev/null +++ b/cassandane/tiny-tests/Replication/splitbrain_bothexpunge @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of messages APPENDed to the master +# +sub test_splitbrain_bothexpunge + :needs_component_replication +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + xlog $self, "generating messages A..D"; + my %exp; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{B} = $self->make_message("Message B", store => $master_store); + $exp{C} = $self->make_message("Message C", store => $master_store); + $exp{D} = $self->make_message("Message D", store => $master_store); + + xlog $self, "Before replication, the master should have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "Before replication, the replica should have no messages"; + $self->check_messages({}, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, the master should still have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should now have all four messages"; + $self->check_messages(\%exp, store => $replica_store); + + my %mexp = %exp; + my %rexp = %exp; + + $mexp{E} = $self->make_message("Message E", store => $master_store); + $rexp{F} = $self->make_message("Message F", store => $replica_store); + + # uid is 5 at both ends + $rexp{F}->set_attribute(uid => 5); + + xlog $self, "No replication, the master should have its 5 messages"; + $self->check_messages(\%mexp, store => $master_store); + xlog $self, "No replication, the replica should have the other 5 messages"; + $self->check_messages(\%rexp, store => $replica_store); + + xlog $self, "Delete and expunge the message on the master"; + my $talk = $master_store->get_client(); + $master_store->_select(); + $talk->store('5', '+flags', '(\\Deleted)'); + $talk->expunge(); + delete $mexp{E}; + + xlog $self, "Delete and expunge the message on the master"; + my $rtalk = $replica_store->get_client(); + $replica_store->_select(); + $rtalk->store('5', '+flags', '(\\Deleted)'); + $rtalk->expunge(); + delete $rexp{F}; + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, the master should have just the original 4 messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should have the same 4 messages"; + $self->check_messages(\%exp, store => $replica_store); +} diff --git a/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_nonempty b/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_nonempty new file mode 100644 index 0000000000..c945da1821 --- /dev/null +++ b/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_nonempty @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +# +# Test non-empty mailbox causes replication to abort +# +sub test_splitbrain_different_uniqueid_nonempty + :min_version_3_5 :needs_component_replication :NoReplicaonly +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $mtalk = $master_store->get_client(); + my $rtalk = $replica_store->get_client(); + + $mtalk->create('INBOX.subfolder'); + my $mres = $mtalk->status("INBOX.subfolder", ['mailboxid']); + my $mid = $mres->{mailboxid}[0]; + $rtalk->create('INBOX.subfolder'); + my $rres = $rtalk->status("INBOX.subfolder", ['mailboxid']); + my $rid = $rres->{mailboxid}[0]; + + $self->assert_not_null($mid); + $self->assert_not_null($rid); + $self->assert_str_not_equals($mid, $rid); + + $master_store->set_folder("INBOX.subfolder"); + $replica_store->set_folder("INBOX.subfolder"); + + $self->make_message("Message A", store => $master_store); + $self->make_message("Message B", store => $replica_store); + + # this will fail + eval { + $self->run_replication(); + }; + + # sync_client should have logged the failure + if ($self->{instance}->{have_syslog_replacement}) { + my @mlines = $self->{instance}->getsyslog(); + $self->assert_matches(qr/IOERROR: user replication failed/, "@mlines"); + $self->assert_matches(qr/MAILBOX received NO response: IMAP_MAILBOX_MOVED/, "@mlines"); + + } + + # sync server should have logged the failure + $self->assert_syslog_matches( + $self->{replica}, + qr/SYNCERROR: mailbox uniqueid changed - retry/ + ); + + $rtalk = $replica_store->get_client(); + $rres = $rtalk->status("INBOX.subfolder", ['mailboxid']); + $rid = $rres->{mailboxid}[0]; + + $self->assert_str_not_equals($mid, $rid); +} diff --git a/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_unused b/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_unused new file mode 100644 index 0000000000..79730e640b --- /dev/null +++ b/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_unused @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +# +# Test empty mailbox gets overwritten +# +sub test_splitbrain_different_uniqueid_unused + :min_version_3_5 :needs_component_replication :NoReplicaonly +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $mtalk = $master_store->get_client(); + my $rtalk = $replica_store->get_client(); + + $mtalk->create('INBOX.subfolder'); + my $mres = $mtalk->status("INBOX.subfolder", ['mailboxid']); + my $mid = $mres->{mailboxid}[0]; + $rtalk->create('INBOX.subfolder'); + my $rres = $rtalk->status("INBOX.subfolder", ['mailboxid']); + my $rid = $rres->{mailboxid}[0]; + + $self->assert_not_null($mid); + $self->assert_not_null($rid); + $self->assert_str_not_equals($mid, $rid); + + $master_store->set_folder("INBOX.subfolder"); + + $self->make_message("Message A", store => $master_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + $rtalk = $replica_store->get_client(); + $rres = $rtalk->status("INBOX.subfolder", ['mailboxid']); + $rid = $rres->{mailboxid}[0]; + + $self->assert_str_equals($mid, $rid); +} diff --git a/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_used b/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_used new file mode 100644 index 0000000000..df4c99ade2 --- /dev/null +++ b/cassandane/tiny-tests/Replication/splitbrain_different_uniqueid_used @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +# +# Test mailbox that's had email but is now empty again +# +sub test_splitbrain_different_uniqueid_used + :min_version_3_5 :needs_component_replication :NoReplicaonly +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $mtalk = $master_store->get_client(); + my $rtalk = $replica_store->get_client(); + + $mtalk->create('INBOX.subfolder'); + my $mres = $mtalk->status("INBOX.subfolder", ['mailboxid']); + my $mid = $mres->{mailboxid}[0]; + $rtalk->create('INBOX.subfolder'); + my $rres = $rtalk->status("INBOX.subfolder", ['mailboxid']); + my $rid = $rres->{mailboxid}[0]; + + $self->assert_not_null($mid); + $self->assert_not_null($rid); + $self->assert_str_not_equals($mid, $rid); + + $master_store->set_folder("INBOX.subfolder"); + $replica_store->set_folder("INBOX.subfolder"); + + $self->make_message("Message A", store => $master_store); + $self->make_message("Message B", store => $replica_store); + + $rtalk->select('INBOX.subfolder'); + $rtalk->store('1:*', '+flags', '\\Deleted'); + $rtalk->expunge(); + + # this will fail + eval { + $self->run_replication(); + }; + + if ($self->{instance}->{have_syslog_replacement}) { + # sync_client should have logged the failure + my @mlines = $self->{instance}->getsyslog(); + $self->assert_matches(qr/IOERROR: user replication failed/, "@mlines"); + $self->assert_matches(qr/MAILBOX received NO response: IMAP_MAILBOX_MOVED/, "@mlines"); + } + + # sync server should have logged the failure + $self->assert_syslog_matches( + $self->{replica}, + qr/SYNCERROR: mailbox uniqueid changed - retry/ + ); + + $rtalk = $replica_store->get_client(); + $rres = $rtalk->status("INBOX.subfolder", ['mailboxid']); + $rid = $rres->{mailboxid}[0]; + + $self->assert_str_not_equals($mid, $rid); + + xlog "Trying again with no-copyback"; + $self->run_replication(nosyncback => 1); + $self->check_replication('cassandane'); + + $rtalk = $replica_store->get_client(); + $rres = $rtalk->status("INBOX.subfolder", ['mailboxid']); + $rid = $rres->{mailboxid}[0]; + + $self->assert_str_equals($mid, $rid); +} diff --git a/cassandane/tiny-tests/Replication/splitbrain_mailbox b/cassandane/tiny-tests/Replication/splitbrain_mailbox new file mode 100644 index 0000000000..e166839168 --- /dev/null +++ b/cassandane/tiny-tests/Replication/splitbrain_mailbox @@ -0,0 +1,95 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of mailbox only after a rename +# +sub test_splitbrain_mailbox + :min_version_3_1 :max_version_3_4 :NoAltNameSpace + :needs_component_replication +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + my $mastertalk = $master_store->get_client(); + my $replicatalk = $replica_store->get_client(); + + $mastertalk->create("INBOX.src-name"); + + xlog $self, "run initial replication"; + $self->run_replication(); + $self->check_replication('cassandane'); + + $mastertalk = $master_store->get_client(); + $mastertalk->rename("INBOX.src-name", "INBOX.dest-name"); + + $self->{instance}->getsyslog(); + $self->{replica}->getsyslog(); + + xlog $self, "try replicating just the mailbox by name fails due to duplicate uniqueid"; + eval { $self->run_replication(mailbox => 'user.cassandane.dest-name') }; + $self->assert_matches(qr/exited with code 1/, "$@"); + + my $master_pattern = qr{ + \bMAILBOX\sreceived\sNO\sresponse:\sIMAP_MAILBOX_MOVED\b + }x; + $self->assert_syslog_matches($self->{instance}, $master_pattern); + + my $replica_pattern = qr{ + (?: \bSYNCNOTICE:\sfailed\sto\screate\smailbox + \suser\.cassandane\.dest-name\b + | \bSYNCNOTICE:\smailbox\suniqueid\salready\sin\suse: + \smailbox= + ) + }x; + $self->assert_syslog_matches($self->{replica}, $replica_pattern); + + xlog $self, "Run a full user replication to repair"; + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "Rename again"; + $mastertalk = $master_store->get_client(); + $mastertalk->rename("INBOX.dest-name", "INBOX.foo"); + my $file = $self->{instance}->{basedir} . "/sync.log"; + open(FH, ">", $file); + print FH "MAILBOX user.cassandane.foo\n"; + close(FH); + + $self->{instance}->getsyslog(); + $self->{replica}->getsyslog(); + xlog $self, "Run replication from a file with just the mailbox name in it"; + $self->run_replication(inputfile => $file, rolling => 1); + + if ($self->{instance}->{have_syslog_replacement}) { + my @mastersyslog = $self->{instance}->getsyslog(); + my @replicasyslog = $self->{replica}->getsyslog(); + + my $master_pattern = qr{ + \bdo_folders\(\):\supdate\sfailed:\suser\.cassandane\.foo\b + }x; + + my $replica_pattern1 = qr{ + (?: \bSYNCNOTICE:\sfailed\sto\screate\smailbox + \suser\.cassandane\.foo\b + | \bSYNCNOTICE:\smailbox\suniqueid\salready\sin\suse: + \smailbox= + ) + }x; + + my $replica_pattern2 = qr{ + \bRename:\suser.cassandane\.dest-name\s->\suser\.cassandane\.foo\b + }x; + + # initial failures + $self->assert_matches($master_pattern, "@mastersyslog"); + $self->assert_matches($replica_pattern1, "@replicasyslog"); + # later success + $self->assert_matches($replica_pattern2, "@replicasyslog"); + } + + # replication fixes itself + $self->check_replication('cassandane'); +} diff --git a/cassandane/tiny-tests/Replication/splitbrain_masterexpunge b/cassandane/tiny-tests/Replication/splitbrain_masterexpunge new file mode 100644 index 0000000000..d5da2f3e10 --- /dev/null +++ b/cassandane/tiny-tests/Replication/splitbrain_masterexpunge @@ -0,0 +1,75 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of messages APPENDed to the master +# +sub test_splitbrain_masterexpunge + :needs_component_replication +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + xlog $self, "generating messages A..D"; + my %exp; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{B} = $self->make_message("Message B", store => $master_store); + $exp{C} = $self->make_message("Message C", store => $master_store); + $exp{D} = $self->make_message("Message D", store => $master_store); + + xlog $self, "Before replication, the master should have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "Before replication, the replica should have no messages"; + $self->check_messages({}, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, the master should still have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should now have all four messages"; + $self->check_messages(\%exp, store => $replica_store); + + my %mexp = %exp; + my %rexp = %exp; + + $mexp{E} = $self->make_message("Message E", store => $master_store); + $rexp{F} = $self->make_message("Message F", store => $replica_store); + + # uid is 5 at both ends + $rexp{F}->set_attribute(uid => 5); + + xlog $self, "No replication, the master should have its 5 messages"; + $self->check_messages(\%mexp, store => $master_store); + xlog $self, "No replication, the replica should have the other 5 messages"; + $self->check_messages(\%rexp, store => $replica_store); + + xlog $self, "Delete and expunge the message on the master"; + my $talk = $master_store->get_client(); + $master_store->_select(); + $talk->store('5', '+flags', '(\\Deleted)'); + $talk->expunge(); + delete $mexp{E}; + + xlog $self, "No replication, the master now only has 4 messages"; + $self->check_messages(\%mexp, store => $master_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + %exp = (%mexp, %rexp); + # we know that the message should be prompoted to UID 6 + $exp{F}->set_attribute(uid => 6); + xlog $self, "After replication, the master should have all 5 messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should have the same 5 messages"; + $self->check_messages(\%exp, store => $replica_store); + + # We should have generated a SYNCERROR/SYNCNOTICE or two + $self->assert_syslog_matches($self->{instance}, + qr/SYNC(?:ERROR|NOTICE): guid mismatch/); + $self->assert_syslog_matches($self->{replica}, + qr/SYNC(?:ERROR|NOTICE): guid mismatch/); +} diff --git a/cassandane/tiny-tests/Replication/splitbrain_replicaexpunge b/cassandane/tiny-tests/Replication/splitbrain_replicaexpunge new file mode 100644 index 0000000000..09fce8a687 --- /dev/null +++ b/cassandane/tiny-tests/Replication/splitbrain_replicaexpunge @@ -0,0 +1,73 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of messages APPENDed to the master +# +sub test_splitbrain_replicaexpunge + :needs_component_replication +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + xlog $self, "generating messages A..D"; + my %exp; + $exp{A} = $self->make_message("Message A", store => $master_store); + $exp{B} = $self->make_message("Message B", store => $master_store); + $exp{C} = $self->make_message("Message C", store => $master_store); + $exp{D} = $self->make_message("Message D", store => $master_store); + + xlog $self, "Before replication, the master should have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "Before replication, the replica should have no messages"; + $self->check_messages({}, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, the master should still have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should now have all four messages"; + $self->check_messages(\%exp, store => $replica_store); + + my %mexp = %exp; + my %rexp = %exp; + + $mexp{E} = $self->make_message("Message E", store => $master_store); + $rexp{F} = $self->make_message("Message F", store => $replica_store); + + # uid is 5 at both ends + $rexp{F}->set_attribute(uid => 5); + + xlog $self, "No replication, the master should have its 5 messages"; + $self->check_messages(\%mexp, store => $master_store); + xlog $self, "No replication, the replica should have the other 5 messages"; + $self->check_messages(\%rexp, store => $replica_store); + + xlog $self, "Delete and expunge the message on the master"; + my $rtalk = $replica_store->get_client(); + $replica_store->_select(); + $rtalk->store('5', '+flags', '(\\Deleted)'); + $rtalk->expunge(); + delete $rexp{F}; + + xlog $self, "No replication, the replica now only has 4 messages"; + $self->check_messages(\%rexp, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + %exp = (%mexp, %rexp); + # we know that the message should be prompoted to UID 6 + $exp{E}->set_attribute(uid => 6); + xlog $self, "After replication, the master should have all 5 messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should have the same 5 messages"; + $self->check_messages(\%exp, store => $replica_store); + + # We should have generated a SYNCERROR or two + $self->assert_syslog_matches($self->{instance}, + qr/SYNCERROR: guid mismatch/); +} diff --git a/cassandane/tiny-tests/Replication/subscriptions b/cassandane/tiny-tests/Replication/subscriptions new file mode 100644 index 0000000000..b6f2a1e383 --- /dev/null +++ b/cassandane/tiny-tests/Replication/subscriptions @@ -0,0 +1,99 @@ +#!perl +use Cassandane::Tiny; + +sub test_subscriptions + :needs_component_replication +{ + my ($self) = @_; + + my $user = 'brandnew'; + $self->{instance}->create_user($user); + + # verify that subs file does not exist on master + # verify that subs file does not exist on replica + $self->assert_user_sub_not_exists($self->{instance}, $user); + $self->assert_user_sub_not_exists($self->{replica}, $user); + + # set up and verify some subscriptions on master + my $mastersvc = $self->{instance}->get_service('imap'); + my $masterstore = $mastersvc->create_store(username => $user); + my $mastertalk = $masterstore->get_client(); + + $mastertalk->create("INBOX.Test") || die; + $mastertalk->create("INBOX.Test.Sub") || die; + $mastertalk->create("INBOX.Test Foo") || die; + $mastertalk->create("INBOX.Test Bar") || die; + $mastertalk->subscribe("INBOX") || die; + $mastertalk->subscribe("INBOX.Test") || die; + $mastertalk->subscribe("INBOX.Test.Sub") || die; + $mastertalk->subscribe("INBOX.Test Foo") || die; + $mastertalk->delete("INBOX.Test.Sub") || die; + + my $subdata = $mastertalk->lsub("", "*"); + $self->assert_deep_equals($subdata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX' + ], + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX.Test' + ], + [ + [], + '.', + 'INBOX.Test Foo' + ], + ]); + + # drop the conf dir lock, so the subs get written out + $mastertalk->logout(); + + # verify that subs file exists on master + # verify that subs file does not exist on replica + $self->assert_user_sub_exists($self->{instance}, $user); + $self->assert_user_sub_not_exists($self->{replica}, $user); + + # run replication + $self->run_replication(user => $user); + $self->check_replication($user); + + # verify that subs file exists on master + # verify that subs file exists on replica + $self->assert_user_sub_exists($self->{instance}, $user); + $self->assert_user_sub_exists($self->{replica}, $user); + + # verify replica store can see subs + my $replicasvc = $self->{replica}->get_service('imap'); + my $replicastore = $replicasvc->create_store(username => $user); + my $replicatalk = $replicastore->get_client(); + + $subdata = $replicatalk->lsub("", "*"); + $self->assert_deep_equals($subdata, [ + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX' + ], + [ + [ + '\\HasChildren' + ], + '.', + 'INBOX.Test' + ], + [ + [], + '.', + 'INBOX.Test Foo' + ], + ]); +} diff --git a/cassandane/tiny-tests/Replication/subscriptions_unixhs b/cassandane/tiny-tests/Replication/subscriptions_unixhs new file mode 100644 index 0000000000..22ec51250b --- /dev/null +++ b/cassandane/tiny-tests/Replication/subscriptions_unixhs @@ -0,0 +1,99 @@ +#!perl +use Cassandane::Tiny; + +sub test_subscriptions_unixhs + :UnixHierarchySep :needs_component_replication +{ + my ($self) = @_; + + my $user = 'brand.new'; + $self->{instance}->create_user($user); + + # verify that subs file does not exist on master + # verify that subs file does not exist on replica + $self->assert_user_sub_not_exists($self->{instance}, $user); + $self->assert_user_sub_not_exists($self->{replica}, $user); + + # set up and verify some subscriptions on master + my $mastersvc = $self->{instance}->get_service('imap'); + my $masterstore = $mastersvc->create_store(username => $user); + my $mastertalk = $masterstore->get_client(); + + $mastertalk->create("INBOX/Test") || die; + $mastertalk->create("INBOX/Test/Sub") || die; + $mastertalk->create("INBOX/Test Foo") || die; + $mastertalk->create("INBOX/Test Bar") || die; + $mastertalk->subscribe("INBOX") || die; + $mastertalk->subscribe("INBOX/Test") || die; + $mastertalk->subscribe("INBOX/Test/Sub") || die; + $mastertalk->subscribe("INBOX/Test Foo") || die; + $mastertalk->delete("INBOX/Test/Sub") || die; + + my $subdata = $mastertalk->lsub("", "*"); + $self->assert_deep_equals($subdata, [ + [ + [ + '\\HasChildren' + ], + '/', + 'INBOX' + ], + [ + [ + '\\HasChildren' + ], + '/', + 'INBOX/Test' + ], + [ + [], + '/', + 'INBOX/Test Foo' + ], + ]); + + # drop the conf dir lock, so the subs get written out + $mastertalk->logout(); + + # verify that subs file exists on master + # verify that subs file does not exist on replica + $self->assert_user_sub_exists($self->{instance}, $user); + $self->assert_user_sub_not_exists($self->{replica}, $user); + + # run replication + $self->run_replication(user => $user); + $self->check_replication($user); + + # verify that subs file exists on master + # verify that subs file exists on replica + $self->assert_user_sub_exists($self->{instance}, $user); + $self->assert_user_sub_exists($self->{replica}, $user); + + # verify replica store can see subs + my $replicasvc = $self->{replica}->get_service('imap'); + my $replicastore = $replicasvc->create_store(username => $user); + my $replicatalk = $replicastore->get_client(); + + $subdata = $replicatalk->lsub("", "*"); + $self->assert_deep_equals($subdata, [ + [ + [ + '\\HasChildren' + ], + '/', + 'INBOX' + ], + [ + [ + '\\HasChildren' + ], + '/', + 'INBOX/Test' + ], + [ + [], + '/', + 'INBOX/Test Foo' + ], + ]); +} diff --git a/cassandane/tiny-tests/Replication/sync_empty_file b/cassandane/tiny-tests/Replication/sync_empty_file new file mode 100644 index 0000000000..27fff2e652 --- /dev/null +++ b/cassandane/tiny-tests/Replication/sync_empty_file @@ -0,0 +1,18 @@ +#!perl +use Cassandane::Tiny; + +# this is testing a bug where sync_client would abort on zero-length file +sub test_sync_empty_file + :DelayedDelete :min_version_3_3 :needs_component_replication +{ + my ($self) = @_; + + $self->run_replication(); + + my $file = $self->{instance}->{basedir} . "/sync.log"; + open(FH, ">", $file); + close(FH); + + xlog $self, "Run replication from an empty file"; + $self->run_replication(inputfile => $file, rolling => 1); +} diff --git a/cassandane/tiny-tests/Replication/sync_log_mailbox_with_spaces b/cassandane/tiny-tests/Replication/sync_log_mailbox_with_spaces new file mode 100644 index 0000000000..cbf88cd9e3 --- /dev/null +++ b/cassandane/tiny-tests/Replication/sync_log_mailbox_with_spaces @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_sync_log_mailbox_with_spaces + :DelayedDelete :NoStartInstances :needs_component_replication +{ + my ($self) = @_; + + my $channel = 'eggplant'; # look it's gotta be called something + + # make sure we get a sync log file in a predictable location + $self->{instance}->{config}->set('sync_log' => 'yes'); + $self->{instance}->{config}->set('sync_log_channels' => $channel); + $self->_start_instances(); + + # make some folders with and without spaces + my $master_store = $self->{master_store}; + my $mastertalk = $master_store->get_client(); + + $mastertalk->create("INBOX.2nd level with spaces"); + $self->assert_str_equals('ok', + $mastertalk->get_last_completion_response()); + + $mastertalk->create("INBOX.foo"); + $self->assert_str_equals('ok', + $mastertalk->get_last_completion_response()); + + $mastertalk->create("INBOX.foo.3rd level with spaces"); + $self->assert_str_equals('ok', + $mastertalk->get_last_completion_response()); + + # make sure the contents of the sync log file are correctly quoted + my $sync_log_fname = $self->{instance}->get_basedir() + . "/conf/sync/$channel/log"; + + open my $fh, '<', $sync_log_fname or die "open $sync_log_fname: $!"; + while (<$fh>) { + # We can take some shortcuts here because we're only testing + # for correct quoting of mailbox names with/without SPACES, + # and not other non-atom characters. + # We're also only acting on mailboxes, so we don't need this + # parser to consider any other log entries. + # An exhaustive test would be more complicated! + chomp; + + if (m/^MAILBOX "(.*)"$/) { + # Argument is quoted! + # We expect that we only added quotes where the single + # mboxname contained spaces, so assert that it contains + # spaces + $self->assert_matches(qr/\s/, $1); + } + elsif (m/^MAILBOX ([^"].*[^"])$/) { + # Argument is not quoted! + # We expect that if there were spaces, it would have been + # quoted, so assert that there are no spaces. + $self->assert_does_not_match(qr/\s/, $1); + } + else { + # something weird here! always assert + $self->assert(undef, "found unrecognised line in sync_log: $_"); + } + } + close $fh; + + # no need to even replicate anything; everything we cared about was + # in the sync log :) +} diff --git a/cassandane/tiny-tests/Replication/syncall_failinguser b/cassandane/tiny-tests/Replication/syncall_failinguser new file mode 100644 index 0000000000..7d0ccba745 --- /dev/null +++ b/cassandane/tiny-tests/Replication/syncall_failinguser @@ -0,0 +1,114 @@ +#!perl +use Cassandane::Tiny; + +# +# Test handling of replication when append fails due to disk error +# +sub test_syncall_failinguser + :NoStartInstances :min_version_3_6 :needs_component_replication +{ + my ($self) = @_; + + my $canary = << 'EOF'; +From: Fred J. Bloggs +To: Sarah Jane Smith +Subject: this is just to say +X-Cassandane-Unique: canary + +I have eaten +the canary +that was in +the coal mine + +and which +you were probably +saving +for emergencies + +Forgive me +it was delicious +so tweet +and so coaled +EOF + $canary =~ s/\n/\r\n/g; + my $canaryguid = "f2eaa91974c50ec3cfb530014362e92efb06a9ba"; + + $self->{replica}->{config}->set('debug_writefail_guid' => $canaryguid); + $self->_start_instances(); + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + + $self->{instance}->create_user("a_early"); + $self->{instance}->create_user("z_late"); + + my $mastersvc = $self->{instance}->get_service('imap'); + my $astore = $mastersvc->create_store(username => "a_early"); + my $zstore = $mastersvc->create_store(username => "z_late"); + my $replicasvc = $self->{replica}->get_service('imap'); + my $replica_astore = $replicasvc->create_store(username => "a_early"); + my $replica_zstore = $replicasvc->create_store(username => "z_late"); + + xlog $self, "Creating a message in each user"; + my %apreexp; + my %cpreexp; + my %zpreexp; + $apreexp{1} = $self->make_message("Message A", store => $astore); + $cpreexp{1} = $self->make_message("Message C", store => $master_store); + $zpreexp{1} = $self->make_message("Message Z", store => $zstore); + + xlog $self, "Running all user replication"; + $self->run_replication(allusers => 1); + + xlog $self, "Creating a second message for each user (cassandane having the canary)"; + my %aexp = %apreexp; + my %cexp = %cpreexp; + my %zexp = %zpreexp; + $aexp{2} = $self->make_message("Message A2", store => $astore); + $cexp{2} = Cassandane::Message->new(raw => $canary, + attrs => { UID => 2 }), + $self->_save_message($cexp{2}, $master_store); + $zexp{2} = $self->make_message("Message Z2", store => $zstore); + + xlog $self, "new messages should be on master only"; + $self->check_messages(\%aexp, keyed_on => 'uid', store => $astore); + $self->check_messages(\%apreexp, keyed_on => 'uid', store => $replica_astore); + $self->check_messages(\%cexp, keyed_on => 'uid', store => $master_store); + $self->check_messages(\%cpreexp, keyed_on => 'uid', store => $replica_store); + $self->check_messages(\%zexp, keyed_on => 'uid', store => $zstore); + $self->check_messages(\%zpreexp, keyed_on => 'uid', store => $replica_zstore); + + xlog $self, "running replication..."; + eval { + $self->run_replication(allusers => 1); + }; + my $e = $@; + + # sync_client should have exited with an error + $self->assert($e); + $self->assert_matches(qr/child\sprocess\s + \(binary\ssync_client\spid\s\d+\)\s + exited\swith\scode/x, + $e->to_string()); + + # sync_client should have logged the BAD response + $self->assert_syslog_matches($self->{instance}, + qr/IOERROR: received bad response/); + + # sync server should have logged the write error + $self->assert_syslog_matches($self->{replica}, + qr{IOERROR:\sfailed\sto\supload\sfile + (?:\s\(simulated\))?:\sguid=<$canaryguid> + }x); + + xlog $self, "Check that cassandane user wasn't updated, both others were"; + $self->check_replication('a_early'); + $self->check_replication('z_late'); + + $self->check_messages(\%aexp, keyed_on => 'uid', store => $astore); + $self->check_messages(\%aexp, keyed_on => 'uid', store => $replica_astore); + $self->check_messages(\%cexp, keyed_on => 'uid', store => $master_store); + $self->check_messages(\%cpreexp, keyed_on => 'uid', store => $replica_store); + $self->check_messages(\%zexp, keyed_on => 'uid', store => $zstore); + $self->check_messages(\%zexp, keyed_on => 'uid', store => $replica_zstore); +} diff --git a/cassandane/tiny-tests/Replication/toarchive b/cassandane/tiny-tests/Replication/toarchive new file mode 100644 index 0000000000..a768c0ba7e --- /dev/null +++ b/cassandane/tiny-tests/Replication/toarchive @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_toarchive + :NoStartInstances :ArchivePartition :min_version_3_7 + :needs_component_replication +{ + my ($self) = @_; + + my $repcfg = $self->{replica}->{config}; + $repcfg->set('debug_log_sync_partition_choice' => 'yes'); + $self->_start_instances(); + + my $mtalk = $self->{master_store}->get_client(); + $self->{master_store}->_select(); + $self->assert_num_equals(1, $mtalk->uid()); + $self->{master_store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Append 3 old messages"; + my %msg; + foreach my $id (1..3) { + my $olddate = DateTime->now(); + $olddate->add(DateTime::Duration->new(months => -4 + $id)); + + $msg{$id} = $self->make_message("Message $id", + date => $olddate, + store => $self->{master_store}); + $msg{$id}->set_attributes(id => $id, + uid => $id, + flags => []); + } + + xlog $self, "Append 3 current messages"; + foreach my $id (4..6) { + $msg{$id} = $self->make_message("Message $id", + store => $self->{master_store}); + $msg{$id}->set_attributes(id => $id, + uid => $id, + flags => []); + } + + xlog $self, "Run cyr_expire to archive old messages"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '7d' ); + + $self->check_messages(\%msg); + + my $mbpath = $self->{instance}->run_mbpath('-u', 'cassandane'); + my $mdatadir = $mbpath->{data}; + my $marchivedir = $mbpath->{archive}; + + foreach my $id (1..6) { + if ($id > 3) { + $self->assert_file_test("$mdatadir/$id.", '-f'); + $self->assert_not_file_test("$marchivedir/$id.", '-f'); + } + else { + $self->assert_not_file_test("$mdatadir/$id.", '-f'); + $self->assert_file_test("$marchivedir/$id.", '-f'); + } + } + + $self->{replica}->getsyslog(); # discard setup noise + + xlog $self, "Run replication, staging on archive partition"; + $self->run_replication('stagetoarchive' => 1); + $self->check_replication('cassandane'); + + # ensure we made the correct choices about how to stage + if ($self->{replica}->{have_syslog_replacement}) { + my $part = $repcfg->substitute( + $repcfg->get('archivepartition-default') + ); + + my @choices = $self->{replica}->getsyslog(qr{debug_log_sync_partition_choice: chose reserve path}); + + $self->assert_num_equals(scalar(keys %msg), scalar @choices); + + foreach my $choice (@choices) { + $self->assert_matches(qr{\bbase=<$part>}, + $choice); + $self->assert_matches(qr{\breserve_path=<$part/sync\./}, + $choice); + } + } + + # ensure that data ends up in the correct places + $mbpath = $self->{replica}->run_mbpath('-u', 'cassandane'); + my $rdatadir = $mbpath->{data}; + my $rarchivedir = $mbpath->{archive}; + + foreach my $id (1..6) { + if ($id > 3) { + $self->assert_file_test("$rdatadir/$id.", '-f'); + $self->assert_not_file_test("$rarchivedir/$id.", '-f'); + } + else { + $self->assert_not_file_test("$rdatadir/$id.", '-f'); + $self->assert_file_test("$rarchivedir/$id.", '-f'); + } + } +} diff --git a/cassandane/tiny-tests/Replication/toarchive_noarchive b/cassandane/tiny-tests/Replication/toarchive_noarchive new file mode 100644 index 0000000000..e1a36618ab --- /dev/null +++ b/cassandane/tiny-tests/Replication/toarchive_noarchive @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +sub test_toarchive_noarchive + :NoStartInstances :min_version_3_7 :needs_component_replication +{ + my ($self) = @_; + + my $repcfg = $self->{replica}->{config}; + $repcfg->set('debug_log_sync_partition_choice' => 'yes'); + $self->_start_instances(); + + my $mtalk = $self->{master_store}->get_client(); + $self->{master_store}->_select(); + $self->assert_num_equals(1, $mtalk->uid()); + $self->{master_store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Append 3 old messages"; + my %msg; + foreach my $id (1..3) { + my $olddate = DateTime->now(); + $olddate->add(DateTime::Duration->new(months => -4 + $id)); + + $msg{$id} = $self->make_message("Message $id", + date => $olddate, + store => $self->{master_store}); + $msg{$id}->set_attributes(id => $id, + uid => $id, + flags => []); + } + + xlog $self, "Append 3 current messages"; + foreach my $id (4..6) { + $msg{$id} = $self->make_message("Message $id", + store => $self->{master_store}); + $msg{$id}->set_attributes(id => $id, + uid => $id, + flags => []); + } + + xlog $self, "Run cyr_expire to archive old messages"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-A' => '7d' ); + + $self->check_messages(\%msg); + + my $mbpath = $self->{instance}->run_mbpath('-u', 'cassandane'); + my $mdatadir = $mbpath->{data}; + my $marchivedir = $mbpath->{archive}; + + # expect data and archive should be the same + $self->assert_str_equals($mdatadir, $marchivedir); + + # all messages in same place + foreach my $id (1..6) { + $self->assert_file_test("$mdatadir/$id.", '-f'); + } + + $self->{replica}->getsyslog(); # discard setup noise + + xlog $self, "Run replication, staging on archive partition"; + $self->run_replication('stagetoarchive' => 1); + $self->check_replication('cassandane'); + + # ensure we made the correct choices about how to stage + if ($self->{replica}->{have_syslog_replacement}) { + my $part = $repcfg->substitute( + $repcfg->get('partition-default') + ); + + my @choices = $self->{replica}->getsyslog(qr{debug_log_sync_partition_choice: chose reserve path}); + + $self->assert_num_equals(scalar(keys %msg), scalar @choices); + + foreach my $choice (@choices) { + $self->assert_matches(qr{\bbase=<$part>}, + $choice); + $self->assert_matches(qr{\breserve_path=<$part/sync\./}, + $choice); + } + } + + # ensure that data ends up in the correct places + $mbpath = $self->{replica}->run_mbpath('-u', 'cassandane'); + my $rdatadir = $mbpath->{data}; + my $rarchivedir = $mbpath->{archive}; + + # expect data and archive should be the same + $self->assert_str_equals($rdatadir, $rarchivedir); + + # all messages in same place + foreach my $id (1..6) { + $self->assert_file_test("$rdatadir/$id.", '-f'); + } + + foreach my $id (1..6) { + $self->assert_file_test("$rdatadir/$id.", '-f'); + } +} diff --git a/cassandane/tiny-tests/Replication/userflags b/cassandane/tiny-tests/Replication/userflags new file mode 100644 index 0000000000..531d3033cd --- /dev/null +++ b/cassandane/tiny-tests/Replication/userflags @@ -0,0 +1,98 @@ +#!perl +use Cassandane::Tiny; + +# +# Test replication of user-defined flags +# +sub test_userflags + :needs_component_replication +{ + my ($self) = @_; + + my $master_store = $self->{master_store}; + my $replica_store = $self->{replica_store}; + $master_store->set_fetch_attributes(qw(uid flags)); + $replica_store->set_fetch_attributes(qw(uid flags)); + + xlog $self, "generating messages A..D"; + my %exp; + $exp{A} = $self->make_message("Message A", + flags => ["\\Flagged", '$UserFlagA'], + store => $master_store); + $exp{B} = $self->make_message("Message B", + flags => [ '$UserFlagB' ], + store => $master_store); + $exp{C} = $self->make_message("Message C", + flags => [ '$UserFlagC' ], + store => $master_store); + $exp{D} = $self->make_message("Message D", + flags => [ '$UserFlagD' ], + store => $master_store); + + my $master_talk = $master_store->get_client(); + + xlog $self, "master PERMANENTFLAGS response should have all four flags"; + my $perm = $master_talk->get_response_code('permanentflags'); + my @flags = sort grep { !m{^\\} } @$perm; + $self->assert_deep_equals([ '$UserFlagA', + '$UserFlagB', + '$UserFlagC', + '$UserFlagD' ], \@flags); + + xlog $self, "clear some flags on master before replica ever sees them"; + $master_talk->store('1:4', '-flags', '($UserFlagC $UserFlagD)'); + $exp{C}->set_attribute(flags => undef); + $exp{D}->set_attribute(flags => undef); + + xlog $self, "master PERMANENTFLAGS response should still have all flags"; + $perm = $master_talk->get_response_code('permanentflags'); + @flags = sort grep { !m{^\\} } @$perm; + $self->assert_deep_equals([ '$UserFlagA', + '$UserFlagB', + '$UserFlagC', + '$UserFlagD' ], \@flags); + + my $replica_talk = $replica_store->get_client(); + + xlog $self, "replica PERMANENTFLAGS response should have no userflags"; + $perm = $replica_talk->get_response_code('permanentflags'); + @flags = sort grep { !m{^\\} } @$perm; + $self->assert_deep_equals([], \@flags); + + xlog $self, "Before replication, the master should have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "Before replication, the replica should have no messages"; + $self->check_messages({}, store => $replica_store); + + $self->run_replication(); + $self->check_replication('cassandane'); + + xlog $self, "After replication, the master should still have all four messages"; + $self->check_messages(\%exp, store => $master_store); + xlog $self, "After replication, the replica should now have all four messages"; + $self->check_messages(\%exp, store => $replica_store); + + xlog $self, "master PERMANENTFLAGS response should still have all flags"; + $master_store->disconnect(); + $master_store->connect(); + $master_store->_select(); + $master_talk = $master_store->get_client(); + $perm = $master_talk->get_response_code('permanentflags'); + @flags = sort grep { !m{^\\} } @$perm; + $self->assert_deep_equals([ '$UserFlagA', + '$UserFlagB', + '$UserFlagC', + '$UserFlagD' ], \@flags); + + xlog $self, "replica PERMANENTFLAGS response should now have all flags"; + $replica_store->disconnect(); + $replica_store->connect(); + $replica_store->_select(); + $replica_talk = $replica_store->get_client(); + $perm = $replica_talk->get_response_code('permanentflags'); + @flags = sort grep { !m{^\\} } @$perm; + $self->assert_deep_equals([ '$UserFlagA', + '$UserFlagB', + '$UserFlagC', + '$UserFlagD' ], \@flags); +} diff --git a/cassandane/tiny-tests/Replication/userprefix b/cassandane/tiny-tests/Replication/userprefix new file mode 100644 index 0000000000..2556ee6e54 --- /dev/null +++ b/cassandane/tiny-tests/Replication/userprefix @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +# this is testing a bug where DELETED namespace lookup in mboxlist_mboxtree +# wasn't correctly looking only for children of that name, so it would try +# to delete the wrong user's mailbox. +sub test_userprefix + :DelayedDelete :needs_component_replication +{ + my ($self) = @_; + $self->{instance}->create_user("ua"); + $self->{instance}->create_user("uab"); + + my $mastersvc = $self->{instance}->get_service('imap'); + my $astore = $mastersvc->create_store(username => "ua"); + my $atalk = $astore->get_client(); + my $bstore = $mastersvc->create_store(username => "uab"); + my $btalk = $bstore->get_client(); + + xlog "Creating some users with some deleted mailboxes"; + $atalk->create("INBOX.hi"); + $atalk->create("INBOX.no"); + $atalk->delete("INBOX.hi"); + + $btalk->create("INBOX.boo"); + $btalk->create("INBOX.noo"); + $btalk->delete("INBOX.boo"); + + $self->run_replication(user => "ua"); + $self->run_replication(user => "uab"); + + my $masterstore = $mastersvc->create_store(username => 'admin'); + my $admintalk = $masterstore->get_client(); + + xlog "Deleting the user with the prefix name"; + $admintalk->delete("user.ua"); + $self->run_replication(user => "ua"); + $self->run_replication(user => "uab"); + # This would fail at the end with syslog IOERRORs before the bugfix: + # >1580698085>S1 SYNCAPPLY UNUSER ua + # <1580698085<* BYE Fatal error: Internal error: assertion failed: imap/mboxlist.c: 868: user_isnamespacelocked(userid) + # 0248020101/sync_client[20041]: IOERROR: UNUSER received * response: + # Error from sync_do_user(ua): bailing out! +} diff --git a/cassandane/tiny-tests/SearchFuzzy/audit_unindexed b/cassandane/tiny-tests/SearchFuzzy/audit_unindexed new file mode 100644 index 0000000000..34e971c1c4 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/audit_unindexed @@ -0,0 +1,63 @@ +#!perl +use Cassandane::Tiny; + +sub test_audit_unindexed + :min_version_3_1 :needs_component_jmap +{ + # This test does some sneaky things to cyrus.indexed.db to force squatter + # report audit errors. It assumes a specific format for cyrus.indexed.db + # and Cyrus to preserve UIDVALDITY across two consecutive APPENDs. + # As such, it's likely to break for internal changes. + + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $basedir = $self->{instance}->{basedir}; + my $outfile = "$basedir/audit.tmp"; + + *_readfile = sub { + open FH, '<', $outfile + or die "Cannot open $outfile for reading: $!"; + my @entries = readline(FH); + close FH; + return @entries; + }; + + xlog $self, "Create message UID 1 and index it in Xapian and cyrus.indexed.db."; + $self->make_message() || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog $self, "Create message UID 2 but *don't* index it."; + $self->make_message() || die; + + my $data = $self->{instance}->run_mbpath(-u => 'cassandane'); + my $xapdir = $data->{xapian}{t1}; + + xlog $self, "Read current cyrus.indexed.db."; + my ($key, $val); + my $result = $self->{instance}->run_dbcommand_cb(sub { + my ($k, $v) = @_; + return if $k =~ m/\*V\*/; + $self->assert_null($key); + ($key, $val) = ($k, $v); + }, "$xapdir/xapian/cyrus.indexed.db", "twoskip", ['SHOW']); + $self->assert_str_equals('ok', $result); + $self->assert_not_null($key); + $self->assert_not_null($val); + + xlog $self, "Add UID 2 to sequence set in cyrus.indexed.db"; + $self->{instance}->run_dbcommand("$xapdir/xapian/cyrus.indexed.db", "twoskip", ['SET', $key, $val . ':2']); + + xlog $self, "Run squatter audit"; + $result = $self->{instance}->run_command( + { + cyrus => 1, + redirects => { stdout => $outfile }, + }, + 'squatter', '-A' + ); + my @audits = _readfile(); + $self->assert_num_equals(1, scalar @audits); + $self->assert_str_equals("Unindexed message(s) in user.cassandane: 2 \n", $audits[0]); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/cjk_words b/cassandane/tiny-tests/SearchFuzzy/cjk_words new file mode 100644 index 0000000000..87b9b62aa1 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/cjk_words @@ -0,0 +1,89 @@ +#!perl +use Cassandane::Tiny; + +sub test_cjk_words + :min_version_3_0 :needs_search_xapian + :needs_search_xapian_cjk_tokens(words) +{ + my ($self) = @_; + + xlog $self, "Generate and index test messages."; + +use utf8; + my $body = "明末時已經有香港地方的概念"; +no utf8; + $body = encode_base64(encode('UTF-8', $body)); + $body =~ s/\r?\n/\r\n/gs; + my %params = ( + mime_charset => "utf-8", + mime_encoding => 'base64', + body => $body, + ); + $self->make_message("1", %params) || die; + + # Splits into the words: "み, 円, 月額, 申込 +use utf8; + $body = "申込み!月額円"; +no utf8; + $body = encode_base64(encode('UTF-8', $body)); + $body =~ s/\r?\n/\r\n/gs; + %params = ( + mime_charset => "utf-8", + mime_encoding => 'base64', + body => $body, + ); + $self->make_message("2", %params) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + # Connect to IMAP + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + my $term; + # Search for a two-character CJK word +use utf8; + $term = "已經"; +no utf8; + xlog $self, "Get snippets for FUZZY text \"$term\""; + $r = $self->get_snippets('INBOX', $uids, { text => $term }); + $self->assert_num_not_equals(index($r->{snippets}[0][3], "$term"), -1); + + # Search for the CJK words 明末 and 時, note that the + # word order is reversed to the original message +use utf8; + $term = "時明末"; +no utf8; + xlog $self, "Get snippets for FUZZY text \"$term\""; + $r = $self->get_snippets('INBOX', $uids, { text => $term }); + $self->assert_num_equals(scalar @{$r->{snippets}}, 1); + + # Search for the partial CJK word 月 +use utf8; + $term = "月"; +no utf8; + xlog $self, "Get snippets for FUZZY text \"$term\""; + $r = $self->get_snippets('INBOX', $uids, { text => $term }); + $self->assert_num_equals(scalar @{$r->{snippets}}, 0); + + # Search for the interleaved, partial CJK word 額申 +use utf8; + $term = "額申"; +no utf8; + xlog $self, "Get snippets for FUZZY text \"$term\""; + $r = $self->get_snippets('INBOX', $uids, { text => $term }); + $self->assert_num_equals(scalar @{$r->{snippets}}, 0); + + # Search for three of four words: "み, 月額, 申込", + # in different order than the original. +use utf8; + $term = "月額み申込"; +no utf8; + xlog $self, "Get snippets for FUZZY text \"$term\""; + $r = $self->get_snippets('INBOX', $uids, { text => $term }); + $self->assert_num_equals(scalar @{$r->{snippets}}, 1); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/copy_messages b/cassandane/tiny-tests/SearchFuzzy/copy_messages new file mode 100644 index 0000000000..038c25d50a --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/copy_messages @@ -0,0 +1,18 @@ +#!perl +use Cassandane::Tiny; + +sub test_copy_messages + :needs_search_xapian +{ + my ($self) = @_; + + $self->create_testmessages(); + + my $talk = $self->{store}->get_client(); + $talk->create("INBOX.foo"); + $talk->select("INBOX"); + $talk->copy("1:*", "INBOX.foo"); + + xlog $self, "Run squatter again"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-i'); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/dedup_part_compact b/cassandane/tiny-tests/SearchFuzzy/dedup_part_compact new file mode 100644 index 0000000000..35a7dfa344 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/dedup_part_compact @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_dedup_part_compact + :min_version_3_3 :needs_search_xapian +{ + my ($self) = @_; + + my $xapdirs = ($self->{instance}->run_mbpath(-u => 'cassandane'))->{xapian}; + + xlog "force duplicate part into index"; + $self->make_message('msgA', body => 'part1') || die; + $self->make_message('msgB', body => 'part1') || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-D'); + + xlog "assert duplicated parts"; + my ($gdocs, $parts) = $self->delve_docs($xapdirs->{t1} . "/xapian"); + $self->assert_num_equals(2, scalar @$parts); + $self->assert_str_equals(@$parts[0], @$parts[1]); + $self->assert_num_equals(2, scalar @$gdocs); + + xlog "compact and filter to t2 tier"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-z', 't2', '-t', 't1', '-F'); + + xlog "assert parts got deduplicated"; + ($gdocs, $parts) = $self->delve_docs($xapdirs->{t2} . "/xapian"); + $self->assert_num_equals(1, scalar @$parts); + $self->assert_num_equals(2, scalar @$gdocs); + + xlog "force duplicate part into t1 index"; + $self->make_message('msgC', body => 'part1') || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-i', '-D'); + + xlog "compact and filter to t3 tier"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-z', 't3', '-t', 't1,t2', '-F'); + + xlog "assert parts got deduplicated"; + ($gdocs, $parts) = $self->delve_docs($xapdirs->{t3} . "/xapian"); + $self->assert_num_equals(1, scalar @$parts); + $self->assert_num_equals(3, scalar @$gdocs); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/dedup_part_index b/cassandane/tiny-tests/SearchFuzzy/dedup_part_index new file mode 100644 index 0000000000..6c0bee17a8 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/dedup_part_index @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_dedup_part_index + :min_version_3_3 :needs_search_xapian +{ + my ($self) = @_; + + my $xapdirs = ($self->{instance}->run_mbpath(-u => 'cassandane'))->{xapian}; + + $self->make_message('msgA', body => 'part1') || die; + $self->make_message('msgB', body => 'part2') || die; + + xlog "create duplicate part within the same indexing batch"; + $self->make_message('msgC', body => 'part1') || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "create duplicate part in another indexing batch"; + $self->make_message('msgD', body => 'part1') || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-i'); + + xlog "assert deduplicated parts"; + my $delveout = $self->run_delve($xapdirs->{t1} . '/xapian', '-V0'); + $delveout =~ s/^Value 0 for each document: //; + my @docs = split ' ', $delveout; + my @parts = map { $_ =~ /^\d+:\*P\*/ ? substr($_, 5) : () } @docs; + my @gdocs = map { $_ =~ /^\d+:\*G\*/ ? substr($_, 5) : () } @docs; + $self->assert_num_equals(2, scalar @parts); + $self->assert_str_not_equals($parts[0], $parts[1]); + $self->assert_num_equals(4, scalar @gdocs); + + xlog "compact to t2 tier"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-z', 't2', '-t', 't1'); + + xlog "create duplicate part in top tier"; + $self->make_message('msgD', body => 'part1') || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-i'); + + xlog "Assert deduplicated parts across tiers"; + $delveout = $self->run_delve($xapdirs->{t1}. '/xapian.1', '-V0'); + $delveout =~ s/^Value 0 for each document: //; + @docs = split ' ', $delveout; + @parts = map { $_ =~ /^\d+:\*P\*/ ? substr($_, 5) : () } @docs; + @gdocs = map { $_ =~ /^\d+:\*G\*/ ? substr($_, 5) : () } @docs; + $self->assert_num_equals(0, scalar @parts); + $self->assert_num_equals(1, scalar @gdocs); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/detect_language b/cassandane/tiny-tests/SearchFuzzy/detect_language new file mode 100644 index 0000000000..54b45b567f --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/detect_language @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_detect_language + :min_version_3_2 :needs_search_xapian :needs_dependency_cld2 :SearchLanguage +{ + my ($self) = @_; + + $self->make_message("german", + mime_type => 'text/plain', + mime_charset => 'utf-8', + mime_encoding => 'quoted-printable', + body => '' + . "Der Ballon besa=C3=9F eine gewaltige Gr=C3=B6=C3=9Fe, er trug einen Korb, g=\r\n" + . "ro=C3=9F und ger=C3=A4umig und offenbar f=C3=BCr einen l=C3=A4ngeren Aufenthalt\r\n" + . "hergeric=htet. Die zwei M=C3=A4nner, welche sich darin befanden, schienen\r\n" + . "erfahrene Luftschiff=er zu sein, das sah man schon daraus, wie ruhig sie trotz\r\n" + . "der ungeheuren H=C3=B6he atmeten." + ); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + my $uids = $talk->search('fuzzy', 'body', 'atmet'); + $self->assert_deep_equals([1], $uids); + + my $r = $talk->select("INBOX") || die; + $r = $self->get_snippets('INBOX', $uids, { body => 'atmet' }); +use utf8; + $self->assert_num_not_equals(-1, index($r->{snippets}[0][3], ' Höhe atmeten.')); +no utf8; +} diff --git a/cassandane/tiny-tests/SearchFuzzy/detect_language_subject b/cassandane/tiny-tests/SearchFuzzy/detect_language_subject new file mode 100644 index 0000000000..6581ca5e37 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/detect_language_subject @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_detect_language_subject + :min_version_3_2 :needs_search_xapian :needs_dependency_cld2 :SearchLanguage +{ + my ($self) = @_; + + my $body = "" + . "--boundary\r\n" + . "Content-Type: text/plain;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "Hoch oben in den L=C3=BCften =C3=BCber den reichgesegneten Landschaften des\r\n" + . "s=C3=BCdlichen Frankreichs schwebte eine gewaltige dunkle Kugel.\r\n" + . "\r\n" + . "Ein Luftballon war es, der, in der Nacht aufgefahren, eine lange\r\n" + . "Dauerfahrt antreten wollte.\r\n" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: text/plain;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "The Bellman, who was almost morbidly sensitive about appearances, used\r\n" + . "to have the bowsprit unshipped once or twice a week to be revarnished,\r\n" + . "and it more than once happened, when the time came for replacing it,\r\n" + . "that no one on board could remember which end of the ship it belonged to.\r\n" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: text/plain;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "Verri=C3=A8res est abrit=C3=A9e du c=C3=B4t=C3=A9 du nord par une haute mon=\r\n" + . "tagne, c'est une\r\n" + . "des branches du Jura. Les cimes bris=C3=A9es du Verra se couvrent de neige\r\n" + . "d=C3=A8s les premiers froids d'octobre. Un torrent, qui se pr=C3=A9cipite d=\r\n" + . "e la\r\n" + . "montagne, traverse Verri=C3=A8res avant de se jeter dans le Doubs et donne =\r\n" + . "le\r\n" + . "mouvement =C3=A0 un grand nombre de scies =C3=A0 bois; c'est une industrie =\r\n" + . "--boundary--\r\n"; + + $self->make_message("A subject with the German word Landschaften", + mime_type => "multipart/mixed", + mime_boundary => "boundary", + body => $body + ); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + my $uids = $talk->search('fuzzy', 'subject', 'Landschaft'); + $self->assert_deep_equals([1], $uids); + + my $r = $talk->select("INBOX") || die; + $r = $self->get_snippets('INBOX', $uids, { subject => 'Landschaft' }); + $self->assert_str_equals( + 'A subject with the German word Landschaften', + $r->{snippets}[0][3] + ); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/fuzzyalways_annot b/cassandane/tiny-tests/SearchFuzzy/fuzzyalways_annot new file mode 100644 index 0000000000..9ff34fbfdc --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/fuzzyalways_annot @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_fuzzyalways_annot + :min_version_3_3 :needs_search_xapian :SearchFuzzyAlways +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + + $self->make_message('test', body => 'body') || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Assert IMAP SEARCH uses fuzzy search by default"; + + # Fuzzy search uses stemming. + my $uids = $imap->search('body', 'bodies') || die; + $self->assert_deep_equals([1], $uids); + # But does not do substring search. + $uids = $imap->search('body', 'bod') || die; + $self->assert_deep_equals([], $uids); + + xlog "Disable fuzzy search with annotation"; + my $entry = '/shared/vendor/cmu/cyrus-imapd/search-fuzzy-always'; + + # Must not set any mailbox other than INBOX. + $imap->create("INBOX.foo") or die "create INBOX.foo: $@"; + $imap->setmetadata('INBOX.foo', $entry, 'off'); + $self->assert_str_equals('no', $imap->get_last_completion_response()); + # Must set a valid imapd.conf switch value. + $imap->setmetadata('INBOX', $entry, 'x'); + $self->assert_str_equals('no', $imap->get_last_completion_response()); + # Set annotation value. + $imap->setmetadata('INBOX', $entry, 'off'); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); + + xlog "Assert annotation overrides IMAP SEARCH default"; + + # Regular search does no stemming. + $uids = $imap->search('body', 'bodies') || die; + $self->assert_deep_equals([], $uids); + # But does substring search. + $uids = $imap->search('body', 'bod') || die; + $self->assert_deep_equals([1], $uids); + + xlog "Remove annotation and fall back to config"; + $imap->setmetadata('INBOX', $entry, undef); + $self->assert_str_equals('ok', $imap->get_last_completion_response()); + + # Fuzzy search uses stemming. + $uids = $imap->search('body', 'bodies') || die; + $self->assert_deep_equals([1], $uids); + # But does not do substring search. + $uids = $imap->search('body', 'bod') || die; + $self->assert_deep_equals([], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/html_only b/cassandane/tiny-tests/SearchFuzzy/html_only new file mode 100644 index 0000000000..24656b7291 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/html_only @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_html_only + :min_version_3_3 :needs_search_xapian +{ + my ($self) = @_; + my $talk = $self->{store}->get_client(); + + xlog "Index message with both html and plain text part"; + $self->make_message("test", + mime_type => "text/html", + body => "" + . "\r\n" + . "
This is an html LL123 xyzzy body.
\r\n" + . "{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Assert that HTML in plain text is stripped"; + my $uids = $talk->search('fuzzy', 'body', 'html') || die; + $self->assert_deep_equals([1], $uids); + + $uids = $talk->search('fuzzy', 'body', 'div') || die; + $self->assert_deep_equals([], $uids); + + # make sure the "p" doesn't leak into a token + $uids = $talk->search('fuzzy', 'body', 'LL123p') || die; + $self->assert_deep_equals([], $uids); + + # make sure the real token gets indexed + $uids = $talk->search('fuzzy', 'body', 'LL123') || die; + $self->assert_deep_equals([1], $uids); + + # make sure the h11 doesn't leak + $uids = $talk->search('fuzzy', 'body', 'xyzzy1') || die; + $self->assert_deep_equals([], $uids); + $uids = $talk->search('fuzzy', 'body', 'xyzzy') || die; + $self->assert_deep_equals([1], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/mix_fuzzy_and_nonfuzzy b/cassandane/tiny-tests/SearchFuzzy/mix_fuzzy_and_nonfuzzy new file mode 100644 index 0000000000..cb8aa104d2 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/mix_fuzzy_and_nonfuzzy @@ -0,0 +1,20 @@ +#!perl +use Cassandane::Tiny; + +sub test_mix_fuzzy_and_nonfuzzy + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + $self->create_testmessages(); + my $talk = $self->{store}->get_client(); + + xlog $self, "Select INBOX"; + $talk->select("INBOX") || die; + + xlog $self, "SEARCH for from \"foo\@example.com\" with FUZZY body \"connection\""; + my $r = $talk->search( + "fuzzy", ["body", { Quote => "connection" }], + "from", { Quote => "foo\@example.com" } + ) || die; + $self->assert_num_equals(2, scalar @$r); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/noindex_multipartheaders b/cassandane/tiny-tests/SearchFuzzy/noindex_multipartheaders new file mode 100644 index 0000000000..cd391324e7 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/noindex_multipartheaders @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_noindex_multipartheaders + :needs_search_xapian +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $body = "" + . "--boundary\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: application/octet-stream\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "\r\n" + . "SGVsbG8sIFdvcmxkIQ==" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "Return-Path: \r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: text/plain" + . "Content-Transfer-Encoding: 7bit\r\n" + . "Subject: baz\r\n" + . "From: blu\@local\r\n" + . "Message-ID: \r\n" + . "Date: Wed, 06 Oct 2016 14:59:07 +1100\r\n" + . "To: Test User \r\n" + . "\r\n" + . "embedded" + . "\r\n" + . "--boundary--\r\n"; + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "boundary", + body => $body + ); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $r; + + $r = $talk->search( + "header", "Content-Type", { Quote => "multipart/mixed" } + ) || die; + $self->assert_num_equals(1, scalar @$r); + + # Don't index the headers of multiparts or embedded RFC822s + $r = $talk->search( + "header", "Content-Type", { Quote => "text/plain" } + ) || die; + $self->assert_num_equals(0, scalar @$r); + $r = $talk->search( + "fuzzy", "body", { Quote => "text/plain" } + ) || die; + $self->assert_num_equals(0, scalar @$r); + $r = $talk->search( + "fuzzy", "text", { Quote => "content" } + ) || die; + $self->assert_num_equals(0, scalar @$r); + + # But index the body of an embedded RFC822 + $r = $talk->search( + "fuzzy", "body", { Quote => "embedded" } + ) || die; + $self->assert_num_equals(1, scalar @$r); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/normalize_snippets b/cassandane/tiny-tests/SearchFuzzy/normalize_snippets new file mode 100644 index 0000000000..d517be2324 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/normalize_snippets @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_normalize_snippets + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + # Set up test message with funny characters +use utf8; + my @terms = ( "gären", "советской", "diĝir", "naïve", "léger" ); +no utf8; + my $body = encode_base64(encode('UTF-8', join(' ', @terms))); + $body =~ s/\r?\n/\r\n/gs; + + xlog $self, "Generate and index test messages."; + my %params = ( + mime_charset => "utf-8", + mime_encoding => 'base64', + body => $body, + ); + $self->make_message("1", %params) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + # Connect to IMAP + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + # Assert that diacritics are matched and returned + foreach my $term (@terms) { + $r = $self->get_snippets('INBOX', $uids, { text => $term }); + $self->assert_num_not_equals(index($r->{snippets}[0][3], "$term"), -1); + } + + # Assert that search without diacritics matches + if ($self->{skipdiacrit}) { + my $term = "naive"; + xlog $self, "Get snippets for FUZZY text \"$term\""; + $r = $self->get_snippets('INBOX', $uids, { 'text' => $term }); +use utf8; + $self->assert_num_not_equals(index($r->{snippets}[0][3], "naïve"), -1); +no utf8; + } + +} diff --git a/cassandane/tiny-tests/SearchFuzzy/not_match b/cassandane/tiny-tests/SearchFuzzy/not_match new file mode 100644 index 0000000000..69ea340323 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/not_match @@ -0,0 +1,22 @@ +#!perl +use Cassandane::Tiny; + +sub test_not_match + :min_version_3_0 :needs_search_xapian :needs_dependency_cld2 +{ + my ($self) = @_; + my $imap = $self->{store}->get_client(); + my $store = $self->{store}; + + $imap->create("INBOX.A") or die; + $store->set_folder("INBOX.A"); + $self->make_message('fwd subject', body => 'a schenectady body'); + $self->make_message('chad subject', body => 'a futz body'); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + $talk->select("INBOX.A"); + my $uids = $talk->search('fuzzy', 'not', 'text', 'schenectady'); + $self->assert_deep_equals([2], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/reindex_mb_uniqueid b/cassandane/tiny-tests/SearchFuzzy/reindex_mb_uniqueid new file mode 100644 index 0000000000..697465b849 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/reindex_mb_uniqueid @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_reindex_mb_uniqueid + :min_version_3_7 :needs_search_xapian +{ + my ($self) = @_; + + my $xapdirs = ($self->{instance}->run_mbpath(-u => 'cassandane'))->{xapian}; + my $basedir = $self->{instance}->{basedir}; + + $self->make_message('msgA', body => 'part1') || die; + $self->make_message('msgB', body => 'part1') || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-D'); + + xlog "compact and reindex tier"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v', '-z', 't2', '-t', 't1', '-T', 't1:0'); + + xlog "dump t2:cyrus.indexed.db"; + # assumes twoskip backend and version 2 format keys + my $srcfile = $xapdirs->{t2} . '/xapian/cyrus.indexed.db'; + my $dstfile = $basedir . '/tmp/cyrus.indexed.db.flat'; + $self->{instance}->run_command({cyrus => 1}, 'cvt_cyrusdb', $srcfile, 'twoskip', $dstfile, 'flat'); + + xlog "assert reindexed tier contains a mailbox key"; + open(FH, "<$dstfile") || die; + my @mboxrows = grep { /^\*M\*[0-9a-zA-z\-_]+\*/ } ; + close FH; + $self->assert_num_equals(1, scalar @mboxrows); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/search_exactmatch b/cassandane/tiny-tests/SearchFuzzy/search_exactmatch new file mode 100644 index 0000000000..866d07421f --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/search_exactmatch @@ -0,0 +1,40 @@ +#!perl +use Cassandane::Tiny; + +sub test_search_exactmatch + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + xlog $self, "Generate and index test messages."; + $self->make_message("test1", + body => "Test1 body with some long text and there is even more ". + "and more and more and more and more and more and more ". + "and more and more and some text and more and more and ". + "and more and more and more and more and more and more ". + "and almost at the end some other text that is a match ", + ) || die; + $self->make_message("test2", + body => "Test2 body with some other text", + ) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + xlog $self, 'SEARCH for FUZZY exact match'; + my $query = '"some text"'; + $uids = $talk->search('fuzzy', 'body', $query) || die; + $self->assert_num_equals(1, scalar @$uids); + + my %m; + $r = $self->get_snippets('INBOX', $uids, { body => $query }); + %m = map { lc($_->[2]) => $_->[3] } @{ $r->{snippets} }; + $self->assert(index($m{body}, "some text") != -1); + $self->assert(index($m{body}, "some long text") == -1); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/search_omit_html b/cassandane/tiny-tests/SearchFuzzy/search_omit_html new file mode 100644 index 0000000000..de089368bd --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/search_omit_html @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_search_omit_html + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + xlog $self, "Generate and index test messages."; + $self->make_message("toplevel", + mime_type => "text/html", + body => "
hello
" + ) || die; + + $self->make_message("embedded", + mime_type => "multipart/related", + mime_boundary => "boundary_1", + body => "" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "txt" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/html\r\n" + . "\r\n" + . "
world
" + . "\r\n--boundary_1--\r\n" + ) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + $uids = $talk->search('fuzzy', 'body', 'div') || die; + $self->assert_num_equals(0, scalar @$uids); + + $uids = $talk->search('fuzzy', 'body', 'hello') || die; + $self->assert_num_equals(1, scalar @$uids); + + $uids = $talk->search('fuzzy', 'body', 'world') || die; + $self->assert_num_equals(1, scalar @$uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/search_omit_ical b/cassandane/tiny-tests/SearchFuzzy/search_omit_ical new file mode 100644 index 0000000000..2d37baa144 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/search_omit_ical @@ -0,0 +1,119 @@ +#!perl +use Cassandane::Tiny; + +sub test_search_omit_ical + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + xlog $self, "Generate and index test messages."; + + $self->make_message("test", + mime_type => "multipart/related", + mime_boundary => "boundary_1", + body => "" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "txt body" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/calendar;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "BEGIN:VCALENDAR\r\n" + . "VERSION:2.0\r\n" + . "PRODID:-//CyrusIMAP.org/Cyrus 3.1.3-606//EN\r\n" + . "CALSCALE:GREGORIAN\r\n" + . "BEGIN:VTIMEZONE\r\n" + . "TZID:Europe/Vienna\r\n" + . "BEGIN:STANDARD\r\n" + . "DTSTART:19700101T000000\r\n" + . "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n" + . "TZOFFSETFROM:+0200\r\n" + . "TZOFFSETTO:+0100\r\n" + . "END:STANDARD\r\n" + . "BEGIN:DAYLIGHT\r\n" + . "DTSTART:19700101T000000\r\n" + . "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n" + . "TZOFFSETFROM:+0100\r\n" + . "TZOFFSETTO:+0200\r\n" + . "END:DAYLIGHT\r\n" + . "END:VTIMEZONE\r\n" + . "BEGIN:VEVENT\r\n" + . "SUMMARY:icalsummary\r\n" + . "DESCRIPTION:icaldesc\r\n" + . "LOCATION:icallocation\r\n" + . "CREATED:20180518T090306Z\r\n" + . "DTEND;TZID=Europe/Vienna:20180518T100000\r\n" + . "DTSTAMP:20180518T090306Z\r\n" + . "DTSTART;TZID=Europe/Vienna:20180518T090000\r\n" + . "LAST-MODIFIED:20180518T090306Z\r\n" + . "RRULE:FREQ=DAILY\r\n" + . "SEQUENCE:1\r\n" + . "SUMMARY:K=C3=A4se\r\n" + . "TRANSP:OPAQUE\r\n" + . "UID:1234567890\r\n" + . "END:VEVENT\r\n" + . "END:VCALENDAR\r\n" + . "\r\n--boundary_1--\r\n" + ) || die; + + $self->make_message("top", + mime_type => "text/calendar", + body => "" + . "BEGIN:VCALENDAR\r\n" + . "VERSION:2.0\r\n" + . "PRODID:-//CyrusIMAP.org/Cyrus 3.1.3-606//EN\r\n" + . "CALSCALE:GREGORIAN\r\n" + . "BEGIN:VTIMEZONE\r\n" + . "TZID:Europe/Vienna\r\n" + . "BEGIN:STANDARD\r\n" + . "DTSTART:19700101T000000\r\n" + . "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n" + . "TZOFFSETFROM:+0200\r\n" + . "TZOFFSETTO:+0100\r\n" + . "END:STANDARD\r\n" + . "BEGIN:DAYLIGHT\r\n" + . "DTSTART:19700101T000000\r\n" + . "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n" + . "TZOFFSETFROM:+0100\r\n" + . "TZOFFSETTO:+0200\r\n" + . "END:DAYLIGHT\r\n" + . "END:VTIMEZONE\r\n" + . "BEGIN:VEVENT\r\n" + . "SUMMARY:icalsummary\r\n" + . "DESCRIPTION:icaldesc\r\n" + . "LOCATION:icallocation\r\n" + . "CREATED:20180518T090306Z\r\n" + . "DTEND;TZID=Europe/Vienna:20180518T100000\r\n" + . "DTSTAMP:20180518T090306Z\r\n" + . "DTSTART;TZID=Europe/Vienna:20180518T090000\r\n" + . "LAST-MODIFIED:20180518T090306Z\r\n" + . "RRULE:FREQ=DAILY\r\n" + . "SEQUENCE:1\r\n" + . "TRANSP:OPAQUE\r\n" + . "UID:1234567890\r\n" + . "END:VEVENT\r\n" + . "END:VCALENDAR\r\n" + ) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + $uids = $talk->search('fuzzy', 'text', 'rrule') || die; + $self->assert_num_equals(0, scalar @$uids); + + $uids = $talk->search('fuzzy', 'subject', 'icalsummary') || die; + $self->assert_num_equals(2, scalar @$uids); + + $uids = $talk->search('fuzzy', 'text', 'icaldesc') || die; + $self->assert_num_equals(2, scalar @$uids); + + $uids = $talk->search('fuzzy', 'text', 'icallocation') || die; + $self->assert_num_equals(2, scalar @$uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/search_omit_vcard b/cassandane/tiny-tests/SearchFuzzy/search_omit_vcard new file mode 100644 index 0000000000..3fbff67f5c --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/search_omit_vcard @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_search_omit_vcard + :min_version_3_9 :needs_search_xapian +{ + my ($self) = @_; + + xlog $self, "Generate and index test messages."; + + $self->make_message("test", + mime_type => "multipart/related", + mime_boundary => "boundary_1", + body => "" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "txt body" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/vcard;charset=utf-8\r\n" + . "Content-Transfer-Encoding: quoted-printable\r\n" + . "\r\n" + . "BEGIN:VCARD\r\n" + . "VERSION:3.0\r\n" + . "UID:1234567890\r\n" + . "BDAY:1944-06-07\r\n" + . "N:Gump;Forrest;;Mr.\r\n" + . "FN:Forrest Gump\r\n" + . "ORG;PROP-ID=O1:Bubba Gump Shrimp Co.\r\n" + . "TITLE;PROP-ID=T1:Shrimp Man\r\n" + . "PHOTO;PROP-ID=P1;ENCODING=b;TYPE=JPEG:c29tZSBwaG90bw==\r\n" + . "foo.ADR;PROP-ID=A1:;;1501 Broadway;New York;NY;10036;USA\r\n" + . "foo.GEO:40.7571383482188;-73.98695548990568\r\n" + . "foo.TZ:-05:00\r\n" + . "EMAIL;TYPE=PREF:bgump\@example.com\r\n" + . "X-SOCIAL-PROFILE:https://example.com/\@bubba" + . "REV:2008-04-24T19:52:43Z\r\n" + . "END:VCARD\r\n" + . "\r\n--boundary_1--\r\n" + ) || die; + + $self->make_message("top", + mime_type => "text/vcard", + body => "" + . "BEGIN:VCARD\r\n" + . "VERSION:3.0\r\n" + . "UID:1234567890\r\n" + . "BDAY:1944-06-07\r\n" + . "N:Gump;Forrest;;Mr.\r\n" + . "FN:Forrest Gump\r\n" + . "ORG;PROP-ID=O1:Bubba Gump Shrimp Co.\r\n" + . "TITLE;PROP-ID=T1:Shrimp Man\r\n" + . "PHOTO;PROP-ID=P1;ENCODING=b;TYPE=JPEG:c29tZSBwaG90bw==\r\n" + . "foo.ADR;PROP-ID=A1:;;1501 Broadway;New York;NY;10036;USA\r\n" + . "foo.GEO:40.7571383482188;-73.98695548990568\r\n" + . "foo.TZ:-05:00\r\n" + . "EMAIL;TYPE=PREF:bgump\@example.com\r\n" + . "X-SOCIAL-PROFILE:https://example.com/\@bubba" + . "REV:2008-04-24T19:52:43Z\r\n" + . "END:VCARD\r\n" + ) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + $uids = $talk->search('fuzzy', 'text', '1944') || die; + $self->assert_num_equals(0, scalar @$uids); + + $uids = $talk->search('fuzzy', 'text', 'Forrest') || die; + $self->assert_num_equals(2, scalar @$uids); + + $uids = $talk->search('fuzzy', 'text', 'Mr.') || die; + $self->assert_num_equals(2, scalar @$uids); + + $uids = $talk->search('fuzzy', 'text', 'Shrimp') || die; + $self->assert_num_equals(2, scalar @$uids); + + $uids = $talk->search('fuzzy', 'text', 'example') || die; + $self->assert_num_equals(2, scalar @$uids); + + $uids = $talk->search('fuzzy', 'text', 'https') || die; + $self->assert_num_equals(2, scalar @$uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/search_subjectsnippet b/cassandane/tiny-tests/SearchFuzzy/search_subjectsnippet new file mode 100644 index 0000000000..e249d8787c --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/search_subjectsnippet @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_search_subjectsnippet + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + xlog $self, "Generate and index test messages."; + $self->make_message("[plumbing] Re: log server v0 live", + body => "Test1 body with some long text and there is even more ". + "and more and more and more and more and more and more ". + "and more and more and some text and more and more and ". + "and more and more and more and more and more and more ". + "and almost at the end some other text that is a match ", + ) || die; + $self->make_message("test2", + body => "Test2 body with some other text", + ) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + xlog $self, 'SEARCH for FUZZY snippets'; + my $query = 'servers'; + $uids = $talk->search('fuzzy', 'text', $query) || die; + $self->assert_num_equals(1, scalar @$uids); + + my %m; + $r = $self->get_snippets('INBOX', $uids, { text => $query }); + %m = map { lc($_->[2]) => $_->[3] } @{ $r->{snippets} }; + $self->assert_matches(qr/^\[plumbing\]/, $m{subject}); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/skipdiacrit b/cassandane/tiny-tests/SearchFuzzy/skipdiacrit new file mode 100644 index 0000000000..ddd22f5014 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/skipdiacrit @@ -0,0 +1,53 @@ +#!perl +use Cassandane::Tiny; + +sub test_skipdiacrit + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + # Set up test messages + my $body = "Die Trauben gären."; + xlog $self, "Generate and index test messages."; + my %params = ( + mime_charset => "utf-8", + body => $body + ); + $self->make_message("1", %params) || die; + $body = "Gemüse schonend garen."; + %params = ( + mime_charset => "utf-8", + body => $body + ); + $self->make_message("2", %params) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + # Connect to IMAP + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + xlog $self, 'Search for "garen"'; + $r = $talk->search( + "charset", "utf-8", "fuzzy", ["text", { Quote => "garen" }], + ) || die; + if ($self->{skipdiacrit}) { + $self->assert_num_equals(2, scalar @$r); + } else { + $self->assert_num_equals(1, scalar @$r); + } + + xlog $self, 'Search for "gären"'; + $r = $talk->search( + "charset", "utf-8", "fuzzy", ["text", { Quote => "gären" }], + ) || die; + if ($self->{skipdiacrit}) { + $self->assert_num_equals(2, scalar @$r); + } else { + $self->assert_num_equals(1, scalar @$r); + } +} diff --git a/cassandane/tiny-tests/SearchFuzzy/snippet_wildcard b/cassandane/tiny-tests/SearchFuzzy/snippet_wildcard new file mode 100644 index 0000000000..cf9451c639 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/snippet_wildcard @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_snippet_wildcard + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + # Set up Xapian database + xlog $self, "Generate and index test messages"; + my %params = ( + mime_charset => "utf-8", + ); + my $subject; + my $body; + + $subject = "1"; + $body = "Waiter! There's a foo in my soup!"; + $params{body} = $body; + $self->make_message($subject, %params) || die; + + $subject = "2"; + $body = "Let's foop the loop."; + $params{body} = $body; + $self->make_message($subject, %params) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + my $term = "foo"; + xlog $self, "SEARCH for FUZZY body $term*"; + my $r = $talk->search( + "fuzzy", ["body", { Quote => "$term*" }], + ) || die; + $self->assert_num_equals(2, scalar @$r); + my $uids = $r; + + xlog $self, "Select INBOX"; + $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + + xlog $self, "Get snippets for $term"; + $r = $self->get_snippets('INBOX', $uids, { 'text' => "$term*" }); + $self->assert_num_equals(2, scalar @{$r->{snippets}}); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/snippets_escapehtml b/cassandane/tiny-tests/SearchFuzzy/snippets_escapehtml new file mode 100644 index 0000000000..24d66cad90 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/snippets_escapehtml @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_snippets_escapehtml + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + xlog $self, "Generate and index test messages."; + $self->make_message("Test1 subject with an unescaped & in it", + mime_charset => "utf-8", + mime_type => "text/html", + body => "Test1 body with the same tag as snippets" + ) || die; + + $self->make_message("Test2 subject with a in it", + mime_charset => "utf-8", + mime_type => "text/plain", + body => "Test2 body with a , although it's plain text", + ) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + # Connect to IMAP + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + my %m; + + $r = $self->get_snippets('INBOX', $uids, { 'text' => 'test1' }); + %m = map { lc($_->[2]) => $_->[3] } @{ $r->{snippets} }; + $self->assert_str_equals("Test1 body with the same tag as snippets", $m{body}); + $self->assert_str_equals("Test1 subject with an unescaped & in it", $m{subject}); + + $r = $self->get_snippets('INBOX', $uids, { 'text' => 'test2' }); + %m = map { lc($_->[2]) => $_->[3] } @{ $r->{snippets} }; + $self->assert_str_equals("Test2 body with a <tag/>, although it's plain text", $m{body}); + $self->assert_str_equals("Test2 subject with a <tag> in it", $m{subject}); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/snippets_termcover b/cassandane/tiny-tests/SearchFuzzy/snippets_termcover new file mode 100644 index 0000000000..663b6df4e3 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/snippets_termcover @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_snippets_termcover + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + my $body = + "The 'charset' portion of an 'encoded-word' specifies the character ". + "set associated with the unencoded text. A 'charset' can be any of ". + "the character set names allowed in an MIME \"charset\" parameter of a ". + "\"text/plain\" body part, or any character set name registered with ". + "IANA for use with the MIME text/plain content-type. ". + "". + # Attempt to trick the snippet generator into picking the next two lines + "Here is a line with favourite but not without that other search word ". + "Here is another line with a favourite word but not the other one ". + "". + "Some character sets use code-switching techniques to switch between ". + "\"ASCII mode\" and other modes. If unencoded text in an 'encoded-word' ". + "contains a sequence which causes the charset interpreter to switch ". + "out of ASCII mode, it MUST contain additional control codes such that ". + "ASCII mode is again selected at the end of the 'encoded-word'. (This ". + "rule applies separately to each 'encoded-word', including adjacent ". + "encoded-word's within a single header field.) ". + "When there is a possibility of using more than one character set to ". + "represent the text in an 'encoded-word', and in the absence of ". + "private agreements between sender and recipients of a message, it is ". + "recommended that members of the ISO-8859-* series be used in ". + "preference to other character sets.". + "". + # This is the line we want to get as a snippet + "I don't have a favourite cereal. My favourite breakfast is oat meal."; + + xlog $self, "Generate and index test messages."; + my %params = ( + mime_charset => "utf-8", + body => $body + ); + $self->make_message("1", %params) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + # Connect to IMAP + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + my $want = "favourite cereal"; + + $r = $self->get_snippets('INBOX', $uids, { + operator => 'AND', + conditions => [{ + text => 'favourite', + }, { + text => 'cereal', + }, { + text => '"bogus gnarly"' + }], + }); + $self->assert_num_not_equals(-1, index($r->{snippets}[0][3], $want)); + + $r = $self->get_snippets('INBOX', $uids, { + text => 'favourite cereal', + }); + $self->assert_num_not_equals(-1, index($r->{snippets}[0][3], $want)); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cache b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cache new file mode 100644 index 0000000000..44a6f7d79a --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cache @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_squatter_attachextract_cache + :min_version_3_9 :needs_search_xapian :SearchAttachmentExtractor +{ + my ($self) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + my $tracedir = tempdir(DIR => $instance->{basedir} . "/tmp"); + $self->start_echo_extractor(tracedir => $tracedir); + + xlog "Create and index index messages"; + my $cachedir = tempdir(DIR => $instance->{basedir} . "/tmp"); + $self->squatter_attachextract_cache_run($cachedir); + + xlog "Assert text bodies of both messages are indexed"; + my $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1,2], $uids); + + xlog "Assert attachments of both messages are indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attachterm'); + $self->assert_deep_equals([1,2], $uids); + + xlog "Assert extractor only got called once"; + my @tracefiles = glob($tracedir."/*_PUT_*"); + $self->assert_num_equals(1, scalar @tracefiles); + + xlog "Assert cache contains one file"; + my @files = glob($cachedir."/*"); + $self->assert_num_equals(1, scalar @files); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cachedir_noperm b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cachedir_noperm new file mode 100644 index 0000000000..dcd9989379 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cachedir_noperm @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_squatter_attachextract_cachedir_noperm + :min_version_3_9 :needs_search_xapian :SearchAttachmentExtractor :NoCheckSyslog +{ + my ($self) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + my $tracedir = tempdir(DIR => $instance->{basedir} . "/tmp"); + $self->start_echo_extractor(tracedir => $tracedir); + + xlog "Run squatter with read-only cache directory"; + my $cachedir = tempdir(DIR => $instance->{basedir} . "/tmp"); + chmod 0400, $cachedir || die; + $self->squatter_attachextract_cache_run($cachedir, "--allow-partials"); + + xlog "Assert text bodies of both messages are indexed"; + my $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1,2], $uids); + + xlog "Assert attachments of both messages are not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attachterm'); + $self->assert_deep_equals([], $uids); + + xlog "Assert extractor got called twice with attachment uploads"; + my @tracefiles = glob($tracedir."/*_PUT_*"); + $self->assert_num_equals(2, scalar @tracefiles); + + xlog "Assert cache contains no file"; + chmod 0700, $cachedir || die; + my @files = glob($cachedir."/*"); + $self->assert_num_equals(0, scalar @files); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cacheonly b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cacheonly new file mode 100644 index 0000000000..c34f9462cb --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_cacheonly @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_squatter_attachextract_cacheonly + :min_version_3_9 :needs_search_xapian :SearchAttachmentExtractor :NoCheckSyslog +{ + my ($self) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + my $tracedir = tempdir(DIR => $instance->{basedir} . "/tmp"); + $self->start_echo_extractor(tracedir => $tracedir); + + xlog "Instruct squatter to only use attachextract cache"; + my $cachedir = tempdir(DIR => $instance->{basedir} . "/tmp"); + $self->squatter_attachextract_cache_run($cachedir, + "--attachextract-cache-only", "--allow-partials"); + + xlog "Assert text bodies of both messages are indexed"; + my $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1,2], $uids); + + xlog "Assert attachments of both messages are not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attachterm'); + $self->assert_deep_equals([], $uids); + + xlog "Assert extractor did not get got called"; + my @tracefiles = glob($tracedir."/*"); + $self->assert_num_equals(0, scalar @tracefiles); + + xlog "Assert cache contains no file"; + my @files = glob($cachedir."/*"); + $self->assert_num_equals(0, scalar @files); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_nolock b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_nolock new file mode 100644 index 0000000000..5624dd6cd9 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_nolock @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_squatter_attachextract_nolock + :min_version_3_9 :needs_search_xapian :SearchAttachmentExtractor +{ + my ($self) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + my $tracedir = tempdir(DIR => $instance->{basedir} . "/tmp"); + $self->start_echo_extractor( + tracedir => $tracedir, + trace_delay_seconds => 1, + response_delay_seconds => 1, + ); + + xlog $self, "Make plain text message"; + $self->make_message("msg1", + mime_type => "text/plain", + body => "bodyterm"); + + xlog $self, "Make message with attachment"; + $self->make_message("msg2", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/pdf\r\n" + ."Content-Transfer-Encoding: base64\r\n" + ."\r\n" + # that's "attachterm" + ."YXR0YWNodGVybQo=" + ."\r\n--123456789abcdef--\r\n"); + + xlog $self, "Clear syslog"; + $self->{instance}->getsyslog(); + + xlog $self, "Run squatter"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v'); + + xlog $self, "Inspect syslog and extractor trace files"; + my $released_timestamp = undef; + my $reacquired_timestamp = undef; + if ($self->{instance}->{have_syslog_replacement}) { + my @log = $self->{instance}->getsyslog( + qr/squatter\[\d+\]: (released|reacquired) mailbox lock/); + + ($released_timestamp) = ($log[0] =~ /released.+unixepoch=<(\d+)>/); + $self->assert_not_null($released_timestamp); + + ($reacquired_timestamp) = ($log[1] =~ /reacquired.+unixepoch=<(\d+)>/); + $self->assert_not_null($reacquired_timestamp); + } + + my @tracefiles = glob($tracedir."/*_PUT_*"); + $self->assert_num_equals(1, scalar @tracefiles); + my $extractor_timestamp = stat($tracefiles[0])->ctime; + $self->assert_not_null($extractor_timestamp); + + xlog $self, "Assert extractor got called without mailbox lock"; + if (defined $released_timestamp) { + $self->assert_num_lt($extractor_timestamp, $released_timestamp); + } + if (defined $reacquired_timestamp) { + $self->assert_num_lt($reacquired_timestamp, $extractor_timestamp); + } + + xlog $self, "Assert terms actually got indexed"; + my $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1,2], $uids); + + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attachterm'); + $self->assert_deep_equals([2], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_timeout b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_timeout new file mode 100644 index 0000000000..4002f7de9a --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_timeout @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_squatter_attachextract_timeout + :min_version_3_9 :needs_search_xapian :SearchAttachmentExtractor :NoCheckSyslog +{ + my ($self) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + my $tracedir = tempdir (DIR => $instance->{basedir} . "/tmp"); + + # SearchAttachmentExtractor magic configures Cyrus to + # wait at most 3 seconds for a response from extractor + + $self->start_echo_extractor( + tracedir => $tracedir, + response_delay_seconds => [5], # timeout on first request only + ); + + xlog $self, "Make message with attachment"; + $self->make_message("msg1", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/pdf\r\n" + ."\r\n" + ."attachterm" + ."\r\n--123456789abcdef--\r\n"); + + xlog $self, "Run squatter (allowing partials)"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v', '-p'); + + xlog "Assert text body is indexed"; + my $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1], $uids); + + xlog "Assert attachement is not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attachterm'); + $self->assert_deep_equals([], $uids); + + xlog "Assert extractor got called once"; + my @tracefiles = glob($tracedir."/*"); + $self->assert_num_equals(1, scalar @tracefiles); + $self->assert_matches(qr/req1_GET_/, $tracefiles[0]); + + xlog $self, "Rerun squatter for partials"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v', '-i', '-P'); + + xlog "Assert text body is indexed"; + $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1], $uids); + + xlog "Assert attachement is indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attachterm'); + $self->assert_deep_equals([1], $uids); + + xlog "Assert extractor got called three times"; + @tracefiles = glob($tracedir."/*"); + $self->assert_num_equals(3, scalar @tracefiles); + $self->assert_matches(qr/req1_GET_/, $tracefiles[0]); + $self->assert_matches(qr/req2_GET_/, $tracefiles[1]); + $self->assert_matches(qr/req3_PUT_/, $tracefiles[2]); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_unprocessable_content b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_unprocessable_content new file mode 100644 index 0000000000..bb39d4fbc8 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/squatter_attachextract_unprocessable_content @@ -0,0 +1,96 @@ +#!perl +use Cassandane::Tiny; + +sub test_squatter_attachextract_unprocessable_content + :min_version_3_9 :needs_search_xapian :SearchAttachmentExtractor :NoCheckSyslog +{ + my ($self) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + my $tracedir = tempdir (DIR => $instance->{basedir} . "/tmp"); + my $nrequests = 0; + + xlog "Start extractor server"; + my $handler = sub { + my ($conn, $req) = @_; + + $nrequests++; + + # touch trace file in tracedir + my @paths = split(q{/}, URI->new($req->uri)->path); + my $guid = pop(@paths); + my $fname = join(q{}, + $tracedir, "/req", $nrequests, "_", $req->method, "_$guid"); + open(my $fh, ">", $fname) or die "Can't open > $fname: $!"; + close $fh; + + my $res; + + if ($req->method eq 'HEAD') { + $res = HTTP::Response->new(404); + $res->content(""); + } elsif ($req->method eq 'GET') { + $res = HTTP::Response->new(404); + $res->content("nope"); + } else { + # return HTTP 422 Unprocessable Content + $res = HTTP::Response->new(422); + $res->content("nope"); + } + + $conn->send_response($res); + }; + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + $instance->start_httpd($handler, $uri->port()); + + xlog $self, "Make message with unprocessable attachment"; + $self->make_message("msg1", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/octet-stream\r\n" + ."\r\n" + ."attachterm" + ."\r\n--123456789abcdef--\r\n"); + + xlog $self, "Run squatter (allowing partials)"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v', '-p'); + + xlog "Assert text body is indexed"; + my $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1], $uids); + + xlog "Assert attachement is not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attachterm'); + $self->assert_deep_equals([], $uids); + + xlog "Assert extractor got called"; + my @tracefiles = glob($tracedir."/*"); + $self->assert_num_equals(2, scalar @tracefiles); + $self->assert_matches(qr/req1_GET_/, $tracefiles[0]); + $self->assert_matches(qr/req2_PUT_/, $tracefiles[1]); + + xlog $self, "Rerun squatter for partials"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v', '-i', '-P'); + + xlog "Assert text body is indexed"; + $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1], $uids); + + xlog "Assert attachement is not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attachterm'); + $self->assert_deep_equals([], $uids); + + xlog "Assert extractor got called no more time"; + @tracefiles = glob($tracedir."/*"); + $self->assert_num_equals(2, scalar @tracefiles); + $self->assert_matches(qr/req1_GET_/, $tracefiles[0]); + $self->assert_matches(qr/req2_PUT_/, $tracefiles[1]); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/squatter_partials b/cassandane/tiny-tests/SearchFuzzy/squatter_partials new file mode 100644 index 0000000000..503a543447 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/squatter_partials @@ -0,0 +1,102 @@ +#!perl +use Cassandane::Tiny; + +sub test_squatter_partials + :min_version_3_3 :needs_search_xapian :SearchAttachmentExtractor :NoCheckSyslog +{ + my ($self) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + + xlog "Start extractor server"; + my $nrequests = 0; + my $handler = sub { + my ($conn, $req) = @_; + if ($req->method eq 'HEAD') { + my $res = HTTP::Response->new(204); + $res->content(""); + $conn->send_response($res); + } else { + $nrequests++; + if ($nrequests <= 4) { + # attach1: squatter sends GET and retries PUT 3 times + $conn->send_error(500); + } elsif ($nrequests == 5) { + # attach2: squatter sends GET + my $res = HTTP::Response->new(200); + $res->content("attach2"); + $conn->send_response($res); + } elsif ($nrequests == 6) { + # attach1 retry: squatter sends GET + my $res = HTTP::Response->new(200); + $res->content("attach1"); + $conn->send_response($res); + } else { + xlog "Unexpected request"; + $conn->send_error(500); + } + } + }; + $instance->start_httpd($handler, $uri->port()); + + xlog "Append emails with PDF attachments to trigger extractor"; + $self->make_message("msg1", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/pdf\r\n" + ."\r\n" + ."attach1" + ."\r\n--123456789abcdef--\r\n" + ) || die; + $self->make_message("msg2", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/pdf\r\n" + ."\r\n" + ."attach2" + ."\r\n--123456789abcdef--\r\n" + ) || die; + + xlog "Run squatter and allow partials"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-p', '-Z'); + + xlog "Assert text bodies of both messages are indexed"; + my $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1,2], $uids); + + xlog "Assert attachment of first message is not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attach1'); + $self->assert_deep_equals([], $uids); + + xlog "Assert attachment of second message is indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attach2'); + $self->assert_deep_equals([2], $uids); + + xlog "Run incremental squatter without recovering partials"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-i'); + + xlog "Assert attachment of first message is not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attach1'); + $self->assert_deep_equals([], $uids); + + xlog "Run incremental squatter with recovering partials"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-i', '-P'); + + xlog "Assert attachment of first message is indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attach1'); + $self->assert_deep_equals([1], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/squatter_skip422 b/cassandane/tiny-tests/SearchFuzzy/squatter_skip422 new file mode 100644 index 0000000000..40f89ca0d9 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/squatter_skip422 @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_squatter_skip422 + :min_version_3_3 :needs_search_xapian :SearchAttachmentExtractor :NoCheckSyslog +{ + my ($self) = @_; + my $instance = $self->{instance}; + my $imap = $self->{store}->get_client(); + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + + xlog "Start extractor server"; + my $nrequests = 0; + my $handler = sub { + my ($conn, $req) = @_; + if ($req->method eq 'HEAD') { + my $res = HTTP::Response->new(204); + $res->content(""); + $conn->send_response($res); + } else { + $conn->send_error(422); + } + }; + $instance->start_httpd($handler, $uri->port()); + + xlog "Append emails with PDF attachments to trigger extractor"; + $self->make_message("msg1", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/pdf\r\n" + ."\r\n" + ."attach1" + ."\r\n--123456789abcdef--\r\n" + ) || die; + $self->make_message("msg2", + mime_type => "multipart/related", + mime_boundary => "123456789abcdef", + body => "" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: text/plain\r\n" + ."\r\n" + ."bodyterm" + ."\r\n--123456789abcdef\r\n" + ."Content-Type: application/pdf\r\n" + ."\r\n" + ."attach2" + ."\r\n--123456789abcdef--\r\n" + ) || die; + + xlog "Run squatter and allow partials"; + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-p', '-Z'); + + xlog "Assert text bodies of both messages are indexed"; + my $uids = $imap->search('fuzzy', 'body', 'bodyterm'); + $self->assert_deep_equals([1,2], $uids); + + xlog "Assert attachment of first message is not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attach1'); + $self->assert_deep_equals([], $uids); + + xlog "Assert attachment of second message is not indexed"; + $uids = $imap->search('fuzzy', 'xattachmentbody', 'attach2'); + $self->assert_deep_equals([], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/stem_any b/cassandane/tiny-tests/SearchFuzzy/stem_any new file mode 100644 index 0000000000..b31a8e6753 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/stem_any @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_stem_any + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + $self->create_testmessages(); + + my $talk = $self->{store}->get_client(); + + xlog $self, "Select INBOX"; + $talk->select("INBOX") || die; + + my $r; + xlog $self, 'SEARCH for body "connection"'; + $r = $talk->search('body', { Quote => "connection" }) || die; + if ($self->{fuzzyalways}) { + $self->assert_num_equals(3, scalar @$r); + } else { + $self->assert_num_equals(1, scalar @$r); + } + + + xlog $self, "SEARCH for FUZZY body \"connection\""; + $r = $talk->search( + "fuzzy", ["body", { Quote => "connection" }], + ) || die; + $self->assert_num_equals(3, scalar @$r); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/stem_verbs b/cassandane/tiny-tests/SearchFuzzy/stem_verbs new file mode 100644 index 0000000000..8812ffa588 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/stem_verbs @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_stem_verbs + :min_version_3_0 :needs_search_xapian :JMAPExtensions +{ + my ($self) = @_; + $self->create_testmessages(); + + my $talk = $self->{store}->get_client(); + $self->assert_not_null($self->{jmap}); + + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + xlog $self, 'SEARCH for subject "runs"'; + $r = $talk->search('subject', { Quote => "runs" }) || die; + if ($self->{fuzzyalways}) { + $self->assert_num_equals(3, scalar @$r); + } else { + $self->assert_num_equals(1, scalar @$r); + } + + xlog $self, 'SEARCH for FUZZY subject "runs"'; + $r = $talk->search('fuzzy', ['subject', { Quote => "runs" }]) || die; + $self->assert_num_equals(3, scalar @$r); + + xlog $self, 'Get snippets for FUZZY subject "runs"'; + $r = $self->get_snippets('INBOX', $uids, { subject => 'runs' }); + $self->assert_num_equals(3, scalar @{$r->{snippets}}); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/stopwords b/cassandane/tiny-tests/SearchFuzzy/stopwords new file mode 100644 index 0000000000..85c3f7a042 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/stopwords @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_stopwords + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + # This test assumes that "the" is a stopword and is configured with + # the search_stopword_path in cassandane.ini. If the option is not + # set it tests legacy behaviour. + + my $talk = $self->{store}->get_client(); + + # Set up Xapian database + xlog $self, "Generate and index test messages."; + my %params = ( + mime_charset => "utf-8", + ); + my $subject; + my $body; + + $subject = "1"; + $body = "In my opinion the soup smells tasty"; + $params{body} = $body; + $self->make_message($subject, %params) || die; + + $subject = "2"; + $body = "The funny thing is that this isn't funny"; + $params{body} = $body; + $self->make_message($subject, %params) || die; + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + # Connect via IMAP + xlog $self, "Select INBOX"; + $talk->select("INBOX") || die; + my $uidvalidity = $talk->get_response_code('uidvalidity'); + my $uids = $talk->search('1:*', 'NOT', 'DELETED'); + + my $term; + my $r; + + # Search for stopword only + $r = $talk->search( + "charset", "utf-8", "fuzzy", "text", "the", + ) || die; + $self->assert_num_equals(2, scalar @$r); + + # Search for stopword plus significant term + $r = $talk->search( + "charset", "utf-8", "fuzzy", "text", "the soup", + ) || die; + $self->assert_num_equals(1, scalar @$r); + + $r = $talk->search( + "charset", "utf-8", "fuzzy", "text", "the", "fuzzy", "text", "soup", + ) || die; + $self->assert_num_equals(1, scalar @$r); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/striphtml_alternative b/cassandane/tiny-tests/SearchFuzzy/striphtml_alternative new file mode 100644 index 0000000000..81a45c22eb --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/striphtml_alternative @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_striphtml_alternative + :min_version_3_3 :needs_search_xapian +{ + my ($self) = @_; + my $talk = $self->{store}->get_client(); + + xlog "Index message with both html and plain text part"; + $self->make_message("test", + mime_type => "multipart/alternative", + mime_boundary => "boundary_1", + body => "" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/plain; charset=\"UTF-8\"\r\n" + . "\r\n" + . "
This is a plain text body with html.
\r\n" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/html; charset=\"UTF-8\"\r\n" + . "\r\n" + . "
This is an html body.
\r\n" + . "\r\n--boundary_1--\r\n" + ) || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Assert that HTML in plain text is stripped"; + my $uids = $talk->search('fuzzy', 'body', 'html') || die; + $self->assert_deep_equals([1], $uids); + + $uids = $talk->search('fuzzy', 'body', 'div') || die; + $self->assert_deep_equals([], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/striphtml_plain b/cassandane/tiny-tests/SearchFuzzy/striphtml_plain new file mode 100644 index 0000000000..dfd78834d6 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/striphtml_plain @@ -0,0 +1,23 @@ +#!perl +use Cassandane::Tiny; + +sub test_striphtml_plain + :min_version_3_3 :needs_search_xapian +{ + my ($self) = @_; + my $talk = $self->{store}->get_client(); + + xlog "Index message with only plain text part"; + $self->make_message("test", + body => "" + . "
This is a plain text body with html.
\r\n" + ) || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Assert that HTML in plain-text only isn't stripped"; + my $uids = $talk->search('fuzzy', 'body', 'html') || die; + $self->assert_deep_equals([1], $uids); + + $uids = $talk->search('fuzzy', 'body', 'div') || die; + $self->assert_deep_equals([1], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/striphtml_rfc822 b/cassandane/tiny-tests/SearchFuzzy/striphtml_rfc822 new file mode 100644 index 0000000000..7124a68a79 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/striphtml_rfc822 @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_striphtml_rfc822 + :min_version_3_3 :needs_search_xapian +{ + my ($self) = @_; + my $talk = $self->{store}->get_client(); + + xlog "Index message with attached rfc822 message"; + $self->make_message("test", + mime_type => "multipart/mixed", + mime_boundary => "boundary_1", + body => "" + . "\r\n--boundary_1\r\n" + . "Content-Type: text/plain; charset=\"UTF-8\"\r\n" + . "\r\n" + . "
plain
\r\n" + . "\r\n--boundary_1\r\n" + . "Content-Type: message/rfc822\r\n" + . "\r\n" + . "Subject: bar\r\n" + . "From: from\@local\r\n" + . "Date: Wed, 05 Oct 2016 14:59:07 +1100\r\n" + . "To: to\@local\r\n" + . "Mime-Version: 1.0\r\n" + . "Content-Type: multipart/alternative; boundary=boundary_2\r\n" + . "\r\n" + . "\r\n--boundary_2\r\n" + . "Content-Type: text/plain; charset=\"UTF-8\"\r\n" + . "\r\n" + . "
embeddedplain with html.
\r\n" + . "\r\n--boundary_2\r\n" + . "Content-Type: text/html; charset=\"UTF-8\"\r\n" + . "\r\n" + . "
embeddedhtml.
\r\n" + . "\r\n--boundary_2--\r\n" + ) || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + xlog "Assert that HTML in top-level message isn't stripped"; + my $uids = $talk->search('fuzzy', 'body', 'main') || die; + $self->assert_deep_equals([1], $uids); + + xlog "Assert that HTML in embedded message plain text is stripped"; + $uids = $talk->search('fuzzy', 'body', 'div') || die; + $self->assert_deep_equals([], $uids); + $uids = $talk->search('fuzzy', 'body', 'html') || die; + $self->assert_deep_equals([1], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/subject_and_body_match b/cassandane/tiny-tests/SearchFuzzy/subject_and_body_match new file mode 100644 index 0000000000..a42d4db4af --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/subject_and_body_match @@ -0,0 +1,17 @@ +#!perl +use Cassandane::Tiny; + +sub test_subject_and_body_match + :min_version_3_0 :needs_search_xapian :needs_dependency_cld2 +{ + my ($self) = @_; + + $self->make_message('fwd subject', body => 'a schenectady body'); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + my $uids = $talk->search('fuzzy', 'text', 'fwd', 'text', 'schenectady'); + $self->assert_deep_equals([1], $uids); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/subject_isutf8 b/cassandane/tiny-tests/SearchFuzzy/subject_isutf8 new file mode 100644 index 0000000000..731b8563fd --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/subject_isutf8 @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_subject_isutf8 + :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + + xlog $self, "Generate and index test messages."; + # that's: "nuff réunion critères duff" + my $subject = "=?utf-8?q?nuff_r=C3=A9union_crit=C3=A8res_duff?="; + my $body = "empty"; + my %params = ( + mime_charset => "utf-8", + body => $body + ); + $self->make_message($subject, %params) || die; + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $talk = $self->{store}->get_client(); + + # Connect to IMAP + xlog $self, "Select INBOX"; + my $r = $talk->select("INBOX") || die; + + # Search subject without accents + # my $term = "réunion critères"; + my %searches; + + if ($self->{skipdiacrit}) { + # Diacritics are stripped before indexing and search. That's a sane + # choice as long as there is no language-specific stemming applied + # during indexing and search. + %searches = ( + "reunion criteres" => 1, + "réunion critères" => 1, + "reunion critères" => 1, + "réunion criter" => 1, + "réunion crit" => 0, + "union critères" => 0, + ); + my $term = "naive"; + } else { + # Diacritics are not stripped from search. This currently is very + # restrictive: until Cyrus can stem by language, this is basically + # a whole-word match. + %searches = ( + "reunion criteres" => 0, + "réunion critères" => 1, + "reunion critères" => 0, + "réunion criter" => 0, + "réunion crit" => 0, + "union critères" => 0, + ); + } + + while (my($term, $expectedCnt) = each %searches) { + xlog $self, "SEARCH for FUZZY text \"$term\""; + $r = $talk->search( + "charset", "utf-8", "fuzzy", ["text", { Quote => $term }], + ) || die; + $self->assert_num_equals($expectedCnt, scalar @$r); + } + +} diff --git a/cassandane/tiny-tests/SearchFuzzy/weird_crasher b/cassandane/tiny-tests/SearchFuzzy/weird_crasher new file mode 100644 index 0000000000..42f7547330 --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/weird_crasher @@ -0,0 +1,19 @@ +#!perl +use Cassandane::Tiny; + +sub test_weird_crasher + :Conversations :min_version_3_0 :needs_search_xapian +{ + my ($self) = @_; + return if not $self->{test_fuzzy_search}; + $self->create_testmessages(); + + my $talk = $self->{store}->get_client(); + + xlog $self, "Select INBOX"; + $talk->select("INBOX") || die; + + xlog $self, "SEARCH for 'A 李 A'"; + my $r = $talk->xconvmultisort( [ qw(reverse arrival) ], [ 'conversations', position => [1,10] ], 'utf-8', 'fuzzy', 'text', { Quote => "A 李 A" }); + $self->assert_not_null($r); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/xapian_index_partid b/cassandane/tiny-tests/SearchFuzzy/xapian_index_partid new file mode 100644 index 0000000000..0ab1ed6b5c --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/xapian_index_partid @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_xapian_index_partid + :min_version_3_0 :needs_search_xapian :needs_component_jmap +{ + my ($self) = @_; + + # UID 1: match + $self->make_message("xtext", body => "xbody", + from => Cassandane::Address->new( + localpart => "xfrom", + domain => "example.com" + ) + ) || die; + + # UID 2: no match + $self->make_message("xtext", body => "xtext", + from => Cassandane::Address->new( + localpart => "xfrom", + domain => "example.com" + ) + ) || die; + + # UID 3: no match + $self->make_message("xbody", body => "xtext", + from => Cassandane::Address->new( + localpart => "xfrom", + domain => "example.com" + ) + ) || die; + + # UID 4: match + $self->make_message("nomatch", body => "xbody xtext", + from => Cassandane::Address->new( + localpart => "xfrom", + domain => "example.com" + ) + ) || die; + + # UID 5: no match + $self->make_message("xtext", body => "xbody xtext", + from => Cassandane::Address->new( + localpart => "nomatch", + domain => "example.com" + ) + ) || die; + + + $self->{instance}->run_command({cyrus => 1}, 'squatter', '-v'); + + my $talk = $self->{store}->get_client(); + $talk->select("INBOX") || die; + my $uids = $talk->search('fuzzy', 'from', 'xfrom', + 'fuzzy', 'body', 'xbody', + 'fuzzy', 'text', 'xtext') || die; + $self->assert_num_equals(2, scalar @$uids); + $self->assert_num_equals(1, @$uids[0]); + $self->assert_num_equals(4, @$uids[1]); +} diff --git a/cassandane/tiny-tests/SearchFuzzy/xattachmentname b/cassandane/tiny-tests/SearchFuzzy/xattachmentname new file mode 100644 index 0000000000..a0b0653c3b --- /dev/null +++ b/cassandane/tiny-tests/SearchFuzzy/xattachmentname @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_xattachmentname + :needs_search_xapian +{ + my ($self) = @_; + + my $talk = $self->{store}->get_client(); + + my $body = "" + . "--boundary\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "body" + . "\r\n" + . "--boundary\r\n" + . "Content-Type: application/x-excel; name=\"blah\"\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "Content-Disposition: attachment; filename=\"stuff.xls\"\r\n" + . "\r\n" + . "SGVsbG8sIFdvcmxkIQ==" + . "\r\n" + . "--boundary--\r\n"; + + $self->make_message("foo", + mime_type => "multipart/mixed", + mime_boundary => "boundary", + body => $body + ); + + $self->{instance}->run_command({cyrus => 1}, 'squatter'); + + my $r; + + $r = $talk->search( + "fuzzy", "xattachmentname", { Quote => "stuff" } + ) || die; + $self->assert_num_equals(1, scalar @$r); + + $r = $talk->search( + "fuzzy", "xattachmentname", { Quote => "nope" } + ) || die; + $self->assert_num_equals(0, scalar @$r); + + $r = $talk->search( + "fuzzy", "text", { Quote => "stuff.xls" } + ) || die; + $self->assert_num_equals(1, scalar @$r); + + $r = $talk->search( + "fuzzy", "xattachmentname", { Quote => "blah" }, + ) || die; + $self->assert_num_equals(1, scalar @$r); +} diff --git a/cassandane/tiny-tests/Sieve/badscript_sievec b/cassandane/tiny-tests/Sieve/badscript_sievec new file mode 100644 index 0000000000..18974a7a9b --- /dev/null +++ b/cassandane/tiny-tests/Sieve/badscript_sievec @@ -0,0 +1,12 @@ +#!perl +use Cassandane::Tiny; + +sub test_badscript_sievec + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing sieve script compile failures, via sievec"; + $self->{compile_method} = 'sievec'; + $self->badscript_common(); +} diff --git a/cassandane/tiny-tests/Sieve/badscript_timsieved b/cassandane/tiny-tests/Sieve/badscript_timsieved new file mode 100644 index 0000000000..41b6c571bf --- /dev/null +++ b/cassandane/tiny-tests/Sieve/badscript_timsieved @@ -0,0 +1,12 @@ +#!perl +use Cassandane::Tiny; + +sub test_badscript_timsieved + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing sieve script compile failures, via timsieved"; + $self->{compile_method} = 'timsieved'; + $self->badscript_common(); +} diff --git a/cassandane/tiny-tests/Sieve/create_inherit_color b/cassandane/tiny-tests/Sieve/create_inherit_color new file mode 100644 index 0000000000..10b548e1b0 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/create_inherit_color @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_create_inherit_color + :min_version_3_9 :AltNameSpace :needs_component_sieve :needs_component_jmap + :want_service_http +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "Create mailbox with color"; + my $res = $jmap->CallMethods([['Mailbox/set', { + create => { + 1 => { + parentId => JSON::null, + name => 'foo', + color => "coral", + }, + }, + }, "R1"]]); + $self->assert_not_null($res->[0][1]{created}{1}); + + my $hitfolder = "foo.bar"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "msg1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it"; + my $talk = $self->{store}->get_client(); + $talk->select($hitfolder); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Check that :created mailbox inherited color"; + $res = $jmap->CallMethods([['Mailbox/get', {}, "R1"]]); + my %m = map { $_->{name} => $_ } @{$res->[0][1]{list}}; + $self->assert_str_equals("coral", $m{"bar"}->{color}); +} diff --git a/cassandane/tiny-tests/Sieve/date b/cassandane/tiny-tests/Sieve/date new file mode 100644 index 0000000000..cb99bd15d4 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/date @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_date + :needs_component_sieve +{ + my ($self) = @_; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["date", "variables", "imap4flags", "regex", "relational"]; + +if date :originalzone "date" "date" [ "2018-05-16", "2018-12-16" ] { + addflag "Test1"; +} + +set "time" "22:06:18"; +if date :originalzone "date" "time" ["foo", "${time}"] { + addflag "Test2"; +} + +if date :regex "date" "std11" "^[a-z]{3}, [0-9]{1,2} [a-z]{3} [0-9]{4}" { + addflag "Test3"; +} + +if date :value "ge" :originalzone "date" "hour" "12" { + addflag "Test4"; +} + +if date :originalzone "date" "zone" "-0700" { + addflag "Test5"; +} +EOF + ); + + my $raw1 = << 'EOF'; +Date: Wed, 16 May 2018 22:06:18 -0700 +From: Some Person +To: foo/bar +Cc: Subscribed +Message-ID: +X-Cassandane-Unique: foo + +foo bar +EOF + + my $raw2 = << 'EOF'; +Date: Sun, 16 Dec 2018 22:06:18 -0700 +From: Some Person +To: foo/bar +Cc: Subscribed +Message-ID: +X-Cassandane-Unique: foo + +foo bar +EOF + xlog $self, "Deliver messages"; + my $msg1 = Cassandane::Message->new(raw => $raw1); + $self->{instance}->deliver($msg1); + + my $msg2 = Cassandane::Message->new(raw => $raw2); + $self->{instance}->deliver($msg2); + + my $imaptalk = $self->{store}->get_client(); + $self->{store}->set_fetch_attributes(qw(uid flags)); + $self->{store}->set_folder('INBOX'); + $msg1->set_attribute(uid => 1); + $msg1->set_attribute(flags => [ '\\Recent', 'Test1', 'Test2', 'Test3', 'Test4', 'Test5' ]); + $msg2->set_attribute(uid => 2); + $msg2->set_attribute(flags => [ '\\Recent', 'Test1', 'Test2', 'Test3', 'Test4', 'Test5' ]); + $self->check_messages({ 1 => $msg1, 2 => $msg2 }, keyed_on => 'uid', check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/date_iana_tzid b/cassandane/tiny-tests/Sieve/date_iana_tzid new file mode 100644 index 0000000000..184b61c292 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/date_iana_tzid @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_date_iana_tzid + :needs_component_sieve :min_version_3_7 + :needs_dependency_ical +{ + my ($self) = @_; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["date", "variables", "imap4flags", "regex", "relational"]; + +if date :zone "-1000" "date" "hour" "19" { + addflag "Test1"; +} + +if date :zone "Pacific/Honolulu" "date" "hour" "19" { + addflag "Test2"; +} +EOF + ); + + my $raw = << 'EOF'; +Date: Wed, 16 May 2018 22:06:18 -0700 +From: Some Person +To: foo/bar +Cc: Subscribed +Message-ID: +X-Cassandane-Unique: foo + +foo bar +EOF + xlog $self, "Deliver a message"; + my $msg1 = Cassandane::Message->new(raw => $raw); + $self->{instance}->deliver($msg1); + + my $imaptalk = $self->{store}->get_client(); + $self->{store}->set_fetch_attributes(qw(uid flags)); + $self->{store}->set_folder('INBOX'); + $msg1->set_attribute(uid => 1); + $msg1->set_attribute(flags => [ '\\Recent', 'Test1', 'Test2' ]); + $self->check_messages({ 1 => $msg1 }, keyed_on => 'uid', check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/date_local_zone b/cassandane/tiny-tests/Sieve/date_local_zone new file mode 100644 index 0000000000..95aac4c196 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/date_local_zone @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_date_local_zone + :needs_component_sieve :min_version_3_9 +{ + my ($self) = @_; + + my $date = "Wed, 16 May 2018 22:06:18 -0700"; + my $dt = DateTime->from_epoch(epoch => str2time($date), time_zone => 'local'); + + my $date_hour = $dt->strftime("%H"); + + my $now = DateTime->now(); + my $cur_utc_hour = $now->strftime("%H"); + + $now->set_time_zone('local'); + + my $cur_hour = $now->strftime("%H"); + my $cur_zone = $now->strftime("%z"); + my $cur_std11 = $now->strftime("%a, %d %b %Y %H:[0-9]{2}:[0-9]{2} %z"); + $cur_std11 =~ s/\+/[+]/g; # escape any '+' from %z + + $self->{instance}->install_sieve_script(< +To: foo/bar +Cc: Subscribed +Message-ID: +X-Cassandane-Unique: foo + +foo bar +EOF + xlog $self, "Deliver a message"; + my $msg1 = Cassandane::Message->new(raw => $raw); + $self->{instance}->deliver($msg1); + + my $imaptalk = $self->{store}->get_client(); + $self->{store}->set_fetch_attributes(qw(uid flags)); + $self->{store}->set_folder('INBOX'); + $msg1->set_attribute(uid => 1); + $msg1->set_attribute(flags => [ '\\Recent', 'Test1', 'Test2', 'Test3', 'Test4', 'Test5' ]); + $self->check_messages({ 1 => $msg1 }, keyed_on => 'uid', check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver b/cassandane/tiny-tests/Sieve/deliver new file mode 100644 index 0000000000..3c072a76cc --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver @@ -0,0 +1,41 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver + :needs_component_sieve +{ + my ($self) = @_; + + my $target = "INBOX.target"; + + xlog $self, "Install a sieve script filing all mail into a nonexistant folder"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Actually create the target folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create($target) + or die "Cannot create $target: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver another message"; + my $msg2 = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg2); + $msg2->set_attribute(uid => 1); + + xlog $self, "Check that only the 1st message made it to INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Check that only the 2nd message made it to the target"; + $self->{store}->set_folder($target); + $self->check_messages({ 1 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_compile b/cassandane/tiny-tests/Sieve/deliver_compile new file mode 100644 index 0000000000..d2fd271149 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_compile @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_compile + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + my $target = "INBOX.target"; + + xlog $self, "Create the target folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($target) + or die "Cannot create $target: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Install a sieve script filing all mail into the target folder"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Delete the compiled bytecode"; + my $sieve_dir = $self->{instance}->get_sieve_script_dir('cassandane'); + my $fname = "$sieve_dir/test1.bc"; + unlink $fname or die "Cannot unlink $fname: $!"; + + sleep 1; # so the two deliveries get different syslog timestamps + + xlog $self, "Deliver another message - lmtpd should rebuild the missing bytecode"; + my $msg2 = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg2); + + xlog $self, "Check that both messages made it to the target"; + $self->{store}->set_folder($target); + $self->check_messages({ 1 => $msg1, 2 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_globalshared b/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_globalshared new file mode 100644 index 0000000000..5f6cd47016 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_globalshared @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_fileinto_autocreate_globalshared + :needs_component_sieve :NoStartInstances :NoAltNameSpace +{ + my ($self) = @_; + + $self->{instance}->{config}->set('anysievefolder' => 'yes'); + $self->_start_instances(); + + # sieve script should not be able to create a new global shared mailbox + my $target = "TopLevel"; + + $self->{instance}->install_sieve_script(< 'cassandane'); + + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, users => [ 'cassandane' ]); + + # autosievefolder should have failed to create the target, because the + # user doesn't have permission to create a folder in the global shared + # namespace + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->select($target); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/does not exist/i, $admintalk->get_last_error()); + + # then the fileinto should fail, and the message be delivered to inbox + # instead + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_newuser b/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_newuser new file mode 100644 index 0000000000..451ab10cbd --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_newuser @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_fileinto_autocreate_newuser + :needs_component_sieve :NoStartInstances :NoAltNameSpace +{ + my ($self) = @_; + + $self->{instance}->{config}->set('anysievefolder' => 'yes'); + $self->_start_instances(); + + # sieve script should not be able to create a new user account + my $target = "user.other"; + + $self->{instance}->install_sieve_script(< 'cassandane'); + + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, users => [ 'cassandane' ]); + + # autosievefolder should have failed to create the target, because the + # user doesn't have permission to create a mailbox under user. + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->select($target); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/does not exist/i, $admintalk->get_last_error()); + + # then the fileinto should fail, and the message be delivered to inbox + # instead + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_otheruser b/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_otheruser new file mode 100644 index 0000000000..ce883d94b2 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_fileinto_autocreate_otheruser @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_fileinto_autocreate_otheruser + :needs_component_sieve :NoStartInstances :NoAltNameSpace +{ + my ($self) = @_; + + $self->{instance}->{config}->set('anysievefolder' => 'yes'); + $self->_start_instances(); + + $self->{instance}->create_user('other'); + + # sieve script should not be able to create a mailbox in some other + # user's account + my $target = "user.other.SomeFolder"; + + $self->{instance}->install_sieve_script(< 'cassandane'); + + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, users => [ 'cassandane' ]); + + # autosievefolder should have failed to create the target, because the + # user doesn't have permission to create a folder in another user's + # account + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->select($target); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/does not exist/i, $admintalk->get_last_error()); + + # then the fileinto should fail, and the message be delivered to inbox + # instead + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_fileinto_create_globalshared b/cassandane/tiny-tests/Sieve/deliver_fileinto_create_globalshared new file mode 100644 index 0000000000..0a3345129e --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_fileinto_create_globalshared @@ -0,0 +1,33 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_fileinto_create_globalshared + :needs_component_sieve :min_version_3_0 :NoAltNameSpace +{ + my ($self) = @_; + + # sieve script should not be able to create a new global shared mailbox + my $target = "TopLevel"; + + $self->{instance}->install_sieve_script(< 'cassandane'); + + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, users => [ 'cassandane' ]); + + # autosievefolder should have failed to create the target, because the + # user doesn't have permission to create a folder in the global shared + # namespace + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->select($target); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/does not exist/i, $admintalk->get_last_error()); + + # then the fileinto should fail, and the message be delivered to inbox + # instead + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_fileinto_create_newuser b/cassandane/tiny-tests/Sieve/deliver_fileinto_create_newuser new file mode 100644 index 0000000000..c8d8e971ba --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_fileinto_create_newuser @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_fileinto_create_newuser + :needs_component_sieve :min_version_3_0 :NoAltNameSpace +{ + my ($self) = @_; + + # sieve script should not be able to create a new user + my $target = "user.other"; + + $self->{instance}->install_sieve_script(< 'cassandane'); + + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, users => [ 'cassandane' ]); + + # autosievefolder should have failed to create the target, because the + # user doesn't have permission to create a mailbox under user. + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->select($target); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/does not exist/i, $admintalk->get_last_error()); + + # then the fileinto should fail, and the message be delivered to inbox + # instead + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_fileinto_create_nonimap b/cassandane/tiny-tests/Sieve/deliver_fileinto_create_nonimap new file mode 100644 index 0000000000..ad659cb4e1 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_fileinto_create_nonimap @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_fileinto_create_nonimap + :needs_component_sieve :min_version_3_0 :NoAltNameSpace +{ + my ($self) = @_; + + # sieve script should not be able to create a non-IMAP mailbox + my $target = "INBOX.#calendars.target"; + + $self->{instance}->install_sieve_script(< 'cassandane'); + + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, users => [ 'cassandane' ]); + + # autosievefolder should have failed to create the target, + # because the the target is in a non-IMAP namespace + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->select($target); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/does not exist/i, $admintalk->get_last_error()); + + # then the fileinto should fail, + # and the message be delivered to inbox instead + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_fileinto_create_otheruser b/cassandane/tiny-tests/Sieve/deliver_fileinto_create_otheruser new file mode 100644 index 0000000000..f741cd4e30 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_fileinto_create_otheruser @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_fileinto_create_otheruser + :needs_component_sieve :min_version_3_0 :NoAltNameSpace +{ + my ($self) = @_; + + $self->{instance}->create_user('other'); + + # sieve script should not be able to create a mailbox in some other + # user's account + my $target = "user.other.SomeFolder"; + + $self->{instance}->install_sieve_script(< 'cassandane'); + + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, users => [ 'cassandane' ]); + + # autosievefolder should have failed to create the target, because the + # user doesn't have permission to create a folder in another user's + # account + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->select($target); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + $self->assert_matches(qr/does not exist/i, $admintalk->get_last_error()); + + # then the fileinto should fail, and the message be delivered to inbox + # instead + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_fileinto_dot b/cassandane/tiny-tests/Sieve/deliver_fileinto_dot new file mode 100644 index 0000000000..994ffd8f52 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_fileinto_dot @@ -0,0 +1,47 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_fileinto_dot + :UnixHierarchySep + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing a sieve script which does a 'fileinto' a mailbox"; + xlog $self, "when the user has a dot in their name. Bug 3664"; + # NOTE: The commit https://github.com/cyrusimap/cyrus-imapd/commit/73af8e19546f235f6286cc9147a3ea74bde19ebb + # in Cyrus-imapd changes this behaviour where in we don't do a '.' -> '^' anymore. + + xlog $self, "Create the dotted user"; + my $user = 'betty.boop'; + $self->{instance}->create_user($user); + + xlog $self, "Connect as the new user"; + my $svc = $self->{instance}->get_service('imap'); + $self->{store} = $svc->create_store(username => $user, folder => 'INBOX'); + $self->{store}->set_fetch_attributes('uid'); + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "Create the target folder"; + + my $target = Cassandane::Mboxname->new(config => $self->{instance}->{config}, + userid => $user, + box => 'target')->to_external(); + $imaptalk->create($target) + or die "Cannot create $target: $@"; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(< 'betty.boop'); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, users => [ $user ]); + + xlog $self, "Check that the message made it to target"; + $self->{store}->set_folder($target); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_multiple_users b/cassandane/tiny-tests/Sieve/deliver_multiple_users new file mode 100644 index 0000000000..d09eb2e39e --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_multiple_users @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_multiple_users + :needs_component_sieve :NoAltNameSpace + :want_smtpdaemon +{ + my ($self) = @_; + + # create 2 other users + $self->{instance}->create_user('other1'); + $self->{instance}->create_user('other2'); + + # install redirect script for cassandane + $self->{instance}->install_sieve_script(< 'cassandane'); + + # install fileinto script for other2 + $self->{instance}->install_sieve_script(< 'other2'); + + # deliver a message to all 3 users + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1, + users => [ 'cassandane', 'other1', 'other2' ]); + + # message should NOT appear in cassandane INBOX + my $admintalk = $self->{adminstore}->get_client(); + $admintalk->examine('user.cassandane'); + $admintalk->fetch('1', '(flags)'); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + + # message should appear in other1 INBOX + $admintalk->examine('user.other1'); + my $res = $admintalk->fetch('1', '(flags)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals($res, { '1' => { 'flags' => [ '\\Recent'] }}); + + # message should NOT appear in other2 INBOX + $admintalk->examine('user.other2'); + $admintalk->fetch('1', '(flags)'); + $self->assert_str_equals('no', $admintalk->get_last_completion_response()); + + # message should appear in other2 INBOX.sub + $admintalk->examine('user.other2.sub'); + $res = $admintalk->fetch('1', '(flags)'); + $self->assert_str_equals('ok', $admintalk->get_last_completion_response()); + $self->assert_deep_equals($res, + { '1' => { 'flags' => [ '\\Recent', '\\Flagged'] }}); +} diff --git a/cassandane/tiny-tests/Sieve/deliver_specialuse b/cassandane/tiny-tests/Sieve/deliver_specialuse new file mode 100644 index 0000000000..c4250bde4c --- /dev/null +++ b/cassandane/tiny-tests/Sieve/deliver_specialuse @@ -0,0 +1,39 @@ +#!perl +use Cassandane::Tiny; + +sub test_deliver_specialuse + :min_version_3_0 + :needs_component_sieve + :NoAltNameSpace +{ + my ($self) = @_; + + my $target = "INBOX.target"; + + xlog $self, "create the target folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create($target, "(use (\\Trash))") + or die "Cannot create $target: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Install a sieve script filing all mail into the Trash role"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + $msg->set_attribute(uid => 1); + + xlog $self, "Check that no messages are in INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({}, check_guid => 0); + + xlog $self, "Check that the message made it into the target folder"; + $self->{store}->set_folder($target); + $self->check_messages({ 1 => $msg }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/discard_match_on_body_raw b/cassandane/tiny-tests/Sieve/discard_match_on_body_raw new file mode 100644 index 0000000000..31d2c36826 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/discard_match_on_body_raw @@ -0,0 +1,143 @@ +#!perl +use Cassandane::Tiny; + +sub test_discard_match_on_body_raw + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(< +To: foo/bar +Message-ID: +Subject: Confirmation of your order +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_Part_91374_1856076643.1527870431792" + +------=_Part_91374_1856076643.1527870431792 +Content-Type: multipart/alternative; + boundary="----=_Part_91373_1043761677.1527870431791" + +------=_Part_91373_1043761677.1527870431791 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Dear Mr Foo Bar, + +Thank you for using Blah to do your shopping. + + +ORDER DETAILS=20 +=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D + +One-Click Additions +------------------- +1 Oven Pride Oven Cleaning System + +Total (estimated): 777.70 GBP +Note: The total cost is estimated because some of the items you might have = +ordered, such as meat and cheese, are sold by weight. The exact cost will b= +e shown on your receipt when your order is delivered. This cost includes th= +e delivery charge if any. + + +CHANGING YOUR ORDER +=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D +If you want to change any items on your order or change the delivery, simpl= +y go to www.blah.com and the Orders page; from here, click on the order re= +ference number and make the appropriate changes. + +The last time you can change this order is: 17:40 on 1st June 2018. + + +ICALENDAR EMAIL ATTACHMENTS +=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= +=3D=3D=3D +Order confirmation emails have an ICalendar event file attached to help you. + +YOUR COMPLETE SATISFACTION +=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= +=3D +We want to make sure that you are completely satisfied with your Blah deli= +very; if for any reason you are not, then please advise the Customer Servic= +es Team Member at the door and they will ensure that any issues are resolve= +d for you. + +Thank you for shopping with BLAH. +Yours sincerely, + +BLAH Customer Service Team + +------=_Part_91373_1043761677.1527870431791 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + + + + +------=_Part_91373_1043761677.1527870431791-- + +------=_Part_91374_1856076643.1527870431792 +Content-Type: text/calendar; charset=us-ascii; name=BlahDelivery.ics +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename=BlahDelivery.ics + +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VTIMEZONE +TZID:Europe/London +LAST-MODIFIED:20180601T172711 +BEGIN:STANDARD +DTSTART:20071028T010000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZOFFSETTO:+0000 +TZOFFSETFROM:+0100 +TZNAME:GMT +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070325T010000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +TZOFFSETTO:+0100 +TZOFFSETFROM:+0000 +TZNAME:BST +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +LOCATION:Home +DTSTAMP:20180601T172711 +UID:36496743@foo.com +LAST-MODIFIED:20180601T172711 +SEQUENCE:1 +DTSTART;TZID=Europe/London:20180602T080000 +SUMMARY:Blah delivery +DTEND;TZID=Europe/London:20180602T090000 +DESCRIPTION: +END:VEVENT +END:VCALENDAR +------=_Part_91374_1856076643.1527870431792-- +EOF + xlog $self, "Deliver a message"; + my $msg1 = Cassandane::Message->new(raw => $raw); + $self->{instance}->deliver($msg1); + + # should fail to deliver and NOT appear in INBOX + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + $self->assert_num_equals(0, $imaptalk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/discard_match_on_body_text b/cassandane/tiny-tests/Sieve/discard_match_on_body_text new file mode 100644 index 0000000000..c942dfab10 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/discard_match_on_body_text @@ -0,0 +1,143 @@ +#!perl +use Cassandane::Tiny; + +sub test_discard_match_on_body_text + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(< +To: foo/bar +Message-ID: +Subject: Confirmation of your order +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_Part_91374_1856076643.1527870431792" + +------=_Part_91374_1856076643.1527870431792 +Content-Type: multipart/alternative; + boundary="----=_Part_91373_1043761677.1527870431791" + +------=_Part_91373_1043761677.1527870431791 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Dear Mr Foo Bar, + +Thank you for using Blah to do your shopping. + + +ORDER DETAILS=20 +=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D + +One-Click Additions +------------------- +1 Oven Pride Oven Cleaning System + +Total (estimated): 777.70 GBP +Note: The total cost is estimated because some of the items you might have = +ordered, such as meat and cheese, are sold by weight. The exact cost will b= +e shown on your receipt when your order is delivered. This cost includes th= +e delivery charge if any. + + +CHANGING YOUR ORDER +=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D +If you want to change any items on your order or change the delivery, simpl= +y go to www.blah.com and the Orders page; from here, click on the order re= +ference number and make the appropriate changes. + +The last time you can change this order is: 17:40 on 1st June 2018. + + +ICALENDAR EMAIL ATTACHMENTS +=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= +=3D=3D=3D +Order confirmation emails have an ICalendar event file attached to help you. + +YOUR COMPLETE SATISFACTION +=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= +=3D +We want to make sure that you are completely satisfied with your Blah deli= +very; if for any reason you are not, then please advise the Customer Servic= +es Team Member at the door and they will ensure that any issues are resolve= +d for you. + +Thank you for shopping with BLAH. +Yours sincerely, + +BLAH Customer Service Team + +------=_Part_91373_1043761677.1527870431791 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + + + + +------=_Part_91373_1043761677.1527870431791-- + +------=_Part_91374_1856076643.1527870431792 +Content-Type: text/calendar; charset=us-ascii; name=BlahDelivery.ics +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename=BlahDelivery.ics + +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VTIMEZONE +TZID:Europe/London +LAST-MODIFIED:20180601T172711 +BEGIN:STANDARD +DTSTART:20071028T010000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZOFFSETTO:+0000 +TZOFFSETFROM:+0100 +TZNAME:GMT +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070325T010000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +TZOFFSETTO:+0100 +TZOFFSETFROM:+0000 +TZNAME:BST +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +LOCATION:Home +DTSTAMP:20180601T172711 +UID:36496743@foo.com +LAST-MODIFIED:20180601T172711 +SEQUENCE:1 +DTSTART;TZID=Europe/London:20180602T080000 +SUMMARY:Blah delivery +DTEND;TZID=Europe/London:20180602T090000 +DESCRIPTION: +END:VEVENT +END:VCALENDAR +------=_Part_91374_1856076643.1527870431792-- +EOF + xlog $self, "Deliver a message"; + my $msg1 = Cassandane::Message->new(raw => $raw); + $self->{instance}->deliver($msg1); + + # should fail to deliver and NOT appear in INBOX + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + $self->assert_num_equals(0, $imaptalk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/double_require b/cassandane/tiny-tests/Sieve/double_require new file mode 100644 index 0000000000..237e874d8a --- /dev/null +++ b/cassandane/tiny-tests/Sieve/double_require @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_double_require + :needs_component_sieve +{ + my ($self) = @_; + + my $target = "INBOX.target"; + + xlog $self, "Install a sieve script filing all mail into a nonexistant folder"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Actually create the target folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create($target) + or die "Cannot create $target: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Deliver another message"; + my $msg2 = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg2); + $msg2->set_attribute(uid => 1); + + xlog $self, "Check that only the 1st message made it to INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Check that only the 2nd message made it to the target"; + $self->{store}->set_folder($target); + $self->check_messages({ 1 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/dup_fileinto_implicit_keep_flags b/cassandane/tiny-tests/Sieve/dup_fileinto_implicit_keep_flags new file mode 100644 index 0000000000..47a73c329b --- /dev/null +++ b/cassandane/tiny-tests/Sieve/dup_fileinto_implicit_keep_flags @@ -0,0 +1,27 @@ +#!perl +use Cassandane::Tiny; + +sub test_dup_fileinto_implicit_keep_flags + :needs_component_sieve +{ + my ($self) = @_; + + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Testing duplicate suppression between 'fileinto' & 'keep'"; + + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that only last copy of the message made it to INBOX"; + $self->{store}->set_folder('INBOX'); + $msg1->set_attribute(flags => [ '\\Recent' ]); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/dup_keep_fileinto b/cassandane/tiny-tests/Sieve/dup_keep_fileinto new file mode 100644 index 0000000000..a1d7639c41 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/dup_keep_fileinto @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +# Note: experiment indicates that duplicate suppression +# with sieve's fileinto does not work if the mailbox has +# the OPT_IMAP_DUPDELIVER option enabled. This is not +# really broken, although perhaps unexpected, and it not +# tested for here. + +sub test_dup_keep_fileinto + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing duplicate suppression between 'keep' & 'fileinto'"; + + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that only one copy of the message made it to INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/dup_keep_keep b/cassandane/tiny-tests/Sieve/dup_keep_keep new file mode 100644 index 0000000000..929deae15c --- /dev/null +++ b/cassandane/tiny-tests/Sieve/dup_keep_keep @@ -0,0 +1,24 @@ +#!perl +use Cassandane::Tiny; + +sub test_dup_keep_keep + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing duplicate suppression between 'keep' & 'keep'"; + + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that only one copy of the message made it to INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/duplicate b/cassandane/tiny-tests/Sieve/duplicate new file mode 100644 index 0000000000..e385d5deb7 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/duplicate @@ -0,0 +1,50 @@ +#!perl +use Cassandane::Tiny; + +sub test_duplicate + :min_version_3_1 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Install a sieve script with a duplicate check"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "ALERT: server down"); + $self->{instance}->deliver($msg1); + + xlog $self, "Deliver second message"; + # This message should be discarded + my $msg2 = $self->{gen}->generate(subject => "ALERT: server down"); + $self->{instance}->deliver($msg2); + + xlog $self, "Deliver third message"; + # This message should be discarded + my $msg3 = $self->{gen}->generate(subject => "ALERT: server down"); + $self->{instance}->deliver($msg3); + + sleep 3; + xlog $self, "Deliver fourth message"; + # This message should be delivered (after the expire time) + my $msg4 = $self->{gen}->generate(subject => "ALERT: server down"); + $self->{instance}->deliver($msg4); + + xlog $self, "Deliver fifth message"; + # This message should be discarded + my $msg5 = $self->{gen}->generate(subject => "ALERT: server down"); + $self->{instance}->deliver($msg5); + + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + + $self->assert_num_equals(2, $imaptalk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/editheader_basic b/cassandane/tiny-tests/Sieve/editheader_basic new file mode 100644 index 0000000000..f1fca9b2bd --- /dev/null +++ b/cassandane/tiny-tests/Sieve/editheader_basic @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_editheader_basic + :min_version_3_1 + :needs_component_sieve +{ + my ($self) = @_; + + my $target = "INBOX.target"; + + xlog $self, "Install a sieve script with editheader actions"; + $self->{instance}->install_sieve_script(<{store}->get_client(); + + $imaptalk->create($target) + or die "Cannot create $target: $@"; + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + $imaptalk->select("INBOX"); + my $res = $imaptalk->fetch(1, 'rfc822'); + + $msg1 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/^X-Cassandane-Test: prepend1\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: append1\r\nX-Cassandane-Test: append3\r\nX-Cassandane-Test: append5\r\n\r\n/, $msg1); + + $imaptalk->select($target); + $res = $imaptalk->fetch(1, 'rfc822'); + + $msg1 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/^Return-Path: /, $msg1); + $self->assert_matches(qr/X-Cassandane-Unique: .*\r\n\r\n/, $msg1); +} diff --git a/cassandane/tiny-tests/Sieve/editheader_complex b/cassandane/tiny-tests/Sieve/editheader_complex new file mode 100644 index 0000000000..92c1f92fd1 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/editheader_complex @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_editheader_complex + :min_version_3_3 + :needs_component_sieve +{ + my ($self) = @_; + + my $target = "INBOX.target"; + + xlog $self, "Install a sieve script with editheader actions"; + $self->{instance}->install_sieve_script(<{store}->get_client(); + + $imaptalk->create($target) + or die "Cannot create $target: $@"; + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + $imaptalk->select("INBOX"); + my $res = $imaptalk->fetch(1, 'rfc822'); + + $msg1 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/^X-Cassandane-Test: =\?UTF-8\?Q\?prepend1=0A=0A\?=\r\nX-Hello: =\?UTF-8\?Q\?World=E2=88=97\?=\r\nReturn-Path: /, $msg1); + $self->assert_matches(qr/X-Cassandane-Unique: .*\r\nX-Cassandane-Test: append1\r\nX-Cassandane-Test: append3\r\nX-Cassandane-Test: append5\r\n\r\n/, $msg1); + + $imaptalk->select($target); + $res = $imaptalk->fetch(1, 'rfc822'); + + $msg1 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/^X-Hello: =\?UTF-8\?Q\?World=E2=88=97\?=\r\nReturn-Path: /, $msg1); + $self->assert_matches(qr/X-Cassandane-Unique: .*\r\n\r\n/, $msg1); +} diff --git a/cassandane/tiny-tests/Sieve/editheader_encoded_address_list b/cassandane/tiny-tests/Sieve/editheader_encoded_address_list new file mode 100644 index 0000000000..fa7b5958d1 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/editheader_encoded_address_list @@ -0,0 +1,46 @@ +#!perl +use Cassandane::Tiny; + +sub test_editheader_encoded_address_list + :min_version_3_3 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Install a sieve script with editheader actions"; + my $script = <\${2}"; +} +addheader :last "X-Foo" "must encode star (\${unicode:2217})"; +addheader :last "X-Bar" "don't need to encode this"; +addheader :last "X-Blah" "can encode in non-list \${unicode:2217}"; +EOF + + $script =~ s/\r?\n/\r\n/gs; + $script =~ s/\\/\\\\/gs; + + $self->{instance}->install_sieve_script($script); + + xlog $self, "Deliver a matching message"; + my $msg1 = $self->{gen}->generate( + subject => "Message 1", + extra_headers => [['To', '"=?UTF-8?Q?=E2=88=97?=" , bbb@example.com, ccc@example.com'] + ], + ); + $self->{instance}->deliver($msg1); + + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + my $res = $imaptalk->fetch(1, 'rfc822'); + + $msg1 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/To: =\?UTF-8\?Q\?=22=E2=88=97=22\?= ,\s+"BBB" ,\s+ccc\@example.com\r\n/, $msg1); + $self->assert_matches(qr/X-Foo: =\?UTF-8\?Q\?must_encode_star_\(=E2=88=97\)\?=\r\n/, $msg1); + $self->assert_matches(qr/X-Bar: don't need to encode this\r\n/, $msg1); + $self->assert_matches(qr/X-Blah: =\?UTF-8\?Q\?can_encode__in_non-list_=E2=88=97\?=\r\n\r\n/, $msg1); + +} diff --git a/cassandane/tiny-tests/Sieve/encoded_char_variable_in_mboxname b/cassandane/tiny-tests/Sieve/encoded_char_variable_in_mboxname new file mode 100644 index 0000000000..b960806c70 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/encoded_char_variable_in_mboxname @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_encoded_char_variable_in_mboxname + :needs_component_sieve :min_version_3_1 :SieveUTF8Fileinto +{ + my ($self) = @_; + + my $target = "INBOX.\N{U+2217}"; + + xlog $self, "Testing encoded-character in a mailbox name"; + + xlog $self, "Actually create the target folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create($target) + or die "Cannot create $target: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Install script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message made it to the target"; + $self->{store}->set_folder($target); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/enotify b/cassandane/tiny-tests/Sieve/enotify new file mode 100644 index 0000000000..bd120ee636 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/enotify @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_enotify + :needs_component_sieve :min_version_3_2 +{ + my ($self) = @_; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["enotify"]; + +notify "https://cyrusimap.org/notifiers/updatecal"; +notify :message "Hello World!" "mailto:foo@example.com"; +EOF + ); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + my $data = $self->{instance}->getnotify(); + my ($updatecal) = grep { $_->{METHOD} eq 'updatecal' } @$data; + my ($mailto) = grep { $_->{METHOD} eq 'mailto' } @$data; + + $self->assert_not_null($updatecal); + $self->assert_not_null($mailto); + $self->assert_matches(qr/Hello World!/, $mailto->{MESSAGE}); +} diff --git a/cassandane/tiny-tests/Sieve/ereject b/cassandane/tiny-tests/Sieve/ereject new file mode 100644 index 0000000000..8f6b6d0ac0 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/ereject @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_ereject + :min_version_3_1 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Install a sieve script rejecting all mail"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + my $res = $self->{instance}->deliver($msg1); + + # should fail to deliver + $self->assert_num_not_equals(0, $res); + + # should NOT appear in INBOX + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + $self->assert_num_equals(0, $imaptalk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/error_flag b/cassandane/tiny-tests/Sieve/error_flag new file mode 100644 index 0000000000..6443e08161 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/error_flag @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_error_flag + :needs_component_sieve :min_version_3_3 +{ + my ($self) = @_; + + xlog $self, "Install a sieve script filing all mail into a nonexistant folder"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + # SHOULD get flagged + my $msg2 = $self->{gen}->generate(subject => "this will fail with an error"); + $self->{instance}->deliver($msg2); + + # should NOT get flagged + my $msg3 = $self->{gen}->generate(subject => "Message 3"); + $self->{instance}->deliver($msg3); + + # SHOULD get flagged + my $msg4 = $self->{gen}->generate(subject => "this fileinto won't succeed"); + $self->{instance}->deliver($msg4); + + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->select("INBOX"); + $self->assert_num_equals(4, $imaptalk->get_response_code('exists')); + + my $res = $imaptalk->fetch('1:*', 'flags'); + + $self->assert_null(grep { $_ eq '$SieveFailed' } @{$res->{1}{flags}}); + $self->assert_not_null(grep { $_ eq '$SieveFailed' } @{$res->{2}{flags}}); + $self->assert_null(grep { $_ eq '$SieveFailed' } @{$res->{3}{flags}}); + $self->assert_not_null(grep { $_ eq '$SieveFailed' } @{$res->{4}{flags}}); +} diff --git a/cassandane/tiny-tests/Sieve/fileinto_mailboxid b/cassandane/tiny-tests/Sieve/fileinto_mailboxid new file mode 100644 index 0000000000..e56a194882 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/fileinto_mailboxid @@ -0,0 +1,80 @@ +#!perl +use Cassandane::Tiny; + +sub test_fileinto_mailboxid + :min_version_3_1 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"mailboxid\" action"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX.testfolder"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<create($hitfolder); + $talk->create($missfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + my $res = $talk->status($hitfolder, ['mailboxid']); + my $id = $res->{mailboxid}[0]; + + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } + + xlog $self, "Delete the target folder"; + $talk->delete($hitfolder); + + xlog $self, "Deliver a message now that the folder doesn't exist"; + { + my $msg = $self->{gen}->generate(subject => "msg3"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg3"} = $msg; + } + + xlog $self, "Check that the message made it to miss folder"; + $self->{store}->set_folder($missfolder); + $self->check_messages($exp{$missfolder}, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/fileinto_mailboxid_variable b/cassandane/tiny-tests/Sieve/fileinto_mailboxid_variable new file mode 100644 index 0000000000..fc638231b7 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/fileinto_mailboxid_variable @@ -0,0 +1,81 @@ +#!perl +use Cassandane::Tiny; + +sub test_fileinto_mailboxid_variable + :min_version_3_5 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"mailboxid\" action"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX.testfolder"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<create($hitfolder); + $talk->create($missfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + my $res = $talk->status($hitfolder, ['mailboxid']); + my $id = $res->{mailboxid}[0]; + + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } + + xlog $self, "Delete the target folder"; + $talk->delete($hitfolder); + + xlog $self, "Deliver a message now that the folder doesn't exist"; + { + my $msg = $self->{gen}->generate(subject => "msg3"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg3"} = $msg; + } + + xlog $self, "Check that the message made it to miss folder"; + $self->{store}->set_folder($missfolder); + $self->check_messages($exp{$missfolder}, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/fileinto_mailboxidexists b/cassandane/tiny-tests/Sieve/fileinto_mailboxidexists new file mode 100644 index 0000000000..12e5fa1b23 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/fileinto_mailboxidexists @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_fileinto_mailboxidexists + :min_version_3_1 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"mailboxidexists\" test"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX"; + + my $testfolder = "INBOX.testfolder"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<create($hitfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the test folder"; + $talk->create($testfolder); + my $res = $talk->status($testfolder, ['mailboxid']); + my $id = $res->{mailboxid}[0]; + + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Delete the test folder"; + $talk->delete($testfolder); + + xlog $self, "Deliver a message now that the folder doesn't exist"; + { + my $msg = $self->{gen}->generate(subject => "msg3"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg3"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/github_issue_complex_variables b/cassandane/tiny-tests/Sieve/github_issue_complex_variables new file mode 100644 index 0000000000..37768264d1 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/github_issue_complex_variables @@ -0,0 +1,106 @@ +#!perl +use Cassandane::Tiny; + +sub test_github_issue_complex_variables + :min_version_3_1 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Install a sieve script with complex variable work"; + $self->{instance}->install_sieve_script(<<'EOF'); +require ["fileinto", "reject", "vacation", "envelope", "body", "relational", "regex", "subaddress", "copy", "mailbox", "mboxmetadata", "servermetadata", "date", "index", "comparator-i;ascii-numeric", "variables", "imap4flags", "editheader", "duplicate", "vacation-seconds"]; + +### BEGIN USER SIEVE +### GitHub +if allof ( + address :is :domain "Message-ID" "github.com", + address :regex :localpart "Message-ID" "^([^/]*)/([^/]*)/(pull|issues|issue|commit)/(.*)" +) { + # Message-IDs: + + set :lower "org" "${1}"; + set :lower "repo" "${2}"; + set :lower "type" "${3}"; + set "tail" "${4}"; + if anyof( + string :matches "${org}/${repo}" "foo/bar*", + string :is "${org}" ["foo", "bar", "baz"] + ) { + set "ghflags" ""; + + # Mark all issue events as seen. + if address :regex :localpart "Message-ID" "^[^/]+/[^/]+/(pull|issue)/[^/]+/issue_event/" { + addflag "ghflags" "\\Seen"; + set "type" "issues"; + } + + # Flag comments on things I authored + if header :is ["X-GitHub-Reason"] "author" { + addflag "ghflags" "\\Flagged"; + } + + if string :matches "${org}/${repo}" "foo/bar*" { + # change the mailbox name for foo emails + set "org" "foo"; + if string :matches "${repo}" "foo-corelibs-*" { + set "repo" "${1}"; + } elsif string :matches "${repo}" "foo-*" { + set "repo" "${1}"; + } + } + set "mbprefix" "INBOX.GitHub.${org}.${repo}"; + + if string :is "${type}" "pull" { + # PRs + set "mbname" "${mbprefix}.pulls"; + } elsif string :is "${type}" "issues" { + # Issues + set "mbname" "${mbprefix}.issues"; + } elsif string :is "${type}" "commit" { + # Commit comments + set "mbname" "${mbprefix}.comments"; + # Disable replies sorting + set "tail" ""; + } else { + set "mbname" "${mbprefix}.unknown"; + } + + if string :matches "${tail}" "*/*" { + set "oldmbname" "${mbname}"; + set "mbname" "${oldmbname}.replies"; + } + + if header :is ["X-GitHub-Reason"] ["subscribed", "push"] { + fileinto :create :flags "${ghflags}" "${mbname}"; + } else { + fileinto :create :copy :flags "${ghflags}" "${mbname}"; + } + } +} +EOF + + my $raw = << 'EOF'; +Date: Wed, 16 May 2018 22:06:18 -0700 +From: Some Person +To: foo/bar +Cc: Subscribed +Message-ID: +X-GitHub-Reason: subscribed + +foo bar +EOF + xlog $self, "Deliver a message"; + my $msg1 = Cassandane::Message->new(raw => $raw); + $self->{instance}->deliver($msg1); + + # if there's a delivery failure, it will be in the Inbox + xlog $self, "Check there there are no messages in the Inbox"; + my $talk = $self->{store}->get_client(); + $talk->select("INBOX"); + $self->assert_num_equals(0, $talk->get_response_code('exists')); + + # if there's no delivery failure, this folder will be created! + $talk->select("INBOX.GitHub.foo.bar.pulls.replies"); + $self->assert_num_equals(1, $talk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/imip_add b/cassandane/tiny-tests/Sieve/imip_add new file mode 100644 index 0000000000..2413918066 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_add @@ -0,0 +1,119 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_add + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Expunge the message"; + $IMAP->store('1', '+flags', '(\\Deleted)'); + $IMAP->expunge(); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('An Event', $events->[0]{title}); + $self->assert_str_equals('2021-09-23T15:30:00', $events->[0]{start}); + + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=ADD; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:ADD +BEGIN:VEVENT +UID:$uuid +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210924T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP update"; + $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event was updated on calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_not_null($events->[0]{recurrenceOverrides}{'2021-09-24T15:30:00'}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_allow_public b/cassandane/tiny-tests/Sieve/imip_allow_public new file mode 100644 index 0000000000..5e28c1a6d9 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_allow_public @@ -0,0 +1,65 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_allow_public + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_cancel b/cassandane/tiny-tests/Sieve/imip_cancel new file mode 100644 index 0000000000..2a21a210a7 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_cancel @@ -0,0 +1,118 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_cancel + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +STATUS:TENTATIVE +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Expunge the message"; + $IMAP->store('1', '+flags', '(\\Deleted)'); + $IMAP->expunge(); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('tentative', $events->[0]{status}); + + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:CANCEL +BEGIN:VEVENT +CREATED:20210924T034327Z +UID:$uuid +DTSTAMP:20210924T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User:MAILTO:foo\@example.net +ATTENDEE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP cancel"; + $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event was removed from calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('cancelled', $events->[0]{status}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_cancel_delete b/cassandane/tiny-tests/Sieve/imip_cancel_delete new file mode 100644 index 0000000000..d8b98a79d2 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_cancel_delete @@ -0,0 +1,113 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_cancel_delete + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Expunge the message"; + $IMAP->store('1', '+flags', '(\\Deleted)'); + $IMAP->expunge(); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:CANCEL +BEGIN:VEVENT +CREATED:20210924T034327Z +UID:$uuid +DTSTAMP:20210924T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User:MAILTO:foo\@example.net +ATTENDEE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP cancel"; + $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event was removed from calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_deep_equals($events, []); +} diff --git a/cassandane/tiny-tests/Sieve/imip_cancel_instance b/cassandane/tiny-tests/Sieve/imip_cancel_instance new file mode 100644 index 0000000000..bef2666176 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_cancel_instance @@ -0,0 +1,122 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_cancel_instance + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +RRULE:FREQ=WEEKLY +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Expunge the message"; + $IMAP->store('1', '+flags', '(\\Deleted)'); + $IMAP->expunge(); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('An Event', $events->[0]{title}); + $self->assert_str_equals('2021-09-23T15:30:00', $events->[0]{start}); + my $href = $events->[0]{href}; + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:CANCEL +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210924T034327Z +SEQUENCE:1 +STATUS:CANCELLED +RECURRENCE-ID;TZID=America/New_York:20210923T153000 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP cancel"; + $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the updated event made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_equals(JSON::null, + $events->[0]{recurrenceOverrides}{'2021-09-23T15:30:00'}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_cancel_to_organizer b/cassandane/tiny-tests/Sieve/imip_cancel_to_organizer new file mode 100644 index 0000000000..458a119b8a --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_cancel_to_organizer @@ -0,0 +1,97 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_cancel_to_organizer + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('tentative', $events->[0]{status}); + + + my $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:CANCEL +BEGIN:VEVENT +CREATED:20210924T034327Z +UID:$uuid +DTSTAMP:20210924T034327Z +SEQUENCE:1 +ORGANIZER:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User:MAILTO:foo\@example.net +ATTENDEE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP cancel"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Make sure that the event was NOT canceled"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('tentative', $events->[0]{status}); + $self->assert_equals(0, $events->[0]{sequence}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_disallow_public b/cassandane/tiny-tests/Sieve/imip_disallow_public new file mode 100644 index 0000000000..0a46607ea9 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_disallow_public @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_disallow_public + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event DID NOT make it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(0, scalar @$events); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite b/cassandane/tiny-tests/Sieve/imip_invite new file mode 100644 index 0000000000..8dc9a49ba2 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_base64 b/cassandane/tiny-tests/Sieve/imip_invite_base64 new file mode 100644 index 0000000000..e8d109dc03 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_base64 @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_base64 + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +Content-Transfer-Encoding: base64 +X-Cassandane-Unique: $uuid + +$itip_b64 +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_calendarid b/cassandane/tiny-tests/Sieve/imip_invite_calendarid new file mode 100644 index 0000000000..4c4f741884 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_calendarid @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_calendarid + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user and second calendar"; + my $CalDAV = $self->{caldav}; + my $CalendarId = $CalDAV->NewCalendar({name => 'foo'}); + $self->assert_not_null($CalendarId); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_funky_uid b/cassandane/tiny-tests/Sieve/imip_invite_funky_uid new file mode 100644 index 0000000000..6333522547 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_funky_uid @@ -0,0 +1,81 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_funky_uid + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +Content-Transfer-Encoding: base64 +X-Cassandane-Unique: $uuid + +$itip_b64 +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_known_organizer b/cassandane/tiny-tests/Sieve/imip_invite_known_organizer new file mode 100644 index 0000000000..aff69bac62 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_known_organizer @@ -0,0 +1,88 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_known_organizer + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Create addressbook user"; + my $CardDAV = $self->{carddav}; + + xlog $self, "Add a card for known organizer"; + my $card = <NewContact('Default', + Net::CardDAVTalk::VCard->new_fromstring($card)); + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_multipart b/cassandane/tiny-tests/Sieve/imip_invite_multipart new file mode 100644 index 0000000000..90f6a70da6 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_multipart @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_multipart + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: multipart/mixed; boundary=$uuid +Mime-Version: 1.0 +X-Cassandane-Unique: $uuid + +--$uuid +Content-Type: text/plain +Mime-Version: 1.0 + +Invite for cassandane\@example.com + +--$uuid +Content-Type: text/calendar; method=REQUEST; component=VEVENT +Content-Disposition: attachment; filename=event.ics +Mime-Version: 1.0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +--$uuid-- +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_single_then_master b/cassandane/tiny-tests/Sieve/imip_invite_single_then_master new file mode 100644 index 0000000000..050d428d34 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_single_then_master @@ -0,0 +1,120 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_single_then_master + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +RECURRENCE-ID;TZID=American/New_York:20210923T153000 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_null($events->[0]{recurrenceRule}); + $self->assert_not_null($events->[0]{recurrenceOverrides}); + + xlog $self, "Get and accept the event"; + my $href = $events->[0]{href}; + my $response = $CalDAV->Request('GET', $href); + my $ical = $response->{content}; + $ical =~ s/PARTSTAT=NEEDS-ACTION/PARTSTAT=ACCEPTED/; + + $CalDAV->Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar'); + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Overridden Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210924T034327Z +SEQUENCE:0 +RRULE:FREQ=WEEKLY +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP update"; + $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the standalone instance was dropped from the calendar"; + $events = $CalDAV->GetEvents($CalendarId); + + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_not_null($events->[0]{recurrenceRule}); + $self->assert_null($events->[0]{recurrenceOverrides}); + $self->assert_str_equals('needs-action', $events->[0]{participants}{'cassandane@example.com'}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_unknown_organizer b/cassandane/tiny-tests/Sieve/imip_invite_unknown_organizer new file mode 100644 index 0000000000..96f12d3a8b --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_unknown_organizer @@ -0,0 +1,87 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_unknown_organizer + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Create addressbook user"; + my $CardDAV = $self->{carddav}; + + xlog $self, "Add a card for known organizer"; + my $card = <NewContact('Default', + Net::CardDAVTalk::VCard->new_fromstring($card)); + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:bar\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:bar\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event DID NOT make it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(0, scalar @$events); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_updatesonly b/cassandane/tiny-tests/Sieve/imip_invite_updatesonly new file mode 100644 index 0000000000..9549202e3a --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_updatesonly @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_updatesonly + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event did NOT make it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_deep_equals([], $events); +} diff --git a/cassandane/tiny-tests/Sieve/imip_invite_wrong_addr b/cassandane/tiny-tests/Sieve/imip_invite_wrong_addr new file mode 100644 index 0000000000..63129a22cd --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_invite_wrong_addr @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_invite_wrong_addr + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:not-cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_move_event b/cassandane/tiny-tests/Sieve/imip_move_event new file mode 100644 index 0000000000..4cfbb81b95 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_move_event @@ -0,0 +1,180 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_move_event + :needs_component_jmap :needs_component_sieve :want_service_http +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + my $caldav = $self->{caldav}; + my $admin = $self->{adminstore}->get_client(); + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<CallMethods([ + ['Calendar/set', { + create => { + calendarX => { + name => 'X', + }, + calendarY => { + name => 'Y', + }, + }, + }, 'R1'], + ]); + my $calendarX = $res->[0][1]{created}{calendarX}{id}; + $self->assert_not_null($calendarX); + my $calendarY = $res->[0][1]{created}{calendarY}{id}; + $self->assert_not_null($calendarY); + + $res = $jmap->CallMethods([ + ['CalendarPreferences/set', { + update => { + singleton => { + defaultCalendarId => $calendarX, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{singleton}); + + xlog "Get CalendarEvent state"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [], + }, 'R1'], + ]); + my $state = $res->[0][1]{state}; + $self->assert_not_null($state); + + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $imip = < +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +RECURRENCE-ID;TZID=America/New_York:20210923T153000 +TRANSP:OPAQUE +SUMMARY:instance1 +DTSTART;TZID=America/New_York:20210923T153000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog "Deliver iMIP invite for instance1"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + xlog "Assert instance1 got into calendar X"; + $res = $jmap->CallMethods([ + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R1'], + ['CalendarEvent/get', { + '#ids' => { + resultOf => 'R1', + name => 'CalendarEvent/changes', + path => '/created' + }, + properties => [ 'calendarIds' ], + }, 'R2'], + ]); + $self->assert_deep_equals({ + $calendarX => JSON::true, + }, $res->[1][1]{list}[0]{calendarIds}); + my $instance1 = $res->[1][1]{list}[0]{id}; + + xlog "Move instance1 to calendar Y"; + $res = $jmap->CallMethods([ + ['CalendarEvent/set', { + update => { + $instance1 => { + calendarIds => { + $calendarY => JSON::true, + }, + }, + }, + }, 'R1'], + ]); + $self->assert(exists $res->[0][1]{updated}{$instance1}); + $state = $res->[0][1]{newState}; + + $imip = < +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +RECURRENCE-ID;TZID=America/New_York:20210930T153000 +TRANSP:OPAQUE +SUMMARY:instance2 +DTSTART;TZID=America/New_York:20210930T153000 +DURATION:PT1H +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog "Deliver iMIP invite for instance2"; + $self->{instance}->deliver(Cassandane::Message->new(raw => $imip)); + + $res = $jmap->CallMethods([ + ['CalendarEvent/changes', { + sinceState => $state, + }, 'R1'], + ]); + $self->assert_num_equals(1, scalar @{$res->[0][1]{created}}); + my $instance2 = $res->[0][1]{created}[0]; + $self->assert_str_not_equals($instance1, $instance2); + + xlog "Assert both instance1 and instance2 are in calendar Y"; + $res = $jmap->CallMethods([ + ['CalendarEvent/get', { + ids => [ $instance1, $instance2 ], + properties => [ 'calendarIds' ], + }, 'R1'], + ]); + $self->assert_num_equals(2, scalar @{$res->[0][1]{list}}); + $self->assert_deep_equals({ + $calendarY => JSON::true, + }, $res->[0][1]{list}[0]{calendarIds}); + $self->assert_deep_equals({ + $calendarY => JSON::true, + }, $res->[0][1]{list}[1]{calendarIds}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_override b/cassandane/tiny-tests/Sieve/imip_override new file mode 100644 index 0000000000..590dd16d5b --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_override @@ -0,0 +1,155 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_override + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +RRULE:FREQ=WEEKLY +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Expunge the message"; + $IMAP->store('1', '+flags', '(\\Deleted)'); + $IMAP->expunge(); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('An Event', $events->[0]{title}); + $self->assert_str_equals('2021-09-23T15:30:00', $events->[0]{start}); + + xlog $self, "Get the event, set per-user data, and add an alarm"; + my $alarm = <[0]{href}; + my $response = $CalDAV->Request('GET', $href); + my $ical = $response->{content}; + $ical =~ s/PARTSTAT=NEEDS-ACTION/PARTSTAT=ACCEPTED/; + $ical =~ s/END:VEVENT/TRANSP:TRANSPARENT\n${alarm}END:VEVENT/; + + $CalDAV->Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar'); + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Overridden Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210924T034327Z +SEQUENCE:0 +RECURRENCE-ID;TZID=America/New_York:20210923T153000 +LOCATION:location2 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP update"; + $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the updated event made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('An Overridden Event', $events->[0]{recurrenceOverrides}{'2021-09-23T15:30:00'}{title}); + $self->assert_str_equals('location2', $events->[0]{recurrenceOverrides}{'2021-09-23T15:30:00'}{locations}{location}{name}); + + xlog $self, "Make sure that per-user data remains"; + $self->assert_equals(JSON::true, $events->[0]{showAsFree}); + $self->assert_not_null($events->[0]{alerts}{myalarm}); + $self->assert_str_equals('accepted', $events->[0]{participants}{'cassandane@example.com'}{scheduleStatus}); + + $response = $CalDAV->Request('GET', $href); + $ical = Data::ICal->new(data => $response->{content}); + $self->assert_str_equals('TRANSPARENT', $ical->{entries}[0]{properties}{transp}[0]{value}); + $self->assert_str_equals('DISPLAY', $ical->{entries}[0]{entries}[0]{properties}{action}[0]{value}); + $self->assert_str_equals('TRANSPARENT', $ical->{entries}[1]{properties}{transp}[0]{value}); + $self->assert_str_equals('DISPLAY', $ical->{entries}[1]{entries}[0]{properties}{action}[0]{value}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_preserve_alerts b/cassandane/tiny-tests/Sieve/imip_preserve_alerts new file mode 100644 index 0000000000..bbb344cc29 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_preserve_alerts @@ -0,0 +1,174 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_preserve_alerts + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +RRULE:FREQ=WEEKLY +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver initial iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Overridden Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210924T034327Z +SEQUENCE:0 +RECURRENCE-ID;TZID=America/New_York:20210923T153000 +LOCATION:location2 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:bar\@example.net +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Add another attendee to a single instance"; + $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Get the event and add an alarm"; + my $alarm = <GetEvents($CalendarId); + my $href = $events->[0]{href}; + my $response = $CalDAV->Request('GET', $href); + my $ical = $response->{content}; + $ical =~ s/END:VEVENT/${alarm}END:VEVENT/g; + + $CalDAV->Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar'); + + $imip = < +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-2 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210925T034327Z +SEQUENCE:0 +RRULE:FREQ=WEEKLY +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:bar\@example.net +END:VEVENT +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Overridden Event +DTSTART;TZID=American/New_York:20210923T153000 +DTSTAMP:20210924T034327Z +SEQUENCE:0 +RECURRENCE-ID;TZID=American/New_York:20210923T153000 +LOCATION:location2 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:bar\@example.net +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Update the master to include the other user"; + $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Make sure that alarms remain"; + $events = $CalDAV->GetEvents($CalendarId); + + $response = $CalDAV->Request('GET', $href); + $ical = Data::ICal->new(data => $response->{content}); + $self->assert_str_equals('DISPLAY', $ical->{entries}[0]{entries}[0]{properties}{action}[0]{value}); + $self->assert_str_equals('DISPLAY', $ical->{entries}[1]{entries}[0]{properties}{action}[0]{value}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_publish b/cassandane/tiny-tests/Sieve/imip_publish new file mode 100644 index 0000000000..8b915bc735 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_publish @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_publish + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=PUBLISH; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:PUBLISH +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER: +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_publish_legacy b/cassandane/tiny-tests/Sieve/imip_publish_legacy new file mode 100644 index 0000000000..eace42a014 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_publish_legacy @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_publish_legacy + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=PUBLISH; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:PUBLISH +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER: +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_publish_no_organizer b/cassandane/tiny-tests/Sieve/imip_publish_no_organizer new file mode 100644 index 0000000000..e9ebd28e11 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_publish_no_organizer @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_publish_no_organizer + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=PUBLISH; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:PUBLISH +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_publish_no_organizer_legacy b/cassandane/tiny-tests/Sieve/imip_publish_no_organizer_legacy new file mode 100644 index 0000000000..c77b0388dd --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_publish_no_organizer_legacy @@ -0,0 +1,68 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_publish_no_organizer_legacy + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=PUBLISH; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:PUBLISH +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_reply b/cassandane/tiny-tests/Sieve/imip_reply new file mode 100644 index 0000000000..409fe3e00f --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_reply @@ -0,0 +1,104 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_reply + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + + my $imip = < +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the reply made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('Test User', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_reply_no_organizer b/cassandane/tiny-tests/Sieve/imip_reply_no_organizer new file mode 100644 index 0000000000..5abbb938dc --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_reply_no_organizer @@ -0,0 +1,106 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_reply_no_organizer + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + + my $imip = < +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the reply made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('Test User', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_reply_one_recurrence b/cassandane/tiny-tests/Sieve/imip_reply_one_recurrence new file mode 100644 index 0000000000..e284793bd1 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_reply_one_recurrence @@ -0,0 +1,186 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_reply_one_recurrence + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "09b59913-30b2-4f90-982a-7ce6e2a56655"; + my $href = "$CalendarId/$uuid.ics"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('Emerson Leaf', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + + my $imip = <<'EOF'; +Date: Thu, 23 Sep 2021 09:06:18 -0400 +From: Foo +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CyrusIMAP.org/Cyrus + 3.9.0-alpha0-499-gf27bbf33e2-fm-20230619.001-gf27bbf33//EN +METHOD:REPLY +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Australia/Melbourne +LAST-MODIFIED:20230427T153319Z +X-LIC-LOCATION:Australia/Melbourne +TZUNTIL:20230630T000000Z +BEGIN:STANDARD +TZNAME:AEST +TZOFFSETFROM:+1100 +TZOFFSETTO:+1000 +DTSTART:20080406T030000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:AEDT +TZOFFSETFROM:+1000 +TZOFFSETTO:+1100 +DTSTART:20081005T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +RECURRENCE-ID;TZID=Australia/Melbourne:20230629T090000 +UID:09b59913-30b2-4f90-982a-7ce6e2a56655 +DTSTAMP:20230622T024256Z +CREATED:20230622T024156Z +DTSTART;TZID=Australia/Melbourne:20230629T090000 +DURATION:PT1H +SEQUENCE:0 +PRIORITY:0 +SUMMARY:imip update bug +STATUS:CONFIRMED +ORGANIZER;X-JMAP-ID=Y3NrZWV0QGZhc3RtYWlsdGVhbS5jb20;CN=Emerson Leaf; + EMAIL=cassandane@example.com:mailto:cassandane@example.com +ATTENDEE;X-JMAP-ID=Y3NrZWV0QGV4YW1wbGUuZm0;CN=Emerson Leaf; + EMAIL=foo@example.net;CUTYPE=INDIVIDUAL;X-JMAP-ROLE=attendee; + PARTSTAT=TENTATIVE;RSVP=FALSE;X-SEQUENCE=0;X-DTSTAMP=20230622T024255Z: + mailto:foo@example.net +X-APPLE-DEFAULT-ALARM;VALUE=BOOLEAN:TRUE +X-JMAP-SENT-BY;VALUE=TEXT:cassandane@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the reply made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + # top level is not updated + $self->assert_str_equals('Emerson Leaf', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + # particular recurrence is + my $recur = '2023-06-29T09:00:00'; + + $self->assert_str_equals('Emerson Leaf', + $events->[0]{recurrenceOverrides}{$recur}{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('tentative', + $events->[0]{recurrenceOverrides}{$recur}{participants}{'foo@example.net'}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_reply_override b/cassandane/tiny-tests/Sieve/imip_reply_override new file mode 100644 index 0000000000..da96aff6be --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_reply_override @@ -0,0 +1,119 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_reply_override + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + + my $imip = < +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +END:VEVENT +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=DECLINED:MAILTO:foo\@example.net +RECURRENCE-ID;TZID=America/New_York:20211021T153000 +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the reply made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('Test User', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + $self->assert_str_equals('accepted', + $events->[0]{recurrenceOverrides}{'2021-10-21T15:30:00'}{participants}{'cassandane@example.com'}{scheduleStatus}); + $self->assert_str_equals('declined', + $events->[0]{recurrenceOverrides}{'2021-10-21T15:30:00'}{participants}{'foo@example.net'}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_reply_override_google b/cassandane/tiny-tests/Sieve/imip_reply_override_google new file mode 100644 index 0000000000..0c35f16150 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_reply_override_google @@ -0,0 +1,147 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_reply_override_google + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + + my $imip = < +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210723T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED:MAILTO:foo\@example.net +END:VEVENT +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210714T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=TENTATIVE:MAILTO:foo\@example.net +RECURRENCE-ID:20210721T193000Z +END:VEVENT +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210723T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=DECLINED:MAILTO:foo\@example.net +RECURRENCE-ID:20210728T193000Z +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the reply made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('Test User', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + $self->assert_str_equals('2021-07-22T15:30:00', + $events->[0]{recurrenceOverrides}{'2021-07-21T15:30:00'}{start}); + $self->assert_str_equals('accepted', + $events->[0]{recurrenceOverrides}{'2021-07-21T15:30:00'}{participants}{'cassandane@example.com'}{scheduleStatus}); + $self->assert_str_equals('tentative', + $events->[0]{recurrenceOverrides}{'2021-07-21T15:30:00'}{participants}{'foo@example.net'}{scheduleStatus}); + + $self->assert_str_equals('accepted', + $events->[0]{recurrenceOverrides}{'2021-07-28T15:30:00'}{participants}{'cassandane@example.com'}{scheduleStatus}); + $self->assert_str_equals('declined', + $events->[0]{recurrenceOverrides}{'2021-07-28T15:30:00'}{participants}{'foo@example.net'}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_reply_override_invalid b/cassandane/tiny-tests/Sieve/imip_reply_override_invalid new file mode 100644 index 0000000000..56e4fe14e2 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_reply_override_invalid @@ -0,0 +1,198 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_reply_override_invalid + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + $self->assert_null($events->[0]{recurrenceRule}); + + + my $imip = < +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=DECLINED:MAILTO:foo\@example.net +RECURRENCE-ID;TZID=America/New_York:20211021T153000 +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Expunge the message"; + $IMAP->store('1', '+flags', '(\\Deleted)'); + $IMAP->expunge(); + + xlog $self, "Check that the reply was ignored"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + $self->assert_null($events->[0]{recurrenceRule}); + $self->assert_null($events->[0]{recurrenceOverrides}); + + + $event = <Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + $self->assert_not_null($events->[0]{recurrenceRule}); + $self->assert_null($events->[0]{recurrenceOverrides}); + + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +END:VEVENT +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=DECLINED:MAILTO:foo\@example.net +RECURRENCE-ID;TZID=America/New_York:20211022T153000 +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the reply (master only) made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('Test User', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + $self->assert_not_null($events->[0]{recurrenceRule}); + $self->assert_null($events->[0]{recurrenceOverrides}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_reply_override_rdate b/cassandane/tiny-tests/Sieve/imip_reply_override_rdate new file mode 100644 index 0000000000..e930aed92f --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_reply_override_rdate @@ -0,0 +1,117 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_reply_override_rdate + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + my $imip = < +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +END:VEVENT +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=DECLINED:MAILTO:foo\@example.net +RECURRENCE-ID;TZID=America/New_York:20211021T153000 +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the reply made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('Test User', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + $self->assert_str_equals('accepted', + $events->[0]{recurrenceOverrides}{'2021-10-21T15:30:00'}{participants}{'cassandane@example.com'}{scheduleStatus}); + $self->assert_str_equals('declined', + $events->[0]{recurrenceOverrides}{'2021-10-21T15:30:00'}{participants}{'foo@example.net'}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_reply_with_alarm b/cassandane/tiny-tests/Sieve/imip_reply_with_alarm new file mode 100644 index 0000000000..80e0f72525 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_reply_with_alarm @@ -0,0 +1,110 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_reply_with_alarm + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + my $href = "$CalendarId/$uuid.ics"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(<Request('PUT', $href, $event, 'Content-Type' => 'text/calendar'); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('needs-action', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); + + + my $imip = < +To: Cassandane +Message-ID: <$uuid\@example.net> +Content-Type: text/calendar; method=REPLY; component=VEVENT +X-Cassandane-Unique: $uuid + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REPLY +BEGIN:VEVENT +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Cassandane:MAILTO:cassandane\@example.com +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +BEGIN:VALARM +UID:myalarm +TRIGGER;RELATED=START:PT0S +ACTION:DISPLAY +DESCRIPTION:CYR-140 +END:VALARM +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP reply"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the reply made it to calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('Test User', + $events->[0]{participants}{'foo@example.net'}{name}); + $self->assert_str_equals('accepted', + $events->[0]{participants}{'foo@example.net'}{scheduleStatus}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_strip_personal_data b/cassandane/tiny-tests/Sieve/imip_strip_personal_data new file mode 100644 index 0000000000..60b7f2357d --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_strip_personal_data @@ -0,0 +1,84 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_strip_personal_data + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +COLOR:red +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +RRULE:FREQ=WEEKLY +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +CATEGORIES:#0000FF +CATEGORIES:blue +CATEGORIES:foo +BEGIN:VALARM +UID:myalarm +TRIGGER;RELATED=START:PT0S +ACTION:DISPLAY +DESCRIPTION:CYR-140 +END:VALARM +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Get the event and verify that personal data has been stripped"; + my $events = $CalDAV->GetEvents($CalendarId); + my $response = $CalDAV->Request('GET', $events->[0]{href}); + my $ical = $response->{content}; + $self->assert_does_not_match(qr/\r\nBEGIN:VALARM/, $ical); + $self->assert_does_not_match(qr/\r\nCOLOR:/, $ical); + $self->assert_does_not_match(qr/\r\nTRANSP:/, $ical); + $self->assert_does_not_match(qr/\r\nCATEGORIES:#0000FF/, $ical); + $self->assert_does_not_match(qr/\r\nCATEGORIES:blue/, $ical); + $self->assert_matches(qr/\r\nCATEGORIES:foo/, $ical); +} diff --git a/cassandane/tiny-tests/Sieve/imip_update b/cassandane/tiny-tests/Sieve/imip_update new file mode 100644 index 0000000000..c9b3df52b0 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_update @@ -0,0 +1,123 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_update + :needs_component_sieve :needs_component_httpd :want_service_http +{ + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Expunge the message"; + $IMAP->store('1', '+flags', '(\\Deleted)'); + $IMAP->expunge(); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('An Event', $events->[0]{title}); + $self->assert_str_equals('2021-09-23T15:30:00', $events->[0]{start}); + + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210924T170000 +TRANSP:OPAQUE +SUMMARY:An Updated Event +DTSTART;TZID=America/New_York:20210924T140000 +DTSTAMP:20210923T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP update"; + $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event was updated on calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('An Updated Event', $events->[0]{title}); + $self->assert_str_equals('2021-09-24T14:00:00', $events->[0]{start}); +} diff --git a/cassandane/tiny-tests/Sieve/imip_update_master_and_add_override b/cassandane/tiny-tests/Sieve/imip_update_master_and_add_override new file mode 100644 index 0000000000..74ddbc26b9 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/imip_update_master_and_add_override @@ -0,0 +1,157 @@ +#!perl +use Cassandane::Tiny; + +sub test_imip_update_master_and_add_override + :needs_component_sieve :needs_component_httpd :want_service_http +{ + # this test is mainly here to test that a crasher caused by Cyrus using + # a libical component after it was freed + my ($self) = @_; + + my $IMAP = $self->{store}->get_client(); + $self->{store}->_select(); + $self->assert_num_equals(1, $IMAP->uid()); + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $CalendarId = 'Default'; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 1, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Expunge the message"; + $IMAP->store('1', '+flags', '(\\Deleted)'); + $IMAP->expunge(); + + xlog $self, "Check that the event made it to calendar"; + my $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('An Event', $events->[0]{title}); + $self->assert_str_equals('2021-09-23T15:30:00', $events->[0]{start}); + + + xlog $self, "Get the event, set per-user data, and add an alarm"; + my $alarm = <[0]{href}; + my $response = $CalDAV->Request('GET', $href); + my $ical = $response->{content}; + $ical =~ s/PARTSTAT=NEEDS-ACTION/PARTSTAT=ACCEPTED/; + $ical =~ s/END:VEVENT/TRANSP:TRANSPARENT\n${alarm}END:VEVENT/; + + $CalDAV->Request('PUT', $href, $ical, 'Content-Type' => 'text/calendar'); + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210924T170000 +TRANSP:OPAQUE +SUMMARY:An Updated Event +DTSTART;TZID=America/New_York:20210924T140000 +DTSTAMP:20210923T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210924T183000 +TRANSP:OPAQUE +SUMMARY:An Overridden Event +DTSTART;TZID=America/New_York:20210924T153000 +DTSTAMP:20210924T034327Z +SEQUENCE:0 +RECURRENCE-ID;TZID=America/New_York:20210924T153000 +LOCATION:location2 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP update"; + $msg = Cassandane::Message->new(raw => $imip); + $msg->set_attribute(uid => 2, + flags => [ '\\Recent', '\\Flagged' ]); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Check that the event was updated on calendar"; + $events = $CalDAV->GetEvents($CalendarId); + $self->assert_equals(1, scalar @$events); + $self->assert_str_equals($uuid, $events->[0]{uid}); + $self->assert_str_equals('An Updated Event', $events->[0]{title}); + $self->assert_str_equals('2021-09-24T14:00:00', $events->[0]{start}); +} diff --git a/cassandane/tiny-tests/Sieve/implicit_keep_target_bad b/cassandane/tiny-tests/Sieve/implicit_keep_target_bad new file mode 100644 index 0000000000..9b49bd913a --- /dev/null +++ b/cassandane/tiny-tests/Sieve/implicit_keep_target_bad @@ -0,0 +1,37 @@ +#!perl +use Cassandane::Tiny; + +sub test_implicit_keep_target_bad + :min_version_3_9 :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my ($res, $errs) = $self->compile_sievec('norequire', <assert_str_equals('failure', $res); + $self->assert_matches(qr/vnd.cyrus.implicit_keep_target extension MUST be enabled/, $errs); + $self->assert_matches(qr/mailboxid extension MUST be enabled/, $errs); + $self->assert_matches(qr/special-use extension MUST be enabled/, $errs); + + ($res, $errs) = $self->compile_sievec('conflict', <assert_str_equals('failure', $res); + $self->assert_matches(qr/tag :specialuse MUST NOT be used with tag :mailboxid/, $errs); + + ($res, $errs) = $self->compile_sievec('badsyntax', <assert_str_equals('failure', $res); + $self->assert_matches(qr/syntax error/, $errs); +} + diff --git a/cassandane/tiny-tests/Sieve/implicit_keep_target_conditional b/cassandane/tiny-tests/Sieve/implicit_keep_target_conditional new file mode 100644 index 0000000000..2b0948b649 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/implicit_keep_target_conditional @@ -0,0 +1,69 @@ +#!perl +use Cassandane::Tiny; + +sub test_implicit_keep_target_conditional + :min_version_3_9 :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my $folder1 = "INBOX.foo"; + my $folder2 = "INBOX.bar"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder1) + or die "Cannot create $folder1: $@"; + $imaptalk->create($folder2) + or die "Cannot create $folder2: $@"; + + xlog $self, "Install a script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + $msg1->set_attribute(uid => 1); + + xlog $self, "Check that the message made it to $folder1"; + $self->{store}->set_folder($folder1); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Deliver a message"; + my $msg2 = $self->{gen}->generate(subject => "bar"); + $self->{instance}->deliver($msg2); + $msg2->set_attribute(uid => 1); + + xlog $self, "Check that the message made it to $folder2"; + $self->{store}->set_folder($folder2); + $self->check_messages({ 1 => $msg2 }, check_guid => 0); + + xlog $self, "Deliver a message"; + $msg1 = $self->{gen}->generate(subject => "keep"); + $self->{instance}->deliver($msg1); + $msg1->set_attribute(uid => 1); + + xlog $self, "Deliver a message"; + $msg2 = $self->{gen}->generate(subject => "error"); + $self->{instance}->deliver($msg2); + $msg2->set_attribute(uid => 2); + + xlog $self, "Check that the messages made it to INBOX"; + $self->{store}->set_folder("INBOX"); + $self->check_messages({ 1 => $msg1, 2 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/implicit_keep_target_mailboxid b/cassandane/tiny-tests/Sieve/implicit_keep_target_mailboxid new file mode 100644 index 0000000000..a39feb9ba8 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/implicit_keep_target_mailboxid @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_implicit_keep_target_mailboxid + :min_version_3_9 :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my $folder = "INBOX.foo"; + + xlog $self, "Create folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + + xlog $self, "Get folder id"; + my $res = $imaptalk->status($folder, ['mailboxid']); + my $folderid = $res->{mailboxid}[0]; + + xlog $self, "Install a script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to $folder"; + $self->{store}->set_folder($folder); + $self->check_messages({ 1 => $msg }, check_guid => 0); +} + diff --git a/cassandane/tiny-tests/Sieve/implicit_keep_target_mailboxname b/cassandane/tiny-tests/Sieve/implicit_keep_target_mailboxname new file mode 100644 index 0000000000..06f7e7aed3 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/implicit_keep_target_mailboxname @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_implicit_keep_target_mailboxname + :min_version_3_9 :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my $folder = "INBOX.foo"; + + xlog $self, "Create folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + + xlog $self, "Install a script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to $folder"; + $self->{store}->set_folder($folder); + $self->check_messages({ 1 => $msg }, check_guid => 0); +} + diff --git a/cassandane/tiny-tests/Sieve/implicit_keep_target_none b/cassandane/tiny-tests/Sieve/implicit_keep_target_none new file mode 100644 index 0000000000..709a4e7a62 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/implicit_keep_target_none @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_implicit_keep_target_none + :min_version_3_9 :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my $folder = "INBOX.foo"; + + xlog $self, "Create folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + + xlog $self, "Allow plus address delivery"; + $imaptalk->setacl($folder, 'anyone' => 'p'); + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "Install a script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Implicit"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to INBOX"; + $self->{store}->set_folder('INBOX'); + $self->{store}->set_fetch_attributes(qw(uid flags)); + $msg->set_attribute(uid => 1); + $msg->set_attribute(flags => [ '\\Recent' ]); + $self->check_messages({ 1 => $msg }, check_guid => 0); + + xlog $self, "Deliver a message to plus address"; + $msg = $self->{gen}->generate(subject => "Explicit"); + $self->{instance}->deliver($msg, user => "cassandane+foo"); + + xlog $self, "Check that the message made it to $folder"; + $self->{store}->set_folder($folder); + $msg->set_attribute(uid => 1); + $msg->set_attribute(flags => [ '\\Recent', '\\Flagged']); + $self->check_messages({ 1 => $msg }, check_guid => 0); +} + diff --git a/cassandane/tiny-tests/Sieve/implicit_keep_target_specialuse b/cassandane/tiny-tests/Sieve/implicit_keep_target_specialuse new file mode 100644 index 0000000000..0bd253c07c --- /dev/null +++ b/cassandane/tiny-tests/Sieve/implicit_keep_target_specialuse @@ -0,0 +1,31 @@ +#!perl +use Cassandane::Tiny; + +sub test_implicit_keep_target_specialuse + :min_version_3_9 :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my $folder = "INBOX.foo"; + + xlog $self, "Create folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder, "(use (\\Important))") + or die "Cannot create $folder: $@"; + + xlog $self, "Install a script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to $folder"; + $self->{store}->set_folder($folder); + $self->check_messages({ 1 => $msg }, check_guid => 0); +} + diff --git a/cassandane/tiny-tests/Sieve/implicit_keep_target_stop b/cassandane/tiny-tests/Sieve/implicit_keep_target_stop new file mode 100644 index 0000000000..8d9c209309 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/implicit_keep_target_stop @@ -0,0 +1,32 @@ +#!perl +use Cassandane::Tiny; + +sub test_implicit_keep_target_stop + :min_version_3_9 :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my $folder = "INBOX.foo"; + + xlog $self, "Create folder"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + + xlog $self, "Install a script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it to $folder"; + $self->{store}->set_folder($folder); + $self->check_messages({ 1 => $msg }, check_guid => 0); +} + diff --git a/cassandane/tiny-tests/Sieve/include_cancel_implicit_keep b/cassandane/tiny-tests/Sieve/include_cancel_implicit_keep new file mode 100644 index 0000000000..88bc997ed9 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/include_cancel_implicit_keep @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_include_cancel_implicit_keep + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Install a script which includes another"; + $self->{instance}->install_sieve_script(<{instance}->install_sieve_script(<'foo'); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that no messages are in INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({}, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/include_fileinto_implicit_keep_flags b/cassandane/tiny-tests/Sieve/include_fileinto_implicit_keep_flags new file mode 100644 index 0000000000..bc2bb5d648 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/include_fileinto_implicit_keep_flags @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_include_fileinto_implicit_keep_flags + :needs_component_sieve +{ + my ($self) = @_; + + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Install a script which includes another"; + $self->{instance}->install_sieve_script(<{instance}->install_sieve_script(<'foo'); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that only last copy of the message made it to INBOX"; + $self->{store}->set_folder('INBOX'); + $msg->set_attribute(flags => [ '\\Recent', '\\Flagged' ]); + $self->check_messages({ 1 => $msg }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/jmapquery b/cassandane/tiny-tests/Sieve/jmapquery new file mode 100644 index 0000000000..f2f3f77177 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/jmapquery @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_jmapquery + :min_version_3_3 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $imap = $self->{store}->get_client(); + $imap->create("INBOX.matches") or die; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "operator" : "OR", + "conditions" : [ + { + "deliveredTo" : "xxx@yyy.zzz", + "attachmentType" : "image" + } + ] + } +. + ) +{ + fileinto "INBOX.matches"; +} +EOF + ); + + my $body = << 'EOF'; +--047d7b33dd729737fe04d3bde348 +Content-Type: text/plain; charset=UTF-8 + +plain + +--047d7b33dd729737fe04d3bde348 +Content-Type: image/tiff +Content-Transfer-Encoding: base64 + +abc= + +--047d7b33dd729737fe04d3bde348-- +EOF + $body =~ s/\r?\n/\r\n/gs; + + xlog $self, "Deliver a matching message"; + my $msg1 = $self->{gen}->generate( + subject => "Message 1", + extra_headers => [['X-Delivered-To', 'xxx@yyy.zzz']], + mime_type => "multipart/mixed", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body, + ); + $self->{instance}->deliver($msg1); + + $self->{store}->set_fetch_attributes('uid'); + + xlog "Assert that message got moved into INBOX.matches"; + $self->{store}->set_folder('INBOX.matches'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Deliver a non-matching message"; + my $msg2 = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg2); + $msg2->set_attribute(uid => 1); + + xlog "Assert that message got moved into INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/jmapquery_attachmentindexing_body b/cassandane/tiny-tests/Sieve/jmapquery_attachmentindexing_body new file mode 100644 index 0000000000..31651ea83d --- /dev/null +++ b/cassandane/tiny-tests/Sieve/jmapquery_attachmentindexing_body @@ -0,0 +1,85 @@ +#!perl +use Cassandane::Tiny; + +sub test_jmapquery_attachmentindexing_body + :min_version_3_3 :needs_component_sieve :needs_component_jmap + :needs_search_xapian :SearchAttachmentExtractor :JMAPExtensions +{ + # Assert that a 'body' filter in a Sieve script does NOT + # cause the attachment indexer to get called. + + my ($self) = @_; + + my $imap = $self->{store}->get_client(); + my $instance = $self->{instance}; + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + my (undef, $filename) = tempfile('tmpXXXXXX', OPEN => 0, + DIR => $instance->{basedir} . "/tmp"); + + xlog "Start a dummy extractor server"; + my $handler = sub { + my ($conn, $req) = @_; + open HANDLE, ">$filename" || die; + print HANDLE "$req->method"; + close HANDLE; + if ($req->method eq 'HEAD') { + my $res = HTTP::Response->new(204); + $res->content(""); + $conn->send_response($res); + } else { + my $res = HTTP::Response->new(200); + $res->content("data"); + $conn->send_response($res); + } + }; + $instance->start_httpd($handler, $uri->port()); + + xlog "Install JMAP sieve script"; + $imap->create("INBOX.matches") or die; + $instance->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "body": "plaintext" + } +. + ) +{ + fileinto "INBOX.matches"; +} +EOF + ); + + xlog "Deliver a message with attachment"; + my $body = << 'EOF'; +--047d7b33dd729737fe04d3bde348 +Content-Type: text/plain; charset=UTF-8 + +plaintext + +--047d7b33dd729737fe04d3bde348 +Content-Type: application/pdf + +data + +--047d7b33dd729737fe04d3bde348-- +EOF + $body =~ s/\r?\n/\r\n/gs; + my $msg1 = $self->{gen}->generate( + subject => "Message 1", + mime_type => "multipart/mixed", + mime_boundary => "047d7b33dd729737fe04d3bde348", + body => $body, + ); + $instance->deliver($msg1); + + xlog "Assert that extractor did NOT get called"; + $self->assert_not_file_test($filename); + + xlog "Assert that message got moved into INBOX.matches"; + $self->{store}->set_folder('INBOX.matches'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/jmapquery_attachmentindexing_text b/cassandane/tiny-tests/Sieve/jmapquery_attachmentindexing_text new file mode 100644 index 0000000000..ea63e8fb3e --- /dev/null +++ b/cassandane/tiny-tests/Sieve/jmapquery_attachmentindexing_text @@ -0,0 +1,96 @@ +#!perl +use Cassandane::Tiny; + +sub test_jmapquery_attachmentindexing_text + :min_version_3_9 :needs_component_sieve :needs_component_jmap + :needs_search_xapian :SearchAttachmentExtractor :JMAPExtensions +{ + # Assert that a 'text' filter in a Sieve script DOES + # cause the attachment indexer to get called. + + my ($self) = @_; + my $jmap = $self->{jmap}; + my $imap = $self->{store}->get_client(); + my $instance = $self->{instance}; + + my $uri = URI->new($instance->{config}->get('search_attachment_extractor_url')); + + # Start a dummy extractor server. + my $handler = sub { + my ($conn, $req) = @_; + if ($req->method eq 'HEAD') { + my $res = HTTP::Response->new(204); + $res->content(""); + $conn->send_response($res); + } else { + my $res = HTTP::Response->new(200); + $res->content("testattach"); + $conn->send_response($res); + } + }; + $instance->start_httpd($handler, $uri->port()); + + $imap->create("matches") or die; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "body": "testbody" + } +. +, + jmapquery text: + { + "text": "testattach" + } +. +, + jmapquery text: + { + "attachmentBody": "testattach" + } +. + ) +{ + fileinto "matches"; +} +EOF + ); + + my $mime = <<'EOF'; +From: from@local +To: to@local +Subject: testsubject +Date: Mon, 13 Apr 2020 15:34:03 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary=c4683f7a320d4d20902b000486fbdf9b + +--c4683f7a320d4d20902b000486fbdf9b +Content-Type: text/plain + +testbody + +--c4683f7a320d4d20902b000486fbdf9b +Content-Disposition: attachment;filename="test.pdf" +Content-Type: application/pdf; name="test.pdf" +Content-Transfer-Encoding: base64 + +dGVzdGF0dGFjaG1lbnQK + +--c4683f7a320d4d20902b000486fbdf9b-- +EOF + $mime =~ s/\r?\n/\r\n/gs; + + my $msg = Cassandane::Message->new(); + $msg->set_lines(split /\n/, $mime); + $self->{instance}->deliver($msg); + + xlog "Assert that message got moved into INBOX.matches"; + $imap->select('matches'); + $self->assert_num_equals(1, $imap->get_response_code('exists')); + $imap->unselect(); +} diff --git a/cassandane/tiny-tests/Sieve/jmapquery_missing_in_reply_to b/cassandane/tiny-tests/Sieve/jmapquery_missing_in_reply_to new file mode 100644 index 0000000000..1b05bb46e5 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/jmapquery_missing_in_reply_to @@ -0,0 +1,52 @@ +#!perl +use Cassandane::Tiny; + +sub test_jmapquery_missing_in_reply_to + :min_version_3_9 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $imap = $self->{store}->get_client(); + $imap->create("INBOX.matches") or die; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + jmapquery text: + { + "noneInThreadHaveKeyword" : "$seen" + } +. +{ + fileinto "INBOX.matches"; +} +EOF + ); + + my $body = << 'EOF'; +--047d7b33dd729737fe04d3bde348 +Content-Type: text/plain; charset=UTF-8 + +plain + +--047d7b33dd729737fe04d3bde348 +Content-Type: image/tiff +Content-Transfer-Encoding: base64 + +abc= + +--047d7b33dd729737fe04d3bde348-- +EOF + $body =~ s/\r?\n/\r\n/gs; + + xlog $self, "Deliver a message without in-reply-to"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + $msg1->set_attribute(uid => 1); + + # better not have just crashed! + + xlog "Assert that message got moved into INBOX.matches"; + $self->{store}->set_folder('INBOX.matches'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/jmapquery_multiple_to_cross_domain b/cassandane/tiny-tests/Sieve/jmapquery_multiple_to_cross_domain new file mode 100644 index 0000000000..cd56cfa9bb --- /dev/null +++ b/cassandane/tiny-tests/Sieve/jmapquery_multiple_to_cross_domain @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_jmapquery_multiple_to_cross_domain + :min_version_3_3 :needs_component_sieve :needs_component_jmap +{ + my ($self) = @_; + + my $imap = $self->{store}->get_client(); + $imap->create("INBOX.matches") or die; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["x-cyrus-jmapquery", "x-cyrus-log", "variables", "fileinto"]; +if + allof( not string :is "${stop}" "Y", + jmapquery text: + { + "to" : "foo@example.net", + "header" : ["X-Foo"] + } +. + ) +{ + fileinto "INBOX.matches"; +} +EOF + ); + + xlog $self, "Deliver a matching message"; + my $msg1 = $self->{gen}->generate( + subject => "Message 1", + extra_headers => [['To', 'foo@example.net'], + ['X-Foo', 'bar'] + ], + ); + $self->{instance}->deliver($msg1); + + $self->{store}->set_fetch_attributes('uid'); + + xlog "Assert that message got moved into INBOX.matches"; + $self->{store}->set_folder('INBOX.matches'); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Deliver a non-matching message"; + my $msg2 = $self->{gen}->generate( + subject => "Message 2", + extra_headers => [['To', 'foo@example.com, bar@example.net'], + ['X-Foo', 'bar'] + ], + ); + $self->{instance}->deliver($msg2); + $msg2->set_attribute(uid => 1); + + xlog "Assert that message got moved into INBOX"; + $self->{store}->set_folder('INBOX'); + $self->check_messages({ 1 => $msg2 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/nested_tests_and_discard b/cassandane/tiny-tests/Sieve/nested_tests_and_discard new file mode 100644 index 0000000000..62c6be2e0f --- /dev/null +++ b/cassandane/tiny-tests/Sieve/nested_tests_and_discard @@ -0,0 +1,29 @@ +#!perl +use Cassandane::Tiny; + +sub test_nested_tests_and_discard + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Install a sieve script discarding all mail"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + # should fail to deliver and NOT appear in INBOX + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + $self->assert_num_equals(0, $imaptalk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/plus_address_mark_read b/cassandane/tiny-tests/Sieve/plus_address_mark_read new file mode 100644 index 0000000000..5b37503df8 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/plus_address_mark_read @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_plus_address_mark_read + :needs_component_sieve :NoAltNameSpace +{ + my ($self) = @_; + + my $folder = "INBOX.foo"; + + xlog $self, "Create folders"; + my $imaptalk = $self->{store}->get_client(); + $imaptalk->create($folder) + or die "Cannot create $folder: $@"; + + $imaptalk->setacl($folder, 'anyone' => 'p'); + + xlog $self, "Install a script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg, user => "cassandane+foo"); + + xlog $self, "Check that the message made it to $folder"; + $self->{store}->set_folder($folder); + $self->{store}->set_fetch_attributes(qw(uid flags)); + $msg->set_attribute(flags => [ '\\Recent', '\\Seen']); + $self->check_messages({ 1 => $msg }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/redirect_address_with_phrase b/cassandane/tiny-tests/Sieve/redirect_address_with_phrase new file mode 100644 index 0000000000..8291daacba --- /dev/null +++ b/cassandane/tiny-tests/Sieve/redirect_address_with_phrase @@ -0,0 +1,28 @@ +#!perl +use Cassandane::Tiny; + +sub test_redirect_address_with_phrase + :needs_component_sieve + :want_smtpdaemon +{ + my ($self) = @_; + + xlog $self, "Install a script"; + $self->{instance}->install_sieve_script(<"; +EOF + ); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + # Verify that message was redirected (no RCPT TO error) + $self->assert_syslog_does_not_match($self->{instance}, + qr/RCPT TO: code=553 text=5.1.1/); + + xlog $self, "Make sure that message is NOT in INBOX (due to runtime error)"; + my $talk = $self->{store}->get_client(); + $talk->select("INBOX"); + $self->assert_num_equals(0, $talk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/remove_itip_on_jmap_update b/cassandane/tiny-tests/Sieve/remove_itip_on_jmap_update new file mode 100644 index 0000000000..7d6af8e67e --- /dev/null +++ b/cassandane/tiny-tests/Sieve/remove_itip_on_jmap_update @@ -0,0 +1,251 @@ +#!perl +use Cassandane::Tiny; + +sub test_remove_itip_on_jmap_update + :needs_component_sieve :needs_component_httpd :needs_component_jmap + :want_service_http +{ + my ($self) = @_; + + my $jmap = $self->{jmap}; + + xlog $self, "Create calendar user"; + my $CalDAV = $self->{caldav}; + my $uuid = "6de280c9-edff-4019-8ebd-cfebc73f8201"; + + xlog $self, "Install a sieve script to process iMIP"; + $self->{instance}->install_sieve_script(< +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane: + MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite"; + my $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210924T170000 +TRANSP:OPAQUE +SUMMARY:An Updated Event +DTSTART;TZID=America/New_York:20210924T140000 +DTSTAMP:20210923T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane: + MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP update"; + $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Check that 2 iTIP messages made it to Scheduling Inbox"; + my $events = $CalDAV->GetEvents('Inbox'); + $self->assert_equals(2, scalar @$events); + + my $res = $jmap->CallMethods([['CalendarEvent/get', {}, "R1"]]); + my $id = $res->[0][1]{list}[0]{id}; + + xlog $self, "Update participationStatus"; + $res = $jmap->CallMethods([['CalendarEvent/set', + {update => + {$id => + { "participants/cassandane/participationStatus" => "accepted"}}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "Check that iTIP messages were removed from Scheduling Inbox"; + $events = $CalDAV->GetEvents('Inbox'); + $self->assert_equals(0, scalar @$events); + + + $imip = < +To: Cassandane +Message-ID: <$uuid-1\@example.net> +Content-Type: text/calendar; method=CANCEL; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:CANCEL +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTSTAMP:20210923T034327Z +SEQUENCE:2 +STATUS:CANCELLED +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane: + MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP cancel"; + $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Delete canceled event"; + $res = $jmap->CallMethods([['CalendarEvent/set', {destroy => [$id]}, "R2"]]); + + xlog $self, "Check that iTIP messages were removed from Scheduling Inbox"; + $events = $CalDAV->GetEvents('Inbox'); + $self->assert_equals(0, scalar @$events); + + $imip = < +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-0 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210923T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210923T153000 +RECURRENCE-ID;TZID=America/New_York:20210923T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:0 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane: + MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite for one instance"; + $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + $imip = < +To: Cassandane +Message-ID: <$uuid-0\@example.net> +Content-Type: text/calendar; method=REQUEST; component=VEVENT +X-Cassandane-Unique: $uuid-1 + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.10.4//EN +METHOD:REQUEST +BEGIN:VEVENT +CREATED:20210923T034327Z +UID:$uuid +DTEND;TZID=America/New_York:20210930T183000 +TRANSP:OPAQUE +SUMMARY:An Event +DTSTART;TZID=America/New_York:20210930T153000 +RECURRENCE-ID;TZID=America/New_York:20210930T153000 +DTSTAMP:20210923T034327Z +SEQUENCE:1 +ORGANIZER;CN=Test User:MAILTO:foo\@example.net +ATTENDEE;CN=Test User;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:foo\@example.net +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;X-JMAP-ID=cassandane: + MAILTO:cassandane\@example.com +END:VEVENT +END:VCALENDAR +EOF + + xlog $self, "Deliver iMIP invite for another instance"; + $msg = Cassandane::Message->new(raw => $imip); + $self->{instance}->deliver($msg); + + xlog $self, "Check that 2 iTIP messages made it to Scheduling Inbox"; + $events = $CalDAV->GetEvents('Inbox'); + $self->assert_equals(2, scalar @$events); + + $res = $jmap->CallMethods([['CalendarEvent/get', {}, "R1"]]); + $id = $res->[0][1]{list}[0]{id}; + my $id2 = $res->[0][1]{list}[1]{id}; + + xlog $self, "Update participationStatus on one instance"; + $res = $jmap->CallMethods([['CalendarEvent/set', + {update => + {$id => + { "participants/cassandane/participationStatus" => "accepted"}}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "Check that one iTIP message was removed from Scheduling Inbox"; + $events = $CalDAV->GetEvents('Inbox'); + $self->assert_equals(1, scalar @$events); + + xlog $self, "Update participationStatus on the other instance"; + $res = $jmap->CallMethods([['CalendarEvent/set', + {update => + {$id2 => + { "participants/cassandane/participationStatus" => "accepted"}}}, + "R1"]]); + $self->assert_not_null($res->[0][1]{updated}); + + xlog $self, "Check that one iTIP message was removed from Scheduling Inbox"; + $events = $CalDAV->GetEvents('Inbox'); + $self->assert_equals(0, scalar @$events); +} diff --git a/cassandane/tiny-tests/Sieve/rfc5490_create b/cassandane/tiny-tests/Sieve/rfc5490_create new file mode 100644 index 0000000000..f8e5b429a8 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/rfc5490_create @@ -0,0 +1,54 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5490_create + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"fileinto :create\" syntax"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'lazySusan'; + $self->{instance}->install_sieve_script(< 'quinoa', filedto => $hitfolder }, + { subject => 'QUINOA', filedto => $hitfolder }, + { subject => 'Quinoa', filedto => $hitfolder }, + { subject => 'QuinoA', filedto => $hitfolder }, + { subject => 'qUINOa', filedto => $hitfolder }, + { subject => 'selvage', filedto => $missfolder }, + ); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + foreach my $case (@cases) + { + xlog $self, "Deliver a message with subject \"$case->{subject}\""; + my $msg = $self->{gen}->generate(subject => $case->{subject}); + $msg->set_attribute(uid => $uid{$case->{filedto}}); + $uid{$case->{filedto}}++; + $self->{instance}->deliver($msg); + $exp{$case->{filedto}}->{$case->{subject}} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/rfc5490_mailboxexists b/cassandane/tiny-tests/Sieve/rfc5490_mailboxexists new file mode 100644 index 0000000000..05aaf99249 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/rfc5490_mailboxexists @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5490_mailboxexists + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"mailboxexists\" test"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $testfolder = "INBOX.testfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<create($hitfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the test folder"; + $talk->create($testfolder); + + xlog $self, "Deliver a message now that the folder exists"; + { + my $msg = $self->{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/rfc5490_mailboxexists_variables b/cassandane/tiny-tests/Sieve/rfc5490_mailboxexists_variables new file mode 100644 index 0000000000..54b6f41252 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/rfc5490_mailboxexists_variables @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5490_mailboxexists_variables + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"mailboxexists\" test with variables"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $testfolder = "INBOX.testfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<create($hitfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the test folder"; + $talk->create($testfolder); + + xlog $self, "Deliver a message now that the folder exists"; + { + my $msg = $self->{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/rfc5490_metadata b/cassandane/tiny-tests/Sieve/rfc5490_metadata new file mode 100644 index 0000000000..9a5a5b1394 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/rfc5490_metadata @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5490_metadata + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"metadata\" test"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(<create($hitfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the annotation"; + $talk->setmetadata("INBOX", "/private/comment", "awesome"); + + xlog $self, "Deliver a message now that the folder exists"; + { + my $msg = $self->{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/rfc5490_metadata_matches b/cassandane/tiny-tests/Sieve/rfc5490_metadata_matches new file mode 100644 index 0000000000..8bb47f79e2 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/rfc5490_metadata_matches @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5490_metadata_matches + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"metadata\" test"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(<setmetadata("INBOX", "/private/comment", "awesomesauce"); + + $talk->create($hitfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the annotation"; + $talk->setmetadata("INBOX", "/private/comment", "awesome"); + + xlog $self, "Deliver a message now that the folder exists"; + { + my $msg = $self->{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/rfc5490_metadataexists b/cassandane/tiny-tests/Sieve/rfc5490_metadataexists new file mode 100644 index 0000000000..8cb3d83eda --- /dev/null +++ b/cassandane/tiny-tests/Sieve/rfc5490_metadataexists @@ -0,0 +1,57 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5490_metadataexists + :min_version_3_0 :AnnotationAllowUndefined + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"metadataexists\" test"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(<create($hitfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the annotation"; + $talk->setmetadata("INBOX", "/private/magic", "hello"); + + xlog $self, "Deliver a message now that the folder exists"; + { + my $msg = $self->{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/rfc5490_servermetadata b/cassandane/tiny-tests/Sieve/rfc5490_servermetadata new file mode 100644 index 0000000000..34e5686e3e --- /dev/null +++ b/cassandane/tiny-tests/Sieve/rfc5490_servermetadata @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5490_servermetadata + :min_version_3_0 :AnnotationAllowUndefined + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"metadata\" test"; + + my $talk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(<create($hitfolder); + + # have a value + $admintalk->setmetadata("", "/shared/magic", "awesomesauce"); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the annotation"; + $admintalk->setmetadata("", "/shared/magic", "awesome"); + + xlog $self, "Deliver a message now that the folder exists"; + { + my $msg = $self->{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/rfc5490_servermetadataexists b/cassandane/tiny-tests/Sieve/rfc5490_servermetadataexists new file mode 100644 index 0000000000..98150979b7 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/rfc5490_servermetadataexists @@ -0,0 +1,59 @@ +#!perl +use Cassandane::Tiny; + +sub test_rfc5490_servermetadataexists + :min_version_3_0 :AnnotationAllowUndefined + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \"servermetadataexists\" test"; + + my $talk = $self->{store}->get_client(); + my $admintalk = $self->{adminstore}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(<setmetadata("", "/shared/magic", "foo"); + $talk->create($hitfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the annotation"; + $admintalk->setmetadata("", "/shared/moo", "hello"); + + xlog $self, "Deliver a message now that the folder exists"; + { + my $msg = $self->{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/sieve_setflag b/cassandane/tiny-tests/Sieve/sieve_setflag new file mode 100644 index 0000000000..913ca1375e --- /dev/null +++ b/cassandane/tiny-tests/Sieve/sieve_setflag @@ -0,0 +1,43 @@ +#!perl +use Cassandane::Tiny; + +sub test_sieve_setflag + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Actually create the target folder"; + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "Install a sieve script filing all mail into a nonexistant folder"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + # should go in Folder2 + my $msg2 = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg2); + + # should fail to deliver and wind up in INBOX + my $msg3 = $self->{gen}->generate(subject => "Message 3"); + $self->{instance}->deliver($msg3); + + $imaptalk->unselect(); + $imaptalk->select("INBOX"); + $self->assert_num_equals(3, $imaptalk->get_response_code('exists')); + + my @uids = $imaptalk->search('1:*', 'NOT', 'FLAGGED'); + + $self->assert_num_equals(2, scalar(@uids)); +} diff --git a/cassandane/tiny-tests/Sieve/snooze b/cassandane/tiny-tests/Sieve/snooze new file mode 100644 index 0000000000..a59a9f565f --- /dev/null +++ b/cassandane/tiny-tests/Sieve/snooze @@ -0,0 +1,61 @@ +#!perl +use Cassandane::Tiny; + +sub test_snooze + :needs_component_sieve :needs_component_calalarmd + :needs_component_jmap + :min_version_3_1 + :NoAltNameSpace +{ + my ($self) = @_; + + my $snoozed = "INBOX.snoozed"; + my $awakened = "INBOX.awakened"; + + my $localtz = DateTime::TimeZone->new( name => 'local' ); + my $maildate = DateTime->now(time_zone => $localtz); + $maildate->add(DateTime::Duration->new(minutes => 1)); + my $timestr = $maildate->strftime('%T'); + + xlog $self, "Install script"; + $self->{instance}->install_sieve_script(<{store}->get_client(); + + $imaptalk->create($awakened) + or die "Cannot create $awakened: $@"; + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Deliver a message without having a snoozed folder"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message was delivered to INBOX"; + $self->{store}->set_folder("INBOX"); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Create the snoozed folder"; + $imaptalk->create($snoozed, "(USE (\\Snoozed))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Deliver a message"; + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message made it to the snoozed folder"; + $self->{store}->set_folder($snoozed); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Trigger re-delivery of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 90 ); + + xlog $self, "Check that the message made it to the awakened folder"; + $self->{store}->set_folder($awakened); + $msg1->set_attribute(flags => [ '\\Recent', '\\Flagged', '$awakened' ]); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/snooze_mailboxid b/cassandane/tiny-tests/Sieve/snooze_mailboxid new file mode 100644 index 0000000000..8e25dc8fbe --- /dev/null +++ b/cassandane/tiny-tests/Sieve/snooze_mailboxid @@ -0,0 +1,64 @@ +#!perl +use Cassandane::Tiny; + +sub test_snooze_mailboxid + :needs_component_sieve :needs_component_calalarmd + :needs_component_jmap + :min_version_3_1 + :NoAltNameSpace +{ + my ($self) = @_; + + my $snoozed = "INBOX.snoozed"; + my $awakened = "INBOX.awakened"; + + xlog $self, "Create the awakened folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create($awakened) + or die "Cannot create $awakened: $@"; + my $res = $imaptalk->status($awakened, ['mailboxid']); + my $awakenedid = $res->{mailboxid}[0]; + + my $localtz = DateTime::TimeZone->new( name => 'local' ); + my $maildate = DateTime->now(time_zone => $localtz); + $maildate->add(DateTime::Duration->new(minutes => 1)); + my $timestr = $maildate->strftime('%T'); + + xlog $self, "Install script"; + $self->{instance}->install_sieve_script(<{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Deliver a message without having a snoozed folder"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message was delivered to INBOX"; + $self->{store}->set_folder("INBOX"); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Create the snoozed folder"; + $imaptalk->create($snoozed, "(USE (\\Snoozed))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Deliver a message"; + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message made it to the snoozed folder"; + $self->{store}->set_folder($snoozed); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Trigger re-delivery of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 90 ); + + xlog $self, "Check that the message made it to the awakened folder"; + $self->{store}->set_folder($awakened); + $msg1->set_attribute(flags => [ '\\Recent', '\\Flagged', '$awakened' ]); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/snooze_specialuse b/cassandane/tiny-tests/Sieve/snooze_specialuse new file mode 100644 index 0000000000..96012946a8 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/snooze_specialuse @@ -0,0 +1,62 @@ +#!perl +use Cassandane::Tiny; + +sub test_snooze_specialuse + :needs_component_sieve :needs_component_calalarmd + :needs_component_jmap + :min_version_3_3 + :NoAltNameSpace +{ + my ($self) = @_; + + my $snoozed = "INBOX.snoozed"; + my $awakened = "INBOX.awakened"; + + xlog $self, "Create the awakened folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create($awakened, "(USE (\\Important))") + or die "Cannot create $awakened: $@"; + + my $localtz = DateTime::TimeZone->new( name => 'local' ); + my $maildate = DateTime->now(time_zone => $localtz); + $maildate->add(DateTime::Duration->new(minutes => 1)); + my $timestr = $maildate->strftime('%T'); + + xlog $self, "Install script"; + $self->{instance}->install_sieve_script(<{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Deliver a message without having a snoozed folder"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message was delivered to INBOX"; + $self->{store}->set_folder("INBOX"); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Create the snoozed folder"; + $imaptalk->create($snoozed, "(USE (\\Snoozed))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Deliver a message"; + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message made it to the snoozed folder"; + $self->{store}->set_folder($snoozed); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Trigger re-delivery of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 90 ); + + xlog $self, "Check that the message made it to the awakened folder"; + $self->{store}->set_folder($awakened); + $msg1->set_attribute(flags => [ '\\Recent', '\\Flagged', '$awakened' ]); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/snooze_specialuse_create b/cassandane/tiny-tests/Sieve/snooze_specialuse_create new file mode 100644 index 0000000000..0bcd75ec9a --- /dev/null +++ b/cassandane/tiny-tests/Sieve/snooze_specialuse_create @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_snooze_specialuse_create + :needs_component_sieve :needs_component_calalarmd + :needs_component_jmap + :min_version_3_3 + :NoAltNameSpace +{ + my ($self) = @_; + + my $snoozed = "INBOX.snoozed"; + my $awakened = "INBOX.awakened"; + + my $imaptalk = $self->{store}->get_client(); + + my $localtz = DateTime::TimeZone->new( name => 'local' ); + my $maildate = DateTime->now(time_zone => $localtz); + $maildate->add(DateTime::Duration->new(minutes => 1)); + my $timestr = $maildate->strftime('%T'); + + xlog $self, "Install script"; + $self->{instance}->install_sieve_script(<{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Deliver a message without having a snoozed folder"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message was delivered to INBOX"; + $self->{store}->set_folder("INBOX"); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Create the snoozed folder"; + $imaptalk->create($snoozed, "(USE (\\Snoozed))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Deliver a message"; + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message made it to the snoozed folder"; + $self->{store}->set_folder($snoozed); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Trigger re-delivery of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 90 ); + + xlog $self, "Check that the message made it to the awakened folder"; + $self->{store}->set_folder($awakened); + $msg1->set_attribute(flags => [ '\\Recent', '\\Flagged', '$awakened' ]); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/snooze_tzid b/cassandane/tiny-tests/Sieve/snooze_tzid new file mode 100644 index 0000000000..805dcb33fb --- /dev/null +++ b/cassandane/tiny-tests/Sieve/snooze_tzid @@ -0,0 +1,55 @@ +#!perl +use Cassandane::Tiny; + +sub test_snooze_tzid + :needs_component_sieve :needs_component_calalarmd + :needs_component_jmap + :min_version_3_3 + :NoAltNamespace +{ + my ($self) = @_; + + my $snoozed = "INBOX.snoozed"; + my $awakened = "INBOX.awakened"; + + my $localtz = DateTime::TimeZone->new( name => 'Australia/Melbourne' ); + xlog $self, "using local timezone: " . $localtz->name(); + my $maildate = DateTime->now(time_zone => $localtz); + $maildate->add(DateTime::Duration->new(minutes => 1)); + my $timestr = $maildate->strftime('%T'); + + xlog $self, "Install script with tzid"; + $self->{instance}->install_sieve_script(<{store}->get_client(); + + $imaptalk->create($awakened) + or die "Cannot create $awakened: $@"; + $self->{store}->set_fetch_attributes(qw(uid flags)); + + xlog $self, "Create the snoozed folder"; + $imaptalk->create($snoozed, "(USE (\\Snoozed))"); + $self->assert_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message made it to the snoozed folder"; + $self->{store}->set_folder($snoozed); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); + + xlog $self, "Trigger re-delivery of snoozed email"; + $self->{instance}->run_command({ cyrus => 1 }, + 'calalarmd', '-t' => $maildate->epoch() + 39600 + 90 ); # 11h + 90s to account for NY/Mel time diff + + xlog $self, "Check that the message made it to the awakened folder"; + $self->{store}->set_folder($awakened); + $msg1->set_attribute(flags => [ '\\Recent', '$awakened' ]); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/specialuse b/cassandane/tiny-tests/Sieve/specialuse new file mode 100644 index 0000000000..bce256cad3 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/specialuse @@ -0,0 +1,35 @@ +#!perl +use Cassandane::Tiny; + +sub test_specialuse + :min_version_3_1 + :needs_component_sieve + :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing the \":specialuse\" argument"; + + my $hitfolder = "INBOX.newfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<{store}->get_client(); + $talk->create($hitfolder, "(USE (\\Junk))"); + + xlog $self, "Deliver a message now that the folder exists"; + my $msg = $self->{gen}->generate(subject => "msg1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it"; + $talk->select($hitfolder); + $self->assert_num_equals(1, $talk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/specialuse_create b/cassandane/tiny-tests/Sieve/specialuse_create new file mode 100644 index 0000000000..b6ca73d2c1 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/specialuse_create @@ -0,0 +1,30 @@ +#!perl +use Cassandane::Tiny; + +sub test_specialuse_create + :min_version_3_1 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Testing the \":specialuse\" + \":create\" arguments"; + + my $hitfolder = "INBOX.newfolder"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "msg1"); + $self->{instance}->deliver($msg); + + xlog $self, "Check that the message made it"; + my $talk = $self->{store}->get_client(); + $talk->select($hitfolder); + $self->assert_num_equals(1, $talk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/specialuse_exists b/cassandane/tiny-tests/Sieve/specialuse_exists new file mode 100644 index 0000000000..2afad6c4ad --- /dev/null +++ b/cassandane/tiny-tests/Sieve/specialuse_exists @@ -0,0 +1,60 @@ +#!perl +use Cassandane::Tiny; + +sub test_specialuse_exists + :min_version_3_1 + :needs_component_sieve + :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Testing the \"specialuse_exists\" test"; + + my $talk = $self->{store}->get_client(); + + my $hitfolder = "INBOX.newfolder"; + my $testfolder = "INBOX.testfolder"; + my $missfolder = "INBOX"; + + xlog $self, "Install the sieve script"; + my $scriptname = 'flatPack'; + $self->{instance}->install_sieve_script(<create($hitfolder); + + my %uid = ($hitfolder => 1, $missfolder => 1); + my %exp; + xlog $self, "Deliver a message"; + { + my $msg = $self->{gen}->generate(subject => "msg1"); + $msg->set_attribute(uid => $uid{$missfolder}); + $uid{$missfolder}++; + $self->{instance}->deliver($msg); + $exp{$missfolder}->{"msg1"} = $msg; + } + + xlog $self, "Create the test folder"; + $talk->create($testfolder, "(USE (\\Junk))"); + + xlog $self, "Deliver a message now that the folder exists"; + { + my $msg = $self->{gen}->generate(subject => "msg2"); + $msg->set_attribute(uid => $uid{$hitfolder}); + $uid{$hitfolder}++; + $self->{instance}->deliver($msg); + $exp{$hitfolder}->{"msg2"} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/unicode_casemap b/cassandane/tiny-tests/Sieve/unicode_casemap new file mode 100644 index 0000000000..54d82f9ce3 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/unicode_casemap @@ -0,0 +1,79 @@ +#!perl +use Cassandane::Tiny; + +sub test_unicode_casemap + :min_version_3_9 + :needs_component_sieve :needs_component_sieve :NoAltNameSpace :NoMunge8bit +{ + my ($self) = @_; + + xlog $self, "Testing the \"i;unicode-casemap\" collation"; + + my $miss = "INBOX"; + my $is = "INBOX.is"; + my $contains = "INBOX.contains"; + my $matches = "INBOX.matches"; + my $regex = "INBOX.regex"; + + xlog $self, "Actually create the target folders"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create($is) + or die "Cannot create $is: $@"; + $imaptalk->create($contains) + or die "Cannot create $contains: $@"; + $imaptalk->create($matches) + or die "Cannot create $match: $@"; + $imaptalk->create($regex) + or die "Cannot create $regex: $@"; + + xlog $self, "Install the sieve script"; + $self->{instance}->install_sieve_script(< 'hello world!', filedto => $is }, + { subject => 'pÂTé', filedto => $is }, + { subject => 'pate', filedto => $miss }, + { subject => 'I like pâTé', filedto => $contains }, + { subject => 'I like pâTé a lot', filedto => $matches }, + { subject => "I don't like pâTé very much", filedto => $regex }, + { subject => "I won't eat pâTé", filedto => $regex }, + ); + + my %uid = ($is => 1, $contains => 1, $matches => 1, $regex => 1, $miss => 1); + my %exp; + foreach my $case (@cases) + { + xlog $self, "Deliver a message with subject \"$case->{subject}\""; + my $msg = $self->{gen}->generate(subject => $case->{subject}); + $msg->set_attribute(uid => $uid{$case->{filedto}}); + $uid{$case->{filedto}}++; + $self->{instance}->deliver($msg); + $exp{$case->{filedto}}->{$case->{subject}} = $msg; + } + + xlog $self, "Check that the messages made it"; + foreach my $folder (keys %exp) + { + $self->{store}->set_folder($folder); + $self->check_messages($exp{$folder}, check_guid => 0); + } +} diff --git a/cassandane/tiny-tests/Sieve/utf8_mboxname b/cassandane/tiny-tests/Sieve/utf8_mboxname new file mode 100644 index 0000000000..507c1c302b --- /dev/null +++ b/cassandane/tiny-tests/Sieve/utf8_mboxname @@ -0,0 +1,34 @@ +#!perl +use Cassandane::Tiny; + +sub test_utf8_mboxname + :needs_component_sieve :min_version_3_1 :SieveUTF8Fileinto +{ + my ($self) = @_; + + my $target = "INBOX.A & B"; + + xlog $self, "Testing '&' in a mailbox name"; + + xlog $self, "Actually create the target folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create($target) + or die "Cannot create $target: $@"; + $self->{store}->set_fetch_attributes('uid'); + + xlog $self, "Install script"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that the message made it to the target"; + $self->{store}->set_folder($target); + $self->check_messages({ 1 => $msg1 }, check_guid => 0); +} diff --git a/cassandane/tiny-tests/Sieve/utf8_subject_encoded b/cassandane/tiny-tests/Sieve/utf8_subject_encoded new file mode 100644 index 0000000000..e50847f32e --- /dev/null +++ b/cassandane/tiny-tests/Sieve/utf8_subject_encoded @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_utf8_subject_encoded + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "Install a sieve script flagging messages that match utf8 snowman"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + # SHOULD get flagged + my $msg2 = $self->{gen}->generate(subject => "=?UTF-8?B?4piD?="); + $self->{instance}->deliver($msg2); + + # should NOT get flagged + my $msg3 = $self->{gen}->generate(subject => "Message 3"); + $self->{instance}->deliver($msg3); + + $imaptalk->unselect(); + $imaptalk->select("INBOX"); + $self->assert_num_equals(3, $imaptalk->get_response_code('exists')); + + my @uids = $imaptalk->search('1:*', 'NOT', 'FLAGGED'); + + $self->assert_num_equals(2, scalar(@uids)); +} diff --git a/cassandane/tiny-tests/Sieve/utf8_subject_raw b/cassandane/tiny-tests/Sieve/utf8_subject_raw new file mode 100644 index 0000000000..e3f01740e2 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/utf8_subject_raw @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_utf8_subject_raw + :min_version_3_0 + :needs_component_sieve :NoMunge8bit +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "Install a sieve script flagging messages that match utf8 snowman"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + # SHOULD get flagged + my $msg2 = $self->{gen}->generate(subject => "☃"); + $self->{instance}->deliver($msg2); + + # should NOT get flagged + my $msg3 = $self->{gen}->generate(subject => "Message 3"); + $self->{instance}->deliver($msg3); + + $imaptalk->unselect(); + $imaptalk->select("INBOX"); + $self->assert_num_equals(3, $imaptalk->get_response_code('exists')); + + my @uids = $imaptalk->search('1:*', 'NOT', 'FLAGGED'); + + $self->assert_num_equals(2, scalar(@uids)); +} diff --git a/cassandane/tiny-tests/Sieve/vacation_multiple b/cassandane/tiny-tests/Sieve/vacation_multiple new file mode 100644 index 0000000000..7385a4acab --- /dev/null +++ b/cassandane/tiny-tests/Sieve/vacation_multiple @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_vacation_multiple + :min_version_3_1 + :needs_component_sieve +{ + my ($self) = @_; + + # can't do anything without captured syslog + if (!$self->{instance}->{have_syslog_replacement}) { + xlog $self, "can't examine syslog, test is useless"; + return; + } + + xlog $self, "Install a sieve script with vacation action"; + $self->{instance}->install_sieve_script(<<'EOF' +require ["vacation"]; + +vacation :days 3 :addresses ["cassandane@example.com"] text: +I am out of the office today. I will answer your email as soon as I can. +. +; +EOF + ); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1", + to => Cassandane::Address->new(localpart => 'cassandane', domain => 'example.com')); + $self->{instance}->deliver($msg1); + + sleep 1; + + xlog $self, "Deliver another message"; + my $msg2 = $self->{gen}->generate(subject => "Message 2", + to => Cassandane::Address->new(localpart => 'cassandane', domain => 'example.com')); + $self->{instance}->deliver($msg2); + + # Make sure that we only sent one response + my @resp = $self->{instance}->getsyslog(qr/smtpclient_open:/); + $self->assert_num_equals(1, scalar @resp); +} diff --git a/cassandane/tiny-tests/Sieve/vacation_with_explicit_subject b/cassandane/tiny-tests/Sieve/vacation_with_explicit_subject new file mode 100644 index 0000000000..131d6d83f5 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/vacation_with_explicit_subject @@ -0,0 +1,48 @@ +#!perl +use Cassandane::Tiny; + +sub test_vacation_with_explicit_subject + :min_version_3_1 + :needs_component_sieve + :NoAltNameSpace + :want_smtpdaemon +{ + my ($self) = @_; + + my $target = "INBOX.Sent"; + + xlog $self, "Install a sieve script with explicit vacation subject"; + $self->{instance}->install_sieve_script(<<'EOF' +require ["vacation", "fcc"]; + +vacation :fcc "INBOX.Sent" :days 1 :addresses ["cassandane@example.com"] :subject "Boo" text: +I am out of the office today. I will answer your email as soon as I can. +. +; +EOF + ); + + xlog $self, "Create the target folder"; + my $talk = $self->{store}->get_client(); + $talk->create($target, "(USE (\\Sent))"); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1", + to => Cassandane::Address->new(localpart => 'cassandane', domain => 'example.com')); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that a copy of the auto-reply message made it"; + $talk->select($target); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Check that the message is an auto-reply"; + my $res = $talk->fetch(1, 'rfc822'); + my $msg2 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/Subject: Boo\r\n/ms, $msg2); + $self->assert_matches(qr/Auto-Submitted: auto-replied \(vacation\)\r\n/, $msg2); + $self->assert_matches(qr/\r\n\r\nI am out of the office today./, $msg2); + +# use Data::Dumper; +# warn Dumper($msg2); +} diff --git a/cassandane/tiny-tests/Sieve/vacation_with_fcc b/cassandane/tiny-tests/Sieve/vacation_with_fcc new file mode 100644 index 0000000000..b9868e48fb --- /dev/null +++ b/cassandane/tiny-tests/Sieve/vacation_with_fcc @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_vacation_with_fcc + :min_version_3_1 + :needs_component_sieve + :NoAltNameSpace + :want_smtpdaemon +{ + my ($self) = @_; + + my $target = "INBOX.Sent"; + + xlog $self, "Install a sieve script with vacation action that uses :fcc"; + $self->{instance}->install_sieve_script(<<'EOF' +require ["vacation", "fcc"]; + +vacation :fcc "INBOX.Sent" :days 1 :addresses ["cassandane@example.com"] text: +I am out of the office today. I will answer your email as soon as I can. +. +; +EOF + ); + + xlog $self, "Create the target folder"; + my $talk = $self->{store}->get_client(); + $talk->create($target, "(USE (\\Sent))"); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1", + to => Cassandane::Address->new(localpart => 'cassandane', domain => 'example.com')); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that a copy of the auto-reply message made it"; + $talk->select($target); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Check that the message is an auto-reply"; + my $res = $talk->fetch(1, 'rfc822'); + my $msg2 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/Subject: Auto:(?:\r\n)? Message 1\r\n/ms, $msg2); + $self->assert_matches(qr/Auto-Submitted: auto-replied \(vacation\)\r\n/, $msg2); + $self->assert_matches(qr/\r\n\r\nI am out of the office today./, $msg2); +} diff --git a/cassandane/tiny-tests/Sieve/vacation_with_fcc_specialuse b/cassandane/tiny-tests/Sieve/vacation_with_fcc_specialuse new file mode 100644 index 0000000000..355628d6a1 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/vacation_with_fcc_specialuse @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_vacation_with_fcc_specialuse + :min_version_3_1 + :needs_component_sieve + :NoAltNameSpace + :want_smtpdaemon +{ + my ($self) = @_; + + my $target = "INBOX.Sent"; + + xlog $self, "Install a sieve script with vacation action that uses :fcc"; + $self->{instance}->install_sieve_script(<<'EOF' +require ["vacation", "fcc", "special-use"]; + +vacation :fcc "INBOX" :specialuse "\\Sent" :days 1 :addresses ["cassandane@example.com"] text: +I am out of the office today. I will answer your email as soon as I can. +. +; +EOF + ); + + xlog $self, "Create the target folder"; + my $talk = $self->{store}->get_client(); + $talk->create($target, "(USE (\\Sent))"); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1", + to => Cassandane::Address->new(localpart => 'cassandane', domain => 'example.com')); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that a copy of the auto-reply message made it"; + $talk->select($target); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Check that the message is an auto-reply"; + my $res = $talk->fetch(1, 'rfc822'); + my $msg2 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/Subject: Auto:(?:\r\n)? Message 1\r\n/ms, $msg2); + $self->assert_matches(qr/Auto-Submitted: auto-replied \(vacation\)\r\n/, $msg2); + $self->assert_matches(qr/\r\n\r\nI am out of the office today./, $msg2); +} diff --git a/cassandane/tiny-tests/Sieve/vacation_with_following_rules b/cassandane/tiny-tests/Sieve/vacation_with_following_rules new file mode 100644 index 0000000000..573e5ec292 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/vacation_with_following_rules @@ -0,0 +1,44 @@ +#!perl +use Cassandane::Tiny; + +sub test_vacation_with_following_rules + :needs_component_sieve :min_version_3_0 +{ + my ($self) = @_; + + my $target = "INBOX.target"; + + xlog $self, "Install a sieve script filing all mail into a nonexistant folder"; + $self->{instance}->install_sieve_script(<<'EOF' + +require ["fileinto", "reject", "vacation", "imap4flags", "envelope", "relational", "regex", "subaddress", "copy", "mailbox", "mboxmetadata", "servermetadata", "date", "index", "comparator-i;ascii-numeric", "variables"]; + +### 5. Sieve generated for vacation responses +if + allof( + currentdate :zone "+0000" :value "ge" "iso8601" "2017-06-08T05:00:00Z", + currentdate :zone "+0000" :value "le" "iso8601" "2017-06-13T19:00:00Z" + ) +{ + vacation :days 3 :addresses ["one@example.com", "two@example.com"] text: +I am out of the office today. I will answer your email as soon as I can. +. +; +} + +### 7. Sieve generated for organise rules +if header :contains ["To","Cc","From","Subject","Date","Content-Type","Delivered-To","In-Reply-To","List-Post","List-Id","Mailing-List","Message-Id","Received","References","Reply-To","Return-Path","Sender","X-AntiAbuse","X-Apparently-From","X-Attached","X-Delivered-To","X-LinkName","X-Mail-From","X-Resolved-To","X-Sender","X-Sender-IP","X-Spam-Charsets","X-Spam-Hits","X-Spam-Known-Sender","X-Spam-Source","X-Version"] "urgent@example.com" { + addflag "\\Flagged"; + fileinto "INBOX.Work"; + removeflag "\\Flagged"; +} + +EOF + ); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + # This will crash if we have broken parsing of vacation +} diff --git a/cassandane/tiny-tests/Sieve/vacation_with_long_encoded_origsubject b/cassandane/tiny-tests/Sieve/vacation_with_long_encoded_origsubject new file mode 100644 index 0000000000..1308a8916a --- /dev/null +++ b/cassandane/tiny-tests/Sieve/vacation_with_long_encoded_origsubject @@ -0,0 +1,81 @@ +#!perl +use Cassandane::Tiny; + +sub test_vacation_with_long_encoded_origsubject + :min_version_3_1 + :needs_component_sieve + :NoAltNameSpace + :want_smtpdaemon +{ + my ($self) = @_; + + my $target = 'INBOX.Sent'; + + xlog $self, "Install a sieve script with vacation action that uses :fcc"; + $self->{instance}->install_sieve_script(<<"EOF" +require ["vacation", "fcc"]; + +vacation :fcc "$target" :days 1 :addresses ["cassandane\@example.com"] text: +I am out of the office today. I will answer your email as soon as I can. +. +; +EOF + ); + + xlog $self, "Create the target folder"; + my $talk = $self->{store}->get_client(); + $talk->create($target, "(USE (\\Sent))"); + + xlog $self, "Deliver a message"; + # should end up refolding a couple of times + my $subject = "=?UTF-8?Q?=E3=83=86=E3=82=B9=E3=83=88=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC?=\r\n" + . " =?UTF-8?Q?=E3=82=B8=E3=80=81=E7=84=A1=E8=A6=96=E3=81=97=E3=81=A6=E3=81=8F?=\r\n" + . " =?UTF-8?Q?=E3=81=A0=E3=81=95=E3=81=84?="; + + my $msg1 = $self->{gen}->generate( + subject => $subject, + to => Cassandane::Address->new(localpart => 'cassandane', + domain => 'example.com')); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that a copy of the auto-reply message made it"; + $talk->select($target); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Check that the message is an auto-reply"; + my $res = $talk->fetch(1, 'rfc822'); + my $msg2 = $res->{1}->{rfc822}; + + # check we folded a reasonable number of times + my $actual_subject; + if ($msg2 =~ m/^(Subject:.*?\r\n)(?!\s)/ms) { + $actual_subject = $1; + } + $self->assert_matches(qr/^Subject:/, $actual_subject); + my $fold_count = () = $actual_subject =~ m/\r\n /g; + xlog "fold count: $fold_count"; + $self->assert_num_gte(2, $fold_count); + $self->assert_num_lte(4, $fold_count); + + # subject should be the original subject plus "Auto: " and CRLF + if (version->parse($Encode::MIME::Header::VERSION) + < version->parse("2.28")) { + # XXX Work around a bug in older Encode::MIME::Header + # XXX (https://rt.cpan.org/Public/Bug/Display.html?id=42902) + # XXX that loses the space between 'Subject:' and 'Auto:', + # XXX by allowing it to be optional + my $subjpat = "Auto: " . decode("MIME-Header", $subject) . "\r\n"; + my $subjre = qr/Subject:\s?$subjpat/; + $self->assert_matches($subjre, decode("MIME-Header", $actual_subject)); + } + else { + my $subjpat = "Subject: Auto: " + . decode("MIME-Header", $subject) . "\r\n"; + $self->assert_str_equals($subjpat, + decode("MIME-Header", $actual_subject)); + } + + # check for auto-submitted header + $self->assert_matches(qr/Auto-Submitted: auto-replied \(vacation\)\r\n/, $msg2); + $self->assert_matches(qr/\r\n\r\nI am out of the office today./, $msg2); +} diff --git a/cassandane/tiny-tests/Sieve/vacation_with_long_origsubject b/cassandane/tiny-tests/Sieve/vacation_with_long_origsubject new file mode 100644 index 0000000000..cd9574f175 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/vacation_with_long_origsubject @@ -0,0 +1,70 @@ +#!perl +use Cassandane::Tiny; + +sub test_vacation_with_long_origsubject + :min_version_3_1 + :needs_component_sieve + :NoAltNameSpace + :want_smtpdaemon +{ + my ($self) = @_; + + my $target = 'INBOX.Sent'; + + xlog $self, "Install a sieve script with vacation action that uses :fcc"; + $self->{instance}->install_sieve_script(<<"EOF" +require ["vacation", "fcc"]; + +vacation :fcc "$target" :days 1 :addresses ["cassandane\@example.com"] text: +I am out of the office today. I will answer your email as soon as I can. +. +; +EOF + ); + + xlog $self, "Create the target folder"; + my $talk = $self->{store}->get_client(); + $talk->create($target, "(USE (\\Sent))"); + + xlog $self, "Deliver a message"; + # should end up folding a couple of times + my $subject = "volutpat diam ut venenatis tellus in metus " + . "vulputate eu scelerisque felis imperdiet proin " + . "fermentum leo vel orci portad non pulvinar neque " + . "laoreet suspendisse interdum consectetur"; + + my $msg1 = $self->{gen}->generate( + subject => $subject, + to => Cassandane::Address->new(localpart => 'cassandane', + domain => 'example.com')); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that a copy of the auto-reply message made it"; + $talk->select($target); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Check that the message is an auto-reply"; + my $res = $talk->fetch(1, 'rfc822'); + my $msg2 = $res->{1}->{rfc822}; + + my $subjpat = $subject =~ s/ /(?:\r\n)? /gr; + my $subjre = qr{Subject:\r\n Auto: $subjpat}; + + # subject should be the original subject plus "\r\n Auto: " and folding + $self->assert_matches($subjre, $msg2); + + # check we folded a reasonable number of times + my $actual_subject; + if ($msg2 =~ m/^(Subject:.*?\r\n)(?!\s)/ms) { + $actual_subject = $1; + } + $self->assert_matches(qr/^Subject:/, $actual_subject); + my $fold_count = () = $actual_subject =~ m/\r\n /g; + xlog "fold count: $fold_count"; + $self->assert_num_gte(2, $fold_count); + $self->assert_num_lte(4, $fold_count); + + # check for auto-submitted header + $self->assert_matches(qr/Auto-Submitted: auto-replied \(vacation\)\r\n/, $msg2); + $self->assert_matches(qr/\r\n\r\nI am out of the office today./, $msg2); +} diff --git a/cassandane/tiny-tests/Sieve/vacation_with_nonfoldable_origsubject b/cassandane/tiny-tests/Sieve/vacation_with_nonfoldable_origsubject new file mode 100644 index 0000000000..bf3da2008f --- /dev/null +++ b/cassandane/tiny-tests/Sieve/vacation_with_nonfoldable_origsubject @@ -0,0 +1,82 @@ +#!perl +use Cassandane::Tiny; + +sub test_vacation_with_nonfoldable_origsubject + :min_version_3_5 + :needs_component_sieve + :NoAltNameSpace + :want_smtpdaemon +{ + my ($self) = @_; + + my $target = 'INBOX.Sent'; + + xlog $self, "Install a sieve script with vacation action that uses :fcc"; + $self->{instance}->install_sieve_script(<<"EOF" +require ["vacation", "fcc"]; + +vacation :fcc "$target" :days 1 :addresses ["cassandane\@example.com"] text: +I am out of the office today. I will answer your email as soon as I can. +. +; +EOF + ); + + xlog $self, "Create the target folder"; + my $talk = $self->{store}->get_client(); + $talk->create($target, "(USE (\\Sent))"); + + xlog $self, "Deliver a message"; + # runs more than 75c without whitespace! + my $subject = "volutpatdiamutvenenatistellusinmetus" + . "vulputateeuscelerisquefelisimperdietproin" + . "fermentumleovelorciportanonpulvinarneque" + . "laoreetsuspendisseinterdumconsectetur"; + + my $msg1 = $self->{gen}->generate( + subject => $subject, + to => Cassandane::Address->new(localpart => 'cassandane', + domain => 'example.com')); + $self->{instance}->deliver($msg1); + + xlog $self, "Check that a copy of the auto-reply message made it"; + $talk->select($target); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Check that the message is an auto-reply"; + my $res = $talk->fetch(1, 'rfc822'); + my $msg2 = $res->{1}->{rfc822}; + + # check we folded a reasonable number of times + my $actual_subject; + if ($msg2 =~ m/^(Subject:.*?\r\n)(?!\s)/ms) { + $actual_subject = $1; + } + $self->assert_matches(qr/^Subject:/, $actual_subject); + my $fold_count = () = $actual_subject =~ m/\r\n /g; + xlog "fold count: $fold_count"; + $self->assert_num_gte(2, $fold_count); + $self->assert_num_lte(4, $fold_count); + + # subject should be the original subject plus "Auto: " and CRLF + if (version->parse($Encode::MIME::Header::VERSION) + < version->parse("2.28")) { + # XXX Work around a bug in older Encode::MIME::Header + # XXX (https://rt.cpan.org/Public/Bug/Display.html?id=42902) + # XXX that loses the space between 'Subject:' and 'Auto:', + # XXX by allowing it to be optional + my $subjpat = "Auto: " . decode("MIME-Header", $subject) . "\r\n"; + my $subjre = qr/Subject:\s?$subjpat/; + $self->assert_matches($subjre, decode("MIME-Header", $actual_subject)); + } + else { + my $subjpat = "Subject: Auto: " + . decode("MIME-Header", $subject) . "\r\n"; + $self->assert_str_equals($subjpat, + decode("MIME-Header", $actual_subject)); + } + + # check for auto-submitted header + $self->assert_matches(qr/Auto-Submitted: auto-replied \(vacation\)\r\n/, $msg2); + $self->assert_matches(qr/\r\n\r\nI am out of the office today./, $msg2); +} diff --git a/cassandane/tiny-tests/Sieve/variable_modifiers b/cassandane/tiny-tests/Sieve/variable_modifiers new file mode 100644 index 0000000000..e284417e2f --- /dev/null +++ b/cassandane/tiny-tests/Sieve/variable_modifiers @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; + +sub test_variable_modifiers + :needs_component_sieve +{ + my ($self) = @_; + + $self->{instance}->install_sieve_script(<<'EOF' +require ["variables", "editheader", "regex", "enotify"]; + +set "a" "juMBlEd?lETteRS=.*"; +set :length "b" "${a}"; # => "18" +set :lower "c" "${a}"; # => "jumbled?letters=.*" +set :upper "d" "${a}"; # => "JUMBLED?LETTERS=.*" +set :lowerfirst "e" "${a}"; # => "juMBlEd?lETteRS=.*" +set :lowerfirst :upper "f" "${a}"; # => "jUMBLED?LETTERS.*" +set :upperfirst "g" "${a}"; # => "JuMBlEd?lETteRS=.*" +set :upperfirst :lower "h" "${a}"; # => "Jumbled?letters=.*" +set :quotewildcard "i" "${a}"; # => "juMBlEd\?lETteRS=.\*" +set :quoteregex "j" "${a}"; # => "juMBlEd\?lETteRS=\.\*" +set :encodeurl "k" "${a}"; # => "juMBlEd%3FlETteRS%3D.%2A" +set :encodeurl :upper "l" "${a}"; # => "JUMBLED%3FLETTERS%3D.%2A" +set :quotewildcard :upper "m" "${a}"; # => "JUMBLED\?LETTERS=.\*" +set :quoteregex :upper "n" "${a}"; # => "JUMBLED\?LETTERS=\.\*" +set :quoteregex :encodeurl + :upperfirst :lower "o" "${a}"; # => "Jumbled%5C%3fletters%3D%5C.%5C%2A" +set :quoteregex :encodeurl + :upper :length "p" "${a}"; # => "33" + +addheader "X-Cassandane-Test" "len = \"${b}\""; +addheader "X-Cassandane-Test" "lower = \"${c}\""; +addheader "X-Cassandane-Test" "upper = \"${d}\""; +addheader "X-Cassandane-Test" "lowerfirst = \"${e}\""; +addheader "X-Cassandane-Test" "lowerfirst+upper = \"${f}\""; +addheader "X-Cassandane-Test" "upperfirst = \"${g}\""; +addheader "X-Cassandane-Test" "upperfirst+lower = \"${h}\""; +addheader "X-Cassandane-Test" "wild = \"${i}\""; +addheader "X-Cassandane-Test" "regex = \"${j}\""; +addheader "X-Cassandane-Test" "url = \"${k}\""; +addheader "X-Cassandane-Test" "url+upper = \"${l}\""; +addheader "X-Cassandane-Test" "wild+upper = \"${m}\""; +addheader "X-Cassandane-Test" "regex+upper = \"${n}\""; +addheader "X-Cassandane-Test" "regex+url+upperfirst+lower = \"${o}\""; +addheader "X-Cassandane-Test" "regex+url+upper+len = \"${p}\""; +EOF + ); + + xlog $self, "Deliver a message"; + my $msg1 = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + my $imaptalk = $self->{store}->get_client(); + $imaptalk->select("INBOX"); + my $res = $imaptalk->fetch(1, 'rfc822'); + + $msg1 = $res->{1}->{rfc822}; + + $self->assert_matches(qr/X-Cassandane-Test: len = "18"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: lower = "jumbled\?letters=\.\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: upper = "JUMBLED\?LETTERS=\.\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: lowerfirst = "juMBlEd\?lETteRS=\.\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: lowerfirst\+upper = "jUMBLED\?LETTERS=\.\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: upperfirst = "JuMBlEd\?lETteRS=\.\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: upperfirst\+lower = "Jumbled\?letters=\.\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: wild = "juMBlEd\\\?lETteRS=\.\\\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: regex = "juMBlEd\\\?lETteRS=\\\.\\\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: url = "juMBlEd%3FlETteRS%3D\.%2A"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: wild\+upper = "JUMBLED\\\?LETTERS=\.\\\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: regex\+upper = "JUMBLED\\\?LETTERS=\\\.\\\*"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: url\+upper = "JUMBLED%3FLETTERS%3D\.%2A"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: regex\+url\+upperfirst\+lower = "Jumbled%5C%3Fletters%3D%5C.%5C%2A"\r\n/, $msg1); + $self->assert_matches(qr/X-Cassandane-Test: regex\+url\+upper\+len = "33"\r\n/, $msg1); +} diff --git a/cassandane/tiny-tests/Sieve/variables_basic b/cassandane/tiny-tests/Sieve/variables_basic new file mode 100644 index 0000000000..a15aba5caa --- /dev/null +++ b/cassandane/tiny-tests/Sieve/variables_basic @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_variables_basic + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Actually create the target folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.target"); + $imaptalk->create("INBOX.target.Folder1"); + $imaptalk->create("INBOX.target.Folder2"); + + xlog $self, "Install a sieve script filing all mail into a nonexistant folder"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => " \r\n Message\r\n 1 "); + $self->{instance}->deliver($msg1); + + # should go in Folder2 + my $msg2 = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg2); + + # should fail to deliver and wind up in INBOX + my $msg3 = $self->{gen}->generate(subject => "Message 3"); + $self->{instance}->deliver($msg3); + + # should not match the if, and file into target + my $msg4 = $self->{gen}->generate(subject => "Totally different"); + $self->{instance}->deliver($msg4); + + $imaptalk->select("INBOX.target.Folder1"); + $self->assert_num_equals(1, $imaptalk->get_response_code('exists')); + + $imaptalk->select("INBOX.target.Folder2"); + $self->assert_num_equals(1, $imaptalk->get_response_code('exists')); + + $imaptalk->select("INBOX"); + $self->assert_num_equals(1, $imaptalk->get_response_code('exists')); + + $imaptalk->select("INBOX.target"); + $self->assert_num_equals(1, $imaptalk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/Sieve/variables_regex b/cassandane/tiny-tests/Sieve/variables_regex new file mode 100644 index 0000000000..3f6fbdb805 --- /dev/null +++ b/cassandane/tiny-tests/Sieve/variables_regex @@ -0,0 +1,58 @@ +#!perl +use Cassandane::Tiny; + +sub test_variables_regex + :min_version_3_0 + :needs_component_sieve +{ + my ($self) = @_; + + xlog $self, "Actually create the target folder"; + my $imaptalk = $self->{store}->get_client(); + + $imaptalk->create("INBOX.target"); + $imaptalk->create("INBOX.target.Folder1"); + $imaptalk->create("INBOX.target.Folder2"); + + xlog $self, "Install a sieve script filing all mail into a nonexistant folder"; + $self->{instance}->install_sieve_script(<{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg1); + + # should go in Folder2 + my $msg2 = $self->{gen}->generate(subject => "Message x2"); + $self->{instance}->deliver($msg2); + + # should fail to deliver and wind up in INBOX + my $msg3 = $self->{gen}->generate(subject => "Message 3"); + $self->{instance}->deliver($msg3); + + # should not match the if, and file into target + my $msg4 = $self->{gen}->generate(subject => "Totally different"); + $self->{instance}->deliver($msg4); + + $imaptalk->select("INBOX.target.Folder1"); + $self->assert_num_equals(1, $imaptalk->get_response_code('exists')); + + $imaptalk->select("INBOX.target.Folder2"); + $self->assert_num_equals(1, $imaptalk->get_response_code('exists')); + + $imaptalk->select("INBOX"); + $self->assert_num_equals(1, $imaptalk->get_response_code('exists')); + + $imaptalk->select("INBOX.target"); + $self->assert_num_equals(1, $imaptalk->get_response_code('exists')); +} diff --git a/cassandane/tiny-tests/UIDonly/copy_move b/cassandane/tiny-tests/UIDonly/copy_move new file mode 100644 index 0000000000..9e8626f0c4 --- /dev/null +++ b/cassandane/tiny-tests/UIDonly/copy_move @@ -0,0 +1,49 @@ +#!perl +use Cassandane::Tiny; + +sub test_copy_move + :min_version_3_9 :NoAltNameSpace +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + my $folder = 'INBOX.foo'; + + xlog $self, "append some messages"; + my %exp; + my $N = 10; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + xlog $self, "create a second mailbox"; + my $res =$imaptalk->create($folder); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "ENABLE UIDONLY"; + $res = $imaptalk->_imap_cmd('ENABLE', 0, 'enabled', 'UIDONLY'); + $self->assert_num_equals(1, $res->{uidonly}); + + xlog $self, "attempt COPY"; + $res = $imaptalk->copy(1, $folder); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + # get_response_code() doesn't (yet) handle [UIDREQUIRED] + $self->assert_matches(qr/\[UIDREQUIRED\]/, $imaptalk->get_last_error()); + + xlog $self, "attempt MOVE"; + $res = $imaptalk->move(1, $folder); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + # get_response_code() doesn't (yet) handle [UIDREQUIRED] + $self->assert_matches(qr/\[UIDREQUIRED\]/, $imaptalk->get_last_error()); + + xlog $self, "UID MOVE"; + $res = $imaptalk->_imap_cmd('UID MOVE', 1, 'vanished', '1', $folder); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('1', $res->[0]); +} + +1; diff --git a/cassandane/tiny-tests/UIDonly/expunge b/cassandane/tiny-tests/UIDonly/expunge new file mode 100644 index 0000000000..c0699e8986 --- /dev/null +++ b/cassandane/tiny-tests/UIDonly/expunge @@ -0,0 +1,36 @@ +#!perl +use Cassandane::Tiny; + +sub test_expunge + :min_version_3_9 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "append some messages"; + my %exp; + my $N = 10; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + xlog $self, "delete the 1st and 6th"; + $imaptalk->store('1,6', '+FLAGS', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "ENABLE UIDONLY"; + $res = $imaptalk->_imap_cmd('ENABLE', 0, 'enabled', 'UIDONLY'); + $self->assert_num_equals(1, $res->{uidonly}); + + xlog $self, "EXPUNGE"; + $res = $imaptalk->_imap_cmd('EXPUNGE', 0, 'vanished'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals('1,6', $res->[0]); +} + +1; diff --git a/cassandane/tiny-tests/UIDonly/fetch b/cassandane/tiny-tests/UIDonly/fetch new file mode 100644 index 0000000000..453b5d4399 --- /dev/null +++ b/cassandane/tiny-tests/UIDonly/fetch @@ -0,0 +1,71 @@ +#!perl +use Cassandane::Tiny; + +sub test_fetch + :min_version_3_9 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "append some messages"; + my %exp; + my $N = 10; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + xlog $self, "EXPUNGE the 1st and 6th"; + $imaptalk->store('1,6', '+FLAGS', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $imaptalk->expunge(); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "FETCH all UID + FLAGS"; + my $res = $imaptalk->fetch('1:*', '(UID FLAGS)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_str_equals($res->{'1'}->{uid}, "2"); + $self->assert_str_equals($res->{'2'}->{uid}, "3"); + $self->assert_str_equals($res->{'3'}->{uid}, "4"); + $self->assert_str_equals($res->{'4'}->{uid}, "5"); + $self->assert_str_equals($res->{'5'}->{uid}, "7"); + $self->assert_str_equals($res->{'6'}->{uid}, "8"); + $self->assert_str_equals($res->{'7'}->{uid}, "9"); + $self->assert_str_equals($res->{'8'}->{uid}, "10"); + + xlog $self, "ENABLE UIDONLY"; + $res = $imaptalk->_imap_cmd('ENABLE', 0, 'enabled', 'UIDONLY'); + $self->assert_num_equals(1, $res->{uidonly}); + + xlog $self, "attempt FETCH again"; + $res = $imaptalk->fetch('1:*', '(UID FLAGS)'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + # get_response_code() doesn't (yet) handle [UIDREQUIRED] + $self->assert_matches(qr/\[UIDREQUIRED\]/, $imaptalk->get_last_error()); + + xlog $self, "UID FETCH all FLAGS"; + my %fetched = $self->uidonly_cmd($imaptalk, 'UID FETCH', '1:10', '(FLAGS)'); + $self->assert(exists $fetched{'2'}); + # make sure UID isn't in the response + $self->assert(not exists $fetched{'2'}->{uid}); + $self->assert(exists $fetched{'2'}->{flags}); + $self->assert(exists $fetched{'3'}); + $self->assert(exists $fetched{'4'}); + $self->assert(exists $fetched{'5'}); + $self->assert(exists $fetched{'7'}); + $self->assert(exists $fetched{'8'}); + $self->assert(exists $fetched{'9'}); + $self->assert(exists $fetched{'10'}); + + xlog $self, "UID FETCH 2 UID"; + %fetched = $self->uidonly_cmd($imaptalk, 'UID FETCH', '2', '(UID)'); + $self->assert(exists $fetched{'2'}); + # make sure UID is in the response + $self->assert_num_equals(2, $fetched{'2'}->{uid}); +} + +1; diff --git a/cassandane/tiny-tests/UIDonly/qresync b/cassandane/tiny-tests/UIDonly/qresync new file mode 100644 index 0000000000..08393a483f --- /dev/null +++ b/cassandane/tiny-tests/UIDonly/qresync @@ -0,0 +1,51 @@ +#!perl +use Cassandane::Tiny; + +sub test_qresync + :min_version_3_9 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "Deliver a message"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + + $imaptalk->select("INBOX"); + my $uidvalidity = $imaptalk->get_response_code('uidvalidity'); + + xlog $self, "ENABLE QRESYNC"; + my $res = $imaptalk->_imap_cmd('ENABLE', 0, 'enabled', 'QRESYNC'); + $self->assert_num_equals(1, $res->{qresync}); + + xlog "QResync mailbox with message sequence map"; + $imaptalk->unselect(); + $imaptalk->select("INBOX", "(QRESYNC ($uidvalidity 0 1 (1 1)))" => 1); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $res = $imaptalk->get_response_code('fetch'); + $self->assert_not_null($res); + $self->assert(exists $res->{'1'}{'flags'}); + $self->assert(exists $res->{'1'}{'modseq'}); + $self->assert(exists $res->{'1'}{'uid'}); + + xlog $self, "ENABLE UIDONLY"; + $res = $imaptalk->_imap_cmd('ENABLE', 0, 'enabled', 'UIDONLY'); + $self->assert_num_equals(1, $res->{uidonly}); + + xlog "attempt to QResync mailbox with message sequence map"; + $imaptalk->unselect(); + $imaptalk->select("INBOX", "(QRESYNC ($uidvalidity 0 1 (1 1)))" => 1); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + xlog "QResync mailbox"; + my %fetched = $self->uidonly_cmd($imaptalk, 'SELECT', + "INBOX", "(QRESYNC ($uidvalidity 0 1))"); + $self->assert(exists $fetched{'1'}); + # make sure UID isn't in the response + $self->assert(not exists $fetched{'1'}->{uid}); + $self->assert(exists $fetched{'1'}->{flags}); + $self->assert(exists $fetched{'1'}->{modseq}); +} + +1; diff --git a/cassandane/tiny-tests/UIDonly/search b/cassandane/tiny-tests/UIDonly/search new file mode 100644 index 0000000000..623fde8415 --- /dev/null +++ b/cassandane/tiny-tests/UIDonly/search @@ -0,0 +1,72 @@ +#!perl +use Cassandane::Tiny; + +sub test_search + :min_version_3_9 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "append some messages"; + my %exp; + my $N = 10; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + xlog $self, "delete the 1st and 6th"; + $imaptalk->store('1,6', '+FLAGS', '(\\Deleted)'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + + xlog $self, "SEARCH"; + $res = $imaptalk->search('not', 'deleted'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(8, scalar @{$res}); + $self->assert_num_equals(2, $res->[0]); + $self->assert_num_equals(3, $res->[1]); + $self->assert_num_equals(4, $res->[2]); + $self->assert_num_equals(5, $res->[3]); + $self->assert_num_equals(7, $res->[4]); + $self->assert_num_equals(8, $res->[5]); + $self->assert_num_equals(9, $res->[6]); + $self->assert_num_equals(10, $res->[7]); + + xlog $self, "ENABLE UIDONLY"; + $res = $imaptalk->_imap_cmd('ENABLE', 0, 'enabled', 'UIDONLY'); + $self->assert_num_equals(1, $res->{uidonly}); + + xlog $self, "attempt SEARCH"; + $res = $imaptalk->search('not', 'deleted'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + # get_response_code() doesn't (yet) handle [UIDREQUIRED] + $self->assert_matches(qr/\[UIDREQUIRED\]/, $imaptalk->get_last_error()); + + xlog $self, "attempt UID SEARCH with msgnos"; + $res = $imaptalk->_imap_cmd('UID SEARCH', 1, 'search', '1:10'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + + xlog $self, "UID SEARCH"; + $res = $imaptalk->_imap_cmd('UID SEARCH', 1, 'search', 'not', 'deleted'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(8, scalar @{$res}); + $self->assert_num_equals(2, $res->[0]); + $self->assert_num_equals(3, $res->[1]); + $self->assert_num_equals(4, $res->[2]); + $self->assert_num_equals(5, $res->[3]); + $self->assert_num_equals(7, $res->[4]); + $self->assert_num_equals(8, $res->[5]); + $self->assert_num_equals(9, $res->[6]); + $self->assert_num_equals(10, $res->[7]); + + xlog $self, "UID SEARCH with UIDs"; + $res = $imaptalk->_imap_cmd('UID SEARCH', 1, 'search', 'uid', '1:10'); + $self->assert_str_equals('ok', $imaptalk->get_last_completion_response()); + $self->assert_num_equals(10, scalar @{$res}); +} + +1; diff --git a/cassandane/tiny-tests/UIDonly/store b/cassandane/tiny-tests/UIDonly/store new file mode 100644 index 0000000000..1bf25a96d6 --- /dev/null +++ b/cassandane/tiny-tests/UIDonly/store @@ -0,0 +1,45 @@ +#!perl +use Cassandane::Tiny; + +sub test_store + :min_version_3_9 +{ + my ($self) = @_; + + my $imaptalk = $self->{store}->get_client(); + + xlog $self, "append some messages"; + my %exp; + my $N = 10; + for (1..$N) + { + my $msg = $self->make_message("Message $_"); + $exp{$_} = $msg; + } + xlog $self, "check the messages got there"; + $self->check_messages(\%exp); + + xlog $self, "ENABLE UIDONLY & CONDSTORE"; + $res = $imaptalk->_imap_cmd('ENABLE', 0, 'enabled', 'UIDONLY', 'CONDSTORE'); + $self->assert_num_equals(1, $res->{uidonly}); + $self->assert_num_equals(1, $res->{condstore}); + + xlog $self, "attempt STORE"; + $imaptalk->store('1,6', '+FLAGS', '(\\Deleted)'); + $self->assert_str_equals('bad', $imaptalk->get_last_completion_response()); + # get_response_code() doesn't (yet) handle [UIDREQUIRED] + $self->assert_matches(qr/\[UIDREQUIRED\]/, $imaptalk->get_last_error()); + + xlog $self, "UID STORE"; + my %fetched = $self->uidonly_cmd($imaptalk, 'UID STORE', + '1,6', '+FLAGS', '(\\Deleted)'); + $self->assert(exists $fetched{'1'}); + $self->assert_str_equals('\\Deleted', $fetched{'1'}->{flags}[0]); + # make sure that MODSEQ is also in the response + $self->assert(exists $fetched{'1'}->{modseq}); + $self->assert(exists $fetched{'6'}); + $self->assert_str_equals('\\Deleted', $fetched{'6'}->{flags}[0]); + $self->assert(exists $fetched{'6'}->{modseq}); +} + +1; diff --git a/cassandane/tiny-tests/UIDonly/unsolicited b/cassandane/tiny-tests/UIDonly/unsolicited new file mode 100644 index 0000000000..d3f0ff8688 --- /dev/null +++ b/cassandane/tiny-tests/UIDonly/unsolicited @@ -0,0 +1,42 @@ +#!perl +use Cassandane::Tiny; + +sub test_unsolicited + :min_version_3_9 :NoAltNameSpace +{ + my ($self) = @_; + + xlog $self, "Deliver some messages"; + my $msg = $self->{gen}->generate(subject => "Message 1"); + $self->{instance}->deliver($msg); + $msg = $self->{gen}->generate(subject => "Message 2"); + $self->{instance}->deliver($msg); + + my $imaptalk = $self->{store}->get_client(); + my $res = $imaptalk->select('INBOX'); + + xlog $self, "Expunge first message"; + $imaptalk->store('1', '+flags', '\\deleted'); + $imaptalk->expunge(); + + xlog $self, "ENABLE UIDONLY & CONDSTORE"; + $res = $imaptalk->_imap_cmd('ENABLE', 0, 'enabled', 'UIDONLY', 'CONDSTORE'); + $self->assert_num_equals(1, $res->{uidonly}); + $self->assert_num_equals(1, $res->{uidonly}); + + my $admintalk = $self->{adminstore}->get_client(); + $res = $admintalk->select('user.cassandane'); + + xlog $self, "set flag in another session"; + $admintalk->store('1', '+flags', '\\flagged'); + + xlog $self, "poll for changes"; + my %fetched = $self->uidonly_cmd($imaptalk, 'NOOP'); + $self->assert(exists $fetched{'2'}); + # make sure UID isn't in the response + $self->assert(not exists $fetched{'2'}->{uid}); + $self->assert(exists $fetched{'2'}->{flags}); + $self->assert(exists $fetched{'2'}->{modseq}); +} + +1; diff --git a/cassandane/tominstall.sh b/cassandane/tominstall.sh new file mode 100755 index 0000000000..f242e7015c --- /dev/null +++ b/cassandane/tominstall.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +DESTINATION="root@vmtom.com:cass/" +INSTALLABLES="\ + genmail3.pl \ + listmail.pl \ + pop3showafter.pl \ + split-by-thread.pl \ + imap-append.pl \ + sprinkle.pl \ + testrunner.pl \ + Cassandane \ +" + + +rsync -av --delete -e ssh $INSTALLABLES $DESTINATION diff --git a/cassandane/utils/.gitignore b/cassandane/utils/.gitignore new file mode 100644 index 0000000000..5e698073d6 --- /dev/null +++ b/cassandane/utils/.gitignore @@ -0,0 +1,6 @@ +*.o +lemming +gdbtramp +crash +syslog.so +syslog_probe diff --git a/cassandane/utils/Makefile b/cassandane/utils/Makefile new file mode 100644 index 0000000000..214594de0a --- /dev/null +++ b/cassandane/utils/Makefile @@ -0,0 +1,77 @@ +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +PROGRAMS=lemming gdbtramp crash syslog_probe +LIBS=syslog.so + +CC=gcc +COPTFLAGS=-g -O0 +CWARNFLAGS=-Wall -Wextra +CFLAGS=$(COPTFLAGS) $(CWARNFLAGS) -fPIC + +all: $(PROGRAMS) $(LIBS) + +lemming_SOURCE=lemming.c +lemming_OBJS=$(lemming_SOURCE:.c=.o) +lemming: $(lemming_OBJS) + $(LINK.c) -o $@ $(lemming_OBJS) + +gdbtramp_SOURCE=gdbtramp.c +gdbtramp_OBJS=$(gdbtramp_SOURCE:.c=.o) +gdbtramp: $(gdbtramp_OBJS) + $(LINK.c) -o $@ $(gdbtramp_OBJS) + +crash_SOURCE=crash.c +crash_OBJS=$(crash_SOURCE:.c=.o) +crash: $(crash_OBJS) + $(LINK.c) -o $@ $(crash_OBJS) + +syslog_probe_SOURCE=syslog_probe.c +syslog_probe_OBJS=$(syslog_probe_SOURCE:.c=.o) +syslog_probe: $(syslog_probe_OBJS) + $(LINK.c) -o $@ $(syslog_probe_OBJS) + +syslog_SOURCE=syslog.c +syslog_OBJS=$(syslog_SOURCE:.c=.o) +syslog.so: $(syslog_OBJS) + $(LINK.c) -shared -o $@ $(syslog_OBJS) -ldl + +clean: + $(RM) $(PROGRAMS) $(LIBS) *.o diff --git a/cassandane/utils/annotator.pl b/cassandane/utils/annotator.pl new file mode 100755 index 0000000000..265c73f2bc --- /dev/null +++ b/cassandane/utils/annotator.pl @@ -0,0 +1,145 @@ +#!/usr/bin/perl +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +package Cassandane::AnnotatorDaemon; +use strict; +use warnings; +use base qw(Cyrus::Annotator::Daemon); +use Getopt::Long qw(:config no_ignore_case bundling); +use POSIX; + +use lib '.'; +use Cassandane::Util::Log; + +set_verbose(1) if $ENV{CASSANDANE_VERBOSE}; + +# Hack to work around Net::Server being too dumb to unblock the signals +# it handles, notably SIGQUIT. This was breaking the Jenkins build, +# because Jenkins starts child processes with SIGQUIT blocked and +# Cassandane::Instance expects to be able to use SIGQUIT to gracefully +# shut down processes. +sigprocmask(SIG_UNBLOCK, POSIX::SigSet->new( &POSIX::SIGQUIT )) + or die "Cannot unblock SIGQUIT: $!"; + +my %commands = +( + set_shared_annotation => sub + { + my ($message, $entry, $value) = @_; + die "Wrong number of args for set_shared_annotation" unless (@_ == 3); + xlog "set_shared_annotation(\"$entry\", \"$value\")"; + $message->set_shared_annotation($entry, $value); + }, + set_private_annotation => sub + { + my ($message, $entry, $value) = @_; + die "Wrong number of args for set_private_annotation" unless (@_ == 3); + xlog "set_private_annotation(\"$entry\", \"$value\")"; + $message->set_private_annotation($entry, $value); + }, + clear_shared_annotation => sub + { + my ($message, $entry) = @_; + die "Wrong number of args for clear_shared_annotation" unless (@_ == 2); + xlog "clear_shared_annotation(\"$entry\")"; + $message->clear_shared_annotation($entry); + }, + clear_private_annotation => sub + { + my ($message, $entry) = @_; + die "Wrong number of args for clear_private_annotation" unless (@_ == 2); + xlog "clear_private_annotation(\"$entry\")"; + $message->clear_private_annotation($entry); + }, + set_flag => sub + { + my ($message, $flag) = @_; + die "Wrong number of args for set_flag" unless (@_ == 2); + xlog "set_flag($flag)"; + $message->set_flag($flag); + }, + clear_flag => sub + { + my ($message, $flag) = @_; + die "Wrong number of args for clear_flag" unless (@_ == 2); + xlog "clear_flag($flag)"; + $message->clear_flag($flag); + }, +); + +sub annotate_message +{ + my ($self, $message) = @_; + + xlog "annotate_message called"; + + # Parse the body of the message as a series of test commands + my $fh = $message->fh(); + seek $fh, $message->bodystructure()->{Offset}, 0 + or die "Cannot seek in message: $!"; + + while (my $line = readline $fh) + { + chomp $line; + my @a = split /\s+/, $line; + my $cmd = $commands{$a[0]} + or die "Unknown command $a[0]"; + shift(@a); + $cmd->($message, @a); + } +} + +my $pidfile = "$ENV{CASSANDANE_BASEDIR}/conf/socket/annotator.pid"; +my $port = "$ENV{CASSANDANE_BASEDIR}/conf/socket/annotator.sock|unix"; +GetOptions( + 'pidfile|P=s' => \$pidfile, + 'port|p=s' => \$port, +) || die "Bad arguments"; + +# suck ARGV dry to prevent Net::Daemon getting its hands on it +@ARGV = (); + +xlog "annotator $$ starting"; +Cassandane::AnnotatorDaemon->run( + pid_file => $pidfile, + port => $port + ); +xlog "annotator $$ exiting"; diff --git a/cassandane/utils/crash.c b/cassandane/utils/crash.c new file mode 100644 index 0000000000..980c285232 --- /dev/null +++ b/cassandane/utils/crash.c @@ -0,0 +1,66 @@ +/* crash.c: deliberately crash to get a core file + * + * Copyright (c) 2017 FastMail Pty. Ltd. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "FastMail" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * FastMail Pty. Ltd. + * Level 1, 91 William St + * Melbourne 3000 + * Victoria + * Australia + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by FastMail Pty. Ltd." + * + * FASTMAIL PTY LTD DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ + +#include +#include +#include + +static const size_t default_alloc = 10 * 1024 * 1024; /* 10MB */ + +int main(int argc, char **argv) +{ + size_t alloc = default_alloc; + char *ptr = NULL; + + if (argc > 1) { + alloc = strtoull(argv[1], NULL, 10); + } + + printf("allocating %zu bytes\n", alloc); + + /* big allocation to help detect core truncation */ + ptr = malloc(alloc); + (void) ptr; + + sleep(1); + abort(); + + /* never get here */ + return 0; +} diff --git a/cassandane/utils/cyrus-perl-paths.pl b/cassandane/utils/cyrus-perl-paths.pl new file mode 100755 index 0000000000..32dc9403ad --- /dev/null +++ b/cassandane/utils/cyrus-perl-paths.pl @@ -0,0 +1,42 @@ +#!/usr/bin/perl + +use warnings; +use strict; + +use Config; + +use lib '.'; +use Cassandane::Cassini; + +# XXX borrowed and tweaked from Cassandane::Instance +sub _cyrus_perl_search_path +{ + my ($prefix, $destdir) = @_; + my @inc = ( + substr($Config{installvendorlib}, length($Config{vendorprefix})), + substr($Config{installvendorarch}, length($Config{vendorprefix})), + substr($Config{installsitelib}, length($Config{siteprefix})), + substr($Config{installsitearch}, length($Config{siteprefix})) + ); + + my @paths; + my $found = 0; + foreach (@inc) { + my $p = $destdir . $prefix . $_; + $found++ if -d $p; + push @paths, "-I $p"; + } + + warn "warning: Cyrus perl paths not found on disk. Is Cyrus installed?\n" + if not $found; + return @paths; +} + +my $cassini = Cassandane::Cassini->instance(); + +my $cyrus_prefix = $cassini->val('cyrus default', 'prefix', '/usr/cyrus'); +my $cyrus_destdir = $cassini->val('cyrus default', 'destdir', ''); + +my @path = _cyrus_perl_search_path($cyrus_prefix, $cyrus_destdir); + +print join(' ', @path), "\n"; diff --git a/cassandane/utils/fakeldapd b/cassandane/utils/fakeldapd new file mode 100755 index 0000000000..7e38670470 --- /dev/null +++ b/cassandane/utils/fakeldapd @@ -0,0 +1,209 @@ +#!/usr/bin/perl +# A little ldif-driven ldap server +# +# Based somewhat on Net::LDAP::Server::Test, which didn't do the +# one thing I specifically needed, but nor was subclassable. :/ + +use warnings; +use strict; + +# embedded package, so that we needn't care about LIB paths +{ + package Cassandane::FakeLDAP; + + use Data::Dumper; + use Net::LDAP::Constant qw( + LDAP_SUCCESS + LDAP_NO_SUCH_OBJECT + ); + use Net::LDAP::Filter; + use Net::LDAP::FilterMatch; + use Net::LDAP::Server; + use Net::LDAP::Util qw( + canonical_dn + ldap_explode_dn + ); + + use base qw(Net::LDAP::Server); + use fields qw(data debug); + + sub new + { + my ($class, $sock, $data) = @_; + my $self = $class->SUPER::new($sock); + $self->{data} = $data; + $self->{debug} = 0; + return $self; + } + + sub set_debug + { + my ($self, $value) = @_; + $self->{debug} = $value; + } + + sub debug + { + my $self = shift; + return if not $self->{debug}; + print STDERR @_; + } + + sub ldap_result + { + my ($dn, $error, $result, @entries) = @_; + if (scalar @entries) { + return { matchedDN => $dn, + errorMessage => $error, + resultCode => $result }, + @entries; + } + else { + return { matchedDN => $dn, + errorMessage => $error, + resultCode => $result }; + } + } + + sub bind + { + my ($self, $reqdata, $reqmsg) = @_; + # don't care, just accept it + return ldap_result('', '', LDAP_SUCCESS); + } + + sub search + { + my ($self, $reqdata, $reqmsg) = @_; + + my $scope = $reqdata->{scope}; + my $base = canonical_dn($reqdata->{baseObject}); + my $filter = bless($reqdata->{filter}, 'Net::LDAP::Filter'); + my %attrs = map { $_ => 1 } @{ $reqdata->{attributes} || [] }; + my @found; + + foreach my $dn (keys %{$self->{data}}) { + # assume scope=sub(2), narrow further in a moment + next if $base and not $dn =~ m/$base$/; + + if ($scope == 0) { + # base + next if $dn ne $base; + } + elsif ($scope == 1) { + # one + my $dn_depth = scalar @{ ldap_explode_dn($dn) }; + my $base_depth = scalar @{ ldap_explode_dn($base) }; + + next if $dn_depth != $base_depth + 1; + } + elsif ($scope == 3) { + # subordinate + next if $dn eq $base; + } + + my $entry = $self->{data}->{$dn}->clone(); + next if not $filter->match($entry); + + if (scalar keys %attrs) { + foreach my $a ($entry->attributes()) { + if (not exists $attrs{$a}) { + $entry->delete($a => []); + } + } + } + + push @found, $entry; + } + + $self->debug(map { $_->ldif(change => 0) } @found); + + if ($scope == 0 && scalar @found == 0) { + return ldap_result('', '', LDAP_NO_SUCH_OBJECT); + } + + return ldap_result('', '', LDAP_SUCCESS, @found); + } +}; + +package main; + +use Data::Dumper; +use Getopt::Std; +use IO::Handle; +use IO::Select; +use IO::Socket; +use Net::LDAP::LDIF; +use Net::LDAP::Util qw(canonical_dn); + +# support running as a DAEMON with wait=y: +# * if fd 3 is already open, then we will need to write to it later to +# indicate we're ready. +# * we must grab this early, before the number gets used for something +# else, otherwise we won't be able to differentiate between the fd 3 +# we care about or some other thing +# * if fd 3 was not already open, $status_fd will be undef +my $status_fd = IO::Handle->new_from_fd(3, 'w'); + +my %opts; +my %data; + +getopts("C:dl:p:v", \%opts); + +die "need a port" if not int($opts{p} // 0); +die "need an ldif file" if not $opts{l} or not -f $opts{l}; + +my $ldif = Net::LDAP::LDIF->new($opts{l}); +while (not $ldif->eof()) { + my $entry = $ldif->read_entry(); + my $cdn = canonical_dn($entry->dn); + $data{$cdn} = $entry; +} +die "ldif file contained no entries" if not scalar keys %data; + +# ok, we're good. background ourselves if necessary +if (not $opts{d} and not $ENV{CYRUS_ISDAEMON}) { + my $pid = fork; + die "unable to fork: $!" if not defined $pid; + exit(0) if $pid != 0; # bye bye parent +} + +my $listen = IO::Socket::INET->new(Listen => 1, + LocalPort => $opts{p}, + ReuseAddr => 1); +die "could not bind port $opts{p}: $!\n" if not $listen; +my $select = IO::Select->new($listen); +my %handlers; +my $shutdown = 0; + +$SIG{HUP} = sub { $shutdown++; }; + +# okay, now we're ready to accept requests. inform our parent, +# if they were waiting to be informed +if ($ENV{CYRUS_ISDAEMON} && $status_fd) { + print $status_fd "ok\r\n"; + undef $status_fd; +} + +while (my @ready = $select->can_read()) { + foreach my $fh (@ready) { + if ($fh == $listen) { + my $sock = $listen->accept(); + $handlers{*$sock} = Cassandane::FakeLDAP->new($sock, \%data); + $handlers{*$sock}->set_debug(1) if $opts{d}; + $select->add($sock); + } + else { + die "no handler???" if not exists $handlers{*$fh}; + my $finished = $handlers{*$fh}->handle(); + + # if we've finished with the socket, close it + if ($finished) { + delete $handlers{*$fh}; + $select->remove($fh); + close $fh; + } + } + } + last if $shutdown; +} diff --git a/cassandane/utils/fakesaslauthd b/cassandane/utils/fakesaslauthd new file mode 100755 index 0000000000..af64fdc68b --- /dev/null +++ b/cassandane/utils/fakesaslauthd @@ -0,0 +1,88 @@ +#!/usr/bin/env perl +# A pretend saslauthd that just accepts any password except "bad" +# for any user. Use me for testing! + +use Getopt::Std; +use IO::Handle; +use IO::Socket::UNIX; +use Sys::Syslog qw(:standard :macros); + +sub get_counted_string +{ + my $sock = shift; + my $data; + $sock->read($data, 2); + my $size = unpack('n', $data); + $sock->read($data, $size); + return unpack("A$size", $data); +} + +# support running as a DAEMON with wait=y: +# * if fd 3 is already open, then we will need to write to it later to +# indicate we're ready. +# * we must grab this early, before the number gets used for something +# else, otherwise we won't be able to differentiate between the fd 3 +# we care about or some other thing +# * if fd 3 was not already open, $status_fd will be undef +my $status_fd = IO::Handle->new_from_fd(3, 'w'); + +my %opts; + +getopts("C:dp:v", \%opts); + +die "need a socket path" if not $opts{p}; + +openlog('fakesaslauthd', 'pid', LOG_LOCAL6) + or die "Cannot openlog"; + +# ok, we're good. background ourselves +if (not $opts{d} and not $ENV{CYRUS_ISDAEMON}) { + my $pid = fork; + die "unable to fork: $!" if not defined $pid; + exit(0) if $pid != 0; # bye bye parent +} + +# open socket +unlink($opts{p}); +my $sock = IO::Socket::UNIX->new( + Local => $opts{p}, + Type => SOCK_STREAM, + Listen => SOMAXCONN, +); +die "FAILED to create socket $opts{p}: $!" unless $sock; +system "chmod 777 $opts{p}"; +syslog LOG_INFO, "listening on $opts{p}"; + +my $shutdown = 0; +$SIG{HUP} = sub { $shutdown++; }; + +# okay, now we're ready to accept requests. inform our parent, +# if they were waiting to be informed +if ($ENV{CYRUS_ISDAEMON} && $status_fd) { + print $status_fd "ok\r\n"; + undef $status_fd; +} + +while (my $client = $sock->accept()) { + my $LoginName = get_counted_string($client); + my $Password = get_counted_string($client); + my $Service = lc get_counted_string($client); + my $Realm = get_counted_string($client); + syslog LOG_INFO, "connection: $LoginName $Password $Service $Realm"; + + # XXX - custom logic? + + # OK :) + if ($Password eq 'bad') { + $client->print(pack("nA3", 2, "NO\000")); + } + else { + $client->print(pack("nA3", 2, "OK\000")); + } + $client->close(); + + last if $shutdown; +} + +$sock->close(); +unlink $opts{p}; diff --git a/cassandane/utils/fakesmtpd b/cassandane/utils/fakesmtpd new file mode 100755 index 0000000000..0a48026d54 --- /dev/null +++ b/cassandane/utils/fakesmtpd @@ -0,0 +1,39 @@ +#!/usr/bin/perl + +use warnings; +use strict; +use Getopt::Std; + +# XXX Will probably need to embed Cassandane::Net::Server if we want +# XXX cyrus master to be able to manage this rather than Cassandane. +# XXX See utils/fakeldapd for an example of that. +use lib '.'; +use Cassandane::Net::SMTPServer; + +my %opts; +getopts("C:b:h:p:v", \%opts); + +my $host = $opts{h} || 'localhost'; +my $port = int($opts{p} // 0); +die "need a port" if not $port; +my $basedir = $opts{b} || $ENV{CASSANDANE_BASEDIR}; +die "need a basedir" if not $basedir; +my $verbose = $opts{v} || $ENV{CASSANDANE_VERBOSE}; + +my $smtpd = Cassandane::Net::SMTPServer->new({ + cass_verbose => $verbose, + xmtp_personality => 'smtp', + host => $host, + port => $port, + min_servers => 1, + min_spare_servers => 1, + ## max_servers => 50, + ## max_spare_servers => 10, + ## max_requests => 1000, + control_file => "$basedir/conf/smtpd.json", + xmtp_tmp_dir => "$basedir/tmp/", + store_msg => 1, + messages_dir => "$basedir/smtpd/", +}); +$smtpd->run() or die; +exit 0; # Never reached diff --git a/cassandane/utils/gdbtramp.c b/cassandane/utils/gdbtramp.c new file mode 100644 index 0000000000..3541339bc6 --- /dev/null +++ b/cassandane/utils/gdbtramp.c @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights + * reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Opera Software Australia" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Opera Software Australia Pty. Ltd. + * Level 50, 120 Collins St + * Melbourne 3000 + * Victoria + * Australia + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Opera Software + * Australia Pty. Ltd." + * + * OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __GNUC__ +#define __attribute__(x) +#endif + +static const char *basename(const char *path) +{ + const char *t = strrchr(path, '/'); + return (t ? ++t : path); +} + +static volatile int got_sigusr1 = 0; + +static void +handle_sigusr1(int sig __attribute__((unused))) +{ + got_sigusr1++; +} + +static void +usage(void) +{ + /* unusually, we complain to syslog about problems parsing + * the commandline options; this is because this program + * is designed to be run from Cyrus' master and to never have + * a useful controlling terminal */ + syslog(LOG_ERR, "Usage: gdbtramp /full/path/to/cyrus/binary pid\n"); + exit(1); +} + +int +main(int argc, char **argv) +{ + const char *binary; + pid_t pid; + const char *prog; + int timeout_sec = 30; + struct sigaction sa; + FILE *fp; + char gdbx_filename[1024]; + + openlog("gdbtramp", LOG_PERROR|LOG_PID, LOG_LOCAL6); + + if (argc != 3) + usage(); + binary = argv[1]; + pid = atoi(argv[2]); + if (pid <= 0) + usage(); + + + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = handle_sigusr1; + if (sigaction(SIGUSR1, &sa, NULL) < 0) + { + syslog(LOG_ERR, "signal(SIGUSR1): %m"); + exit(1); + } + + prog = strrchr(binary, '/'); + if (prog) + prog++; + else + prog = binary; + + snprintf(gdbx_filename, sizeof(gdbx_filename), + "/var/tmp/%s.x", basename(binary)); + + fp = fopen(gdbx_filename, "w"); + if (!fp) + { + syslog(LOG_ERR, "%s: %m", gdbx_filename); + exit(1); + } + fprintf(fp, "# gdb commands file, written by gdbtramp\n"); + fprintf(fp, "file %s\n", binary); + fprintf(fp, "attach %d\n", (int)pid); + fprintf(fp, "shell kill -USR1 %d\n", (int)getpid()); + fprintf(fp, "echo Please set some breakpoints and use the " + "\"continue\" command\\n\n"); + fclose(fp); + + syslog(LOG_ERR, "You have %d seconds to run gdb " + "thus: >>>> gdb -x %s", + timeout_sec, gdbx_filename); + /* another, different, message just to make sure the syslogd + * flushes the previous one to the logfile */ + syslog(LOG_ERR, "tick tick tick..."); + + /* wait 30 seconds, expecting to be interrupted by a signal */ + poll(NULL, 0, timeout_sec*1000); + if (!got_sigusr1) + { + syslog(LOG_ERR, "You're too slow!"); + exit(1); + } + + syslog(LOG_ERR, "gdbtramp exiting"); + return 0; +} diff --git a/cassandane/utils/guid2cid.pl b/cassandane/utils/guid2cid.pl new file mode 100755 index 0000000000..0b442024e1 --- /dev/null +++ b/cassandane/utils/guid2cid.pl @@ -0,0 +1,31 @@ +#!/usr/bin/perl -w + +use Math::Int64; + +my $sha1hex = shift || die usage(); +my $cid = Math::Int64::uint64(0); +for (0..7) { + $cid <<= 8; + $cid |= hex(substr($sha1hex, $_*2, 2)); +} +$cid ^= Math::Int64::string_to_uint64("0x91f3d9e10b690b12", 16); # chosen by fair dice roll +my $res = lc Math::Int64::uint64_to_string($cid, 16); +print sprintf("%016s", $res) . "\n"; + + +sub usage { + return < + +This can be used to convert from a GUID to a CID, or vice-versa, +though of course it will be just the GUID prefix converting +from a CID. + +e.g. + ./guid2cid.pl 35fdfb3ee0bd4f64320c92bbad4687352966dfb8 + => a40e22dfebd44476 + ./guid2cid.pl a40e22dfebd44476 + => 35fdfb3ee0bd4f64 + +EOF +} diff --git a/cassandane/utils/lemming.c b/cassandane/utils/lemming.c new file mode 100644 index 0000000000..6af8fa1aba --- /dev/null +++ b/cassandane/utils/lemming.c @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights + * reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Opera Software Australia" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Opera Software Australia Pty. Ltd. + * Level 50, 120 Collins St + * Melbourne 3000 + * Victoria + * Australia + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Opera Software + * Australia Pty. Ltd." + * + * OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define STATUS_FD 3 +#define LISTEN_FD 4 + +#define MASTER_SERVICE_AVAILABLE 1 +#define MASTER_SERVICE_UNAVAILABLE 2 + +static volatile int gotsighup = 0; + +static void +usage(void) +{ + fprintf(stderr, "Usage: lemming [-d DELAY] [-m FAILMODE] [-t TAG]\n"); + fflush(stderr); + exit(1); +} + +static void no_cores(void) +{ + struct rlimit lim; + int r; + + memset(&lim, 0, sizeof(lim)); + r = getrlimit(RLIMIT_CORE, &lim); + if (lim.rlim_cur) { + lim.rlim_cur = 0; + r = setrlimit(RLIMIT_CORE, &lim); + if (r) + syslog(LOG_ERR, "setrlimit failed: %m"); + } +} + +static void sighup_handler(int sig __attribute__((unused))) +{ + gotsighup = 1; +} + +static void set_sighup_handler(int restartable) +{ + struct sigaction action; + + sigemptyset(&action.sa_mask); + + action.sa_flags = 0; +#ifdef SA_RESTART + if (restartable) { + action.sa_flags |= SA_RESTART; + } +#endif + action.sa_handler = sighup_handler; + + if (sigaction(SIGHUP, &action, NULL) < 0) { + syslog(LOG_ERR, "unable to install signal handler for SIGHUP: %m"); + exit(1); + } +} + +static int +retry_write(int fd, const void *vbuf, int len) +{ + int n; + const char *buf = vbuf; + + do + { + n = write(fd, buf, len); + if (n < 0) + return -1; + if (n == 0) + return -1; /* WTF? */ + buf += n; + len -= n; + } + while (len > 0); + return 0; +} + +static void +tell_master(int message) +{ + struct { int message; pid_t pid; } msg; + + memset(&msg, 0, sizeof(msg)); + msg.message = message; + msg.pid = getpid(); + if (retry_write(STATUS_FD, &msg, sizeof(msg)) < 0) + { + syslog(LOG_ERR, "Couldn't write status message to master: %m"); + exit(1); + } +} + +static const char * +read_line_from_client(void) +{ + int fd; + int n; + int len = 0; + uint32_t pid = getpid(); + static char line[128]; + static const int maxlen = sizeof(line)-1; + + syslog(LOG_ERR, "lemming serving"); + /* While 'accept'ing, let SIGHUP wake us up */ + set_sighup_handler(0); + fd = accept(LISTEN_FD, NULL, NULL); + set_sighup_handler(1); + if (fd < 0) + { + if (gotsighup) { + syslog(LOG_ERR, "lemming exiting normally on SIGHUP"); + exit(0); + } + syslog(LOG_ERR, "cannot accept: %m"); + exit(1); + } + + tell_master(MASTER_SERVICE_UNAVAILABLE); + + /* write out our pid, the Perl test code wants it */ + n = write(fd, &pid, sizeof(pid)); + if (n < 0) + { + syslog(LOG_ERR, "cannot write pid: %m"); + exit(1); + } + if (n < (int)sizeof(pid)) + { + syslog(LOG_ERR, "short write of pid"); + exit(1); + } + + /* read the command line from the Perl test code */ + for (;;) + { + n = read(fd, line+len, maxlen-len); +// syslog(LOG_ERR, "read returned %d", n); + if (n < 0) + { + syslog(LOG_ERR, "cannot read command: %m"); + exit(1); + } + if (n == 0) + break; /* EOF */ + len += n; + if (line[len-1] == '\r' || + line[len-1] == '\n') + break; /* have a whole line */ + } + close(fd); +// syslog(LOG_ERR, "read total of %d bytes", len); + + /* nul-terminate and trim the line */ + line[len] = '\0'; + while (len > 0 && isspace(line[len-1])) + line[--len] = '\0'; + + return line; +} + +static void lemming_success(void) +{ + syslog(LOG_ERR, "lemming exiting normally"); + exit(0); +} + +static void lemming_exit(void) +{ + syslog(LOG_ERR, "lemming exiting, code 1"); + exit(1); +} + + +int +main(int argc, char **argv) +{ + const char *mode = "success"; + const char *tag = "X"; + int c; + int delay_ms = 20; + char filename[256]; + socklen_t salen; + struct sockaddr_storage localaddr; + struct sockaddr *localsock = (struct sockaddr *)&localaddr; + int family = AF_UNSPEC; + + /* don't interrupt me on SIGHUP */ + set_sighup_handler(1); + no_cores(); + + /* parse arguments */ + while ((c = getopt(argc, argv, "C:d:m:t:")) > 0) + { + switch (c) + { + case 'C': + /* Cyrus alt-config option, ignored */ + break; + case 'd': + delay_ms = atoi(optarg); + break; + case 'm': + mode = optarg; + break; + case 't': + tag = optarg; + break; + default: + usage(); + } + } + if (optind < argc) + usage(); + + openlog("lemming", LOG_PID, LOG_LOCAL6); + + snprintf(filename, sizeof(filename), "lemming.%s.%d", tag, (int)getpid()); + creat(filename, 0644); + + salen = sizeof(struct sockaddr_storage); + if (!getsockname(LISTEN_FD, localsock, &salen)) { + family = localsock->sa_family; + } + else { + syslog(LOG_ERR, "unable to determine socket family: %m"); + } + + if (!strcmp(mode, "exit-ipv4/serve")) + { + switch (family) { + case AF_INET: + lemming_exit(); + break; + + default: + mode = "serve"; + break; + } + } + else if (!strcmp(mode, "exit-ipv6/serve")) + { + switch (family) { + case AF_INET6: + lemming_exit(); + break; + + default: + mode = "serve"; + break; + } + } + + if (!strcmp(mode, "serve")) + mode = read_line_from_client(); + else if (delay_ms) + poll(NULL, 0, delay_ms); + + if (!strcmp(mode, "success")) + { + lemming_success(); + } + else if (!strcmp(mode, "exit")) + { + lemming_exit(); + } + else if (!strcmp(mode, "abort")) + { + syslog(LOG_ERR, "lemming abort()ing"); + abort(); + } + else if (!strcmp(mode, "segv")) + { + syslog(LOG_ERR, "lemming receiving SEGV"); + *(char *)0 = 0; + } + else + { + syslog(LOG_ERR, "unknown failure mode \"%s\"", mode); + fprintf(stderr, "lemming: unknown failure mode \"%s\"\n", mode); + return 1; + } + + return 0; +} diff --git a/cassandane/utils/sleeper b/cassandane/utils/sleeper new file mode 100755 index 0000000000..0035fdb804 --- /dev/null +++ b/cassandane/utils/sleeper @@ -0,0 +1,31 @@ +#!/usr/bin/perl +# like /bin/sleep except ignoring Cyrus-style -C, -M arguments + +use warnings; +use strict; + +use Sys::Syslog qw(:standard :macros); + +sub usage +{ + die "usage: $0 [-C imapd.conf] [-M cyrus.conf] seconds\n"; +} + +my $arg; + +while (scalar @ARGV > 1) { + $arg = shift @ARGV; + if ($arg eq '-C' || $arg eq '-M') { + # ignore argument intended for cyrus processes + shift @ARGV; + } +} +usage() if scalar @ARGV != 1; + +$arg = shift @ARGV; +usage() if $arg !~ m/^\d+$/; + +openlog('sleeper', 'pid', LOG_LOCAL6) or die "Cannot openlog"; +syslog(LOG_INFO, "sleeping for $arg seconds..."); +sleep $arg; +syslog(LOG_INFO, "finished"); diff --git a/cassandane/utils/syslog.c b/cassandane/utils/syslog.c new file mode 100644 index 0000000000..56354f77bb --- /dev/null +++ b/cassandane/utils/syslog.c @@ -0,0 +1,137 @@ +/* syslog.so + * + * LD_PRELOAD module for intercepting syslog calls and capturing them + * to another file. Captured lines are flushed as written, so there + * is no buffering delay here (unlike real syslog). + * + * Set CASSANDANE_SYSLOG_FNAME in the environment to specify the file + * to which logged lines should be appended. + */ + +/* need _GNU_SOURCE for RTLD_NEXT */ +#define _GNU_SOURCE + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define EXPORTED __attribute__((__visibility__("default"))) + +typedef void (*real_openlog_t)(const char *, int, int); +static void real_openlog(const char *ident, int option, int facility) +{ + real_openlog_t p = (real_openlog_t) dlsym(RTLD_NEXT, "openlog"); + p(ident, option, facility); +} + +typedef void (*real_vsyslog_t)(int, const char *, va_list); +static void real_vsyslog(int priority, const char *format, va_list ap) +{ + real_vsyslog_t p = (real_vsyslog_t) dlsym(RTLD_NEXT, "vsyslog"); + p(priority, format, ap); +} + +typedef void (*real_closelog_t)(void); +static void real_closelog(void) +{ + real_closelog_t p = (real_closelog_t) dlsym(RTLD_NEXT, "closelog"); + p(); +} + +static FILE *out = NULL; +static int is_opened = 0; +static char *myident = NULL; +static char hostname[HOST_NAME_MAX + 1] = {0}; +static pid_t pid = 0; + +EXPORTED void openlog(const char *ident, int option, int facility) +{ + const char *syslog_fname; + + if (is_opened) closelog(); + + syslog_fname = getenv("CASSANDANE_SYSLOG_FNAME"); + if (syslog_fname) { + out = fopen(syslog_fname, "ae"); + if (out) { + gethostname(hostname, sizeof(hostname)); + myident = ident ? strdup(ident) : NULL; + pid = getpid(); + is_opened = 1; + } + } + + real_openlog(ident, option, facility); +} + +EXPORTED void closelog(void) +{ + real_closelog(); + + if (out) fclose(out); + out = NULL; + free(myident); + myident = NULL; + memset(hostname, 0, sizeof(hostname)); + pid = 0; + is_opened = 0; +} + +static void fake_vsyslog(int priority, const char *format, va_list ap) +{ + struct timeval now = {0}; + char timestamp[16] = {0}; + int saved_errno = errno; + int logmask; + + if (!is_opened) return; /* no file to write to */ + + logmask = setlogmask(0); /* get the real syslog's current mask */ + if (!(logmask & LOG_MASK(LOG_PRI(priority)))) + return; /* do not want messages of this priority logged */ + + gettimeofday(&now, NULL); + + strftime(timestamp, sizeof(timestamp), "%b %d %T", localtime(&now.tv_sec)); + fprintf(out, "%s.%06" PRIdMAX " %s %s[%" PRIdMAX "]: ", + timestamp, (intmax_t) now.tv_usec, + hostname, myident, (intmax_t) pid); + errno = saved_errno; + + /* glibc handles %m in vfprintf() so we don't need to do + * anything special to simulate that feature of syslog() */ + vfprintf(out, format, ap); + fputs("\n", out); + fflush(out); + errno = saved_errno; +} + +EXPORTED void syslog(int priority, const char *format, ...) +{ + va_list ap; + + va_start(ap, format); + fake_vsyslog(priority, format, ap); + va_end(ap); + + va_start(ap, format); + real_vsyslog(priority, format, ap); + va_end(ap); +} + +EXPORTED void vsyslog(int priority, const char *format, va_list ap) +{ + fake_vsyslog(priority, format, ap); + real_vsyslog(priority, format, ap); +} diff --git a/cassandane/utils/syslog_probe.c b/cassandane/utils/syslog_probe.c new file mode 100644 index 0000000000..7defb8136b --- /dev/null +++ b/cassandane/utils/syslog_probe.c @@ -0,0 +1,59 @@ +/* syslog_probe.c + * + * A tiny little tool that just syslogs the magic word with the + * given string as ident prefix + */ + +#include + +#include +#include +#include +#include + +/* straight outta cyrus */ +#define SYSLOG_FACILITY LOG_LOCAL6 + +int main(int argc, char **argv) +{ + char ident[1024]; + int pid; + + if (argc != 2) { + fprintf(stderr, "Usage: %s prefix\n", argv[0]); + return EX_USAGE; + } + + snprintf(ident, sizeof(ident), "%s/syslog_probe", argv[1]); + + /* Fork, and log the magic word from the child, in case LD_PRELOAD + * is discarded during fork. + * XXX This doesn't necessarily tell us much. To do this properly, + * XXX we probably need to mimick certain details of Cyrus master's + * XXX spawn code (capabilities, setuid, etc). + */ + pid = fork(); + if (pid == 0) { + /* child */ + openlog(ident, LOG_PID, SYSLOG_FACILITY); + syslog(LOG_NOTICE, "the magic word"); + closelog(); + + return 0; + } + else if (pid > 0) { + /* parent */ + int wstatus; + + if (wait(&wstatus) && WIFEXITED(wstatus)) { + return WEXITSTATUS(wstatus); + } + + return EX_SOFTWARE; + } + else { + /* fork failed */ + perror("fork"); + return EX_OSERR; + } +} diff --git a/cassandane/utils/tiny-test-splitter.pl b/cassandane/utils/tiny-test-splitter.pl new file mode 100755 index 0000000000..9d571ffe8c --- /dev/null +++ b/cassandane/utils/tiny-test-splitter.pl @@ -0,0 +1,117 @@ +#!/usr/bin/perl +use v5.26.0; +use warnings; + +use File::Basename qw(fileparse); + +my $file; +my @buffer; + +if (!@ARGV or !-e $ARGV[0]) { + die <<~'EOF'; + Greetings! You've run the tiny test splitter. This program exists to split + big Cassandane test classes into lots of little test files. The primary + benefit of this is that you'll avoid conflicts when many peopled add or edit + tests. Eventually, this program won't need to exist, so its construction is + a little shaky, but at least there's this notice! + + Run this program from ./cassandane like this: + + ./utils/tiny-test-splitter Cassandane/Cyrus/SomeClass.pm + + That will edit SomeClass.pl, removing all the test subroutines and adding, at + the end, a `use` statement to load all the tiny test files. The tiny test + files will be present in ./tiny-tests/SomeClass. + + After doing that, run the tests with: + + ./testrunner.pl SomeClass + + Everything should still pass! If it doesn't, debug things. The most common + problem is that the tiny tests use variables (like $foo or @bar) from + SomeClass.pm -- that won't work, and you'll want to replace them with + methods. + + It it does pass, `git add tiny-tests/SomeClass` and commit! Good job! + EOF +} + +my $filename = shift @ARGV; + +my $prefix = fileparse($filename, '.pm'); + +unless (-d "tiny-tests/$prefix") { + system "mkdir -p tiny-tests/$prefix"; +} + +open my $infile, '<', $filename + or die "can't open $filename for reading: $!"; + +my @lines = <$infile>; + +close $infile or die "error reading $infile: $!"; + +die "$filename appears to use Cassandane::Tiny::Loader already!\n" + if grep {; /use Cassandane::Tiny::Loader/ } @lines; + +LINE: while ($_ = shift @lines) { + if (/^sub test_(\S+)/) { + my $file = $1 =~ s/_/-/gr; + say $file; + + my @test_buffer; + + while (@buffer && $buffer[-1] =~ /^#/) { + push @test_buffer, pop @buffer; + } + + @test_buffer = reverse @test_buffer if @test_buffer; + + push @test_buffer, $_; + + my $in_heredoc; + + TESTLINE: while (defined($_ = shift @lines)) { + push @test_buffer, $_; + + if (/<<\s?(['"])?EOF/) { + $in_heredoc = 1; + next TESTLINE; + } + + if (/^EOF$/m) { + undef $in_heredoc; + next TESTLINE; + } + + if (!$in_heredoc && /^}$/ && (!@lines || $lines[0] =~ /^$/)) { + open my $fh, '>', "tiny-tests/$prefix/$file" + or die "can't open $file: $!"; + print {$fh} "#!perl\nuse Cassandane::Tiny;\n\n", @test_buffer; + close $fh; + next LINE; + } + } + } + + # Skip double blanks lines. + next LINE if @buffer && $buffer[-1] =~ /^$/ && /^$/; + + push @buffer, $_; +} + +pop @buffer while $buffer[-1] !~ /\S/; + +die "output file does not end in magic true value!\n" + unless $buffer[-1] eq "1;\n"; + +splice @buffer, -2, 0, ( + "\n", + "use Cassandane::Tiny::Loader 'tiny-tests/$prefix';\n", +); + +open my $outfile, '>', $filename + or die "can't open $filename for writing: $!"; + +print {$outfile} @buffer; +close $outfile or die "error writing to $filename and closing: $!"; diff --git a/cassandane/vg.supp b/cassandane/vg.supp new file mode 100644 index 0000000000..f5f079936e --- /dev/null +++ b/cassandane/vg.supp @@ -0,0 +1,106 @@ +# +# Copyright (c) 2011 Opera Software Australia Pty. Ltd. All rights +# reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Opera Software Australia" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Opera Software Australia Pty. Ltd. +# Level 50, 120 Collins St +# Melbourne 3000 +# Victoria +# Australia +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Opera Software +# Australia Pty. Ltd." +# +# OPERA SOFTWARE AUSTRALIA DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL OPERA SOFTWARE AUSTRALIA BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# +{ + getpwnam_leak + Memcheck:Leak + fun:malloc + ... + fun:getpwnam + ... +} +{ + getgrouplist_leak + Memcheck:Leak + fun:malloc + ... + fun:getgrouplist + ... +} +{ + getpwuid_leak + Memcheck:Leak + fun:malloc + ... + fun:getpwuid + ... +} +{ + getgrgid_leak + Memcheck:Leak + fun:malloc + ... + fun:getgrgid + ... +} +{ + # mupdate detaches all its threads, so they're cleaned up at exit. + # but valgrind notices beforehand and reports their resources as leaked. + mupdate_detached_threads_leak + Memcheck:Leak + match-leak-kinds: possible + ... + fun:pthread_create@@GLIBC_2.2.5 + fun:service_init + fun:main +} +{ + # should only be necessary for valgrind versions < 3.17.0 + # https://valgrind.org/docs/manual/dist.news.html: + # + # 422623 epoll_ctl warns for uninitialized padding on non-amd64 64bit arches + # + # https://bugs.kde.org/show_bug.cgi?id=422623 + aarch64-linux-gnu-libnss-epoll_ctl + Memcheck:Param + epoll_ctl(event) + fun:epoll_ctl + obj:/usr/lib/aarch64-linux-gnu/libnss_systemd.so.2 + ... +} + +{ + dl_open_worker-leak + Memcheck:Leak + match-leak-kinds: possible + ... + fun:dl_open_worker_begin + ... +} + diff --git a/changes/next/0000-TEMPLATE b/changes/next/0000-TEMPLATE new file mode 100644 index 0000000000..1db12f54b4 --- /dev/null +++ b/changes/next/0000-TEMPLATE @@ -0,0 +1,28 @@ +Submitting a PR? Create a file in this directory with the same name as your +branch, and add the following details (you can use this file as a template). + +Description: + +A one line-ish description of the feature, for the release notes. + + +Config changes: + +If your PR changes lib/imapoptions at all, list which options you've changed +here. They should be documented in the source, so this can just be a list. +This is also the place to call out changes to how cyrus.conf is parsed, if +your PR does that. + + +Upgrade instructions: + +What will an admin need to do when upgrading to a Cyrus version that has +this feature? What will they need to do when enabling this feature? +This might be things like "upgrade mailboxes to at least version x", or +"reconstruct with this flag", or "rebuild conversations db", or "rebuild +search indexes", or "add an xyz job to cyrus.conf", ... etc. + + +GitHub issue: + +If theres a github issue number for this, put it here. diff --git a/changes/next/2997-allowspecialusesubfolder b/changes/next/2997-allowspecialusesubfolder new file mode 100644 index 0000000000..aeb329a6ea --- /dev/null +++ b/changes/next/2997-allowspecialusesubfolder @@ -0,0 +1,18 @@ +Description: + +Add configuration option to permit special-use subfolders + + +Config changes: + +allowspecialusesubfolder + + +Upgrade instructions: + +None + + +GitHub issue: + +https://github.com/cyrusimap/cyrus-imapd/issues/2995 diff --git a/changes/next/calalarm-suppress b/changes/next/calalarm-suppress new file mode 100644 index 0000000000..a1d38fd918 --- /dev/null +++ b/changes/next/calalarm-suppress @@ -0,0 +1,21 @@ +Description: + +Touch file to suppress calalarmd processing + + +Config changes: + +Creates a new option: `caldav_alarm_suppress_file`, default null. +If configured, calalarmd will check for this file before processing +alarms, and skip this run while the file is present. + + +Upgrade instructions: + +You don't need to do anything. You can set this if you want to be able to +pause calalarmd at times. + + +GitHub issue: + +No issue was created for this. diff --git a/changes/next/calalarmd_suppress_duplicates b/changes/next/calalarmd_suppress_duplicates new file mode 100644 index 0000000000..9932b2b91b --- /dev/null +++ b/changes/next/calalarmd_suppress_duplicates @@ -0,0 +1,13 @@ +Description: + +Suppress duplicate calendar alarms in calalarmd. + + +Config changes: + +calendar_suppress_duplicate_alarms + + +Upgrade instructions: + +This feature is enabled by default. diff --git a/changes/next/debug-slowio b/changes/next/debug-slowio new file mode 100644 index 0000000000..7398aaf23e --- /dev/null +++ b/changes/next/debug-slowio @@ -0,0 +1,18 @@ +Description: + +Optional I/O throttling, for testing + + +Config changes: + +debug_slowio + + +Upgrade instructions: + +None + + +GitHub issue: + +None diff --git a/changes/next/deprecate_sieve_sasl_send_unsolicited_capability b/changes/next/deprecate_sieve_sasl_send_unsolicited_capability new file mode 100644 index 0000000000..1703489a5a --- /dev/null +++ b/changes/next/deprecate_sieve_sasl_send_unsolicited_capability @@ -0,0 +1,13 @@ +Description: + +Deprecate sieve_sasl_send_unsolicited_capability in lib/imapoptions and toggle its default. + +Config changes: + +This enables the RFC 5804 behaviour by default. The old behaviour - per +draft-martin-managesieve-08 - is not available anymore. + +GitHub issue: + +https://github.com/cyrusimap/cyrus-imapd/pull/3346 + diff --git a/changes/next/fatals-abort b/changes/next/fatals-abort new file mode 100644 index 0000000000..147f303498 --- /dev/null +++ b/changes/next/fatals-abort @@ -0,0 +1,18 @@ +Description: + +Adds an option for fatal errors to abort and produce a core dump. + + +Config changes: + +fatals_abort + + +Upgrade instructions: + +None + + +GitHub issue: + +None diff --git a/changes/next/global-lock b/changes/next/global-lock new file mode 100644 index 0000000000..b5be70038c --- /dev/null +++ b/changes/next/global-lock @@ -0,0 +1,26 @@ +Description: + +Add a way to freeze an entire server temporarily while taking snapshots or +similar. + + +Config changes: + +Adds a new config switch `global_lock` - if true (the default) then a new +global shared lock is taken before any exclusive lock is taken, and held +until all global locks are released - meaning that any command which wishes +to take a consistent snapshot can use the `cyr_withlock_run` command. + +Whether or not this setting is enabled, you can also use +`cyr_withlock_run --user` to run a command with a single user locked. + + +Upgrade instructions: + +There are no operational changes required; it just works once you're running +the new version. + + +GitHub issue: + +https://github.com/cyrusimap/cyrus-imapd/issues/1763 diff --git a/changes/next/groupracl b/changes/next/groupracl new file mode 100644 index 0000000000..0b26fddfd6 --- /dev/null +++ b/changes/next/groupracl @@ -0,0 +1,22 @@ +Description: + +Update mboxlist group: ACL handling to change raclmodseq + +This adds the 'raclmodseq' command as well, which is used by tests to +check that RACLs are updated as expected, and probably won't be used +by anything else! + + +Config changes: + +No config changes - if reverseacls are enabled, they'll also work with +group: acls now. + + +Upgrade instructions: + +No config changes + +GitHub issue: + +No github issue. diff --git a/changes/next/httpd_remove_digestmd5 b/changes/next/httpd_remove_digestmd5 new file mode 100644 index 0000000000..859f6d1c21 --- /dev/null +++ b/changes/next/httpd_remove_digestmd5 @@ -0,0 +1,11 @@ +Description: + +Remove DIGEST-MD5 from httpd and imtest. + +Config changes: + +In imapd.conf remove sasl_mech_list: DIGEST-MD5 + +Upgrade instructions: + +None diff --git a/changes/next/imap-partial b/changes/next/imap-partial new file mode 100644 index 0000000000..4a71b7f048 --- /dev/null +++ b/changes/next/imap-partial @@ -0,0 +1,15 @@ + +Description: + +Adds support for IMAP PARTIAL (RFC 9394) and +INPROGRESS (draft-ietf-extra-imap-inprogress) extensions + + +Config changes: + +None + + +Upgrade instructions: + +None diff --git a/changes/next/imap-utf8-accept b/changes/next/imap-utf8-accept new file mode 100644 index 0000000000..8e0e908e30 --- /dev/null +++ b/changes/next/imap-utf8-accept @@ -0,0 +1,15 @@ + +Description: + +Adds support for IMAP UTF8=ACCEPT (RFC 6855) + + +Config changes: + +This extension will only be advertised and supported if BOTH +'reject8bit' and 'munge8bit' are disabled + + +Upgrade instructions: + +None diff --git a/changes/next/imap_jmapaccess b/changes/next/imap_jmapaccess new file mode 100644 index 0000000000..b668ee4853 --- /dev/null +++ b/changes/next/imap_jmapaccess @@ -0,0 +1,19 @@ + +Description: + +Adds support for IMAP JMAPACCESS (draft-ietf-extra-jmapaccess). + + +Config changes: + +Adds jmapaccess_url option. + + +Upgrade instructions: + +None. + + +GitHub issue: + +None. diff --git a/changes/next/imap_uint32 b/changes/next/imap_uint32 new file mode 100644 index 0000000000..a7520b691e --- /dev/null +++ b/changes/next/imap_uint32 @@ -0,0 +1,19 @@ + +Description: + +Support full 32-bit unsigned numbers in IMAP parser. + + +Config changes: + +None. + + +Upgrade instructions: + +None. + + +GitHub issue: + +None. diff --git a/changes/next/mboxgroups b/changes/next/mboxgroups new file mode 100644 index 0000000000..1a9d4e41c8 --- /dev/null +++ b/changes/next/mboxgroups @@ -0,0 +1,23 @@ +Description: + +A new authentication backend which is like auth_unix but stores groups in +the mailboxes.db + + +Config changes: + +adds 'mboxgroups' as a possible value for the auth_mech config option +adds 'vnd.cmu.visibleUsers' to event_extra_params + + +Upgrade instructions: + +No changed needed - but you can use the mboxgroups backend if you want. + +The new capability 'XUSERGROUPS' will appear in imapd, and admins will +get the new commands GETUSERGROUP, SETUSERGROUP, and UNSETUSERGROUP. + + +GitHub issue: + +If theres a github issue number for this, put it here. diff --git a/changes/next/parseaddr_utf8_domain b/changes/next/parseaddr_utf8_domain new file mode 100644 index 0000000000..2738e7b0ff --- /dev/null +++ b/changes/next/parseaddr_utf8_domain @@ -0,0 +1,11 @@ +Description: +Updates the email address parser to preserve non-ASCII characters +in the domain part. + + +Config changes: +None. + + +Upgrade instructions: +Reconstruct mailboxes with the -G option to force reparsing email headers. diff --git a/changes/next/pop3k_remove_kpop b/changes/next/pop3k_remove_kpop new file mode 100644 index 0000000000..ec8157e8fa --- /dev/null +++ b/changes/next/pop3k_remove_kpop @@ -0,0 +1,15 @@ +Description: + +Remove MIT Kerberized POP3 support + + +Config changes: + +None + +Upgrade instructions: + +Remove -k command line parameter when starting pop3d. + +GitHub issue: + diff --git a/changes/next/prom-usage-stats b/changes/next/prom-usage-stats new file mode 100644 index 0000000000..679a6251d3 --- /dev/null +++ b/changes/next/prom-usage-stats @@ -0,0 +1,23 @@ +Description: + +Increased granularity of Prometheus report frequency configuration. + + +Config changes: + +prometheus_update_freq +prometheus_service_update_freq +prometheus_master_update_freq +prometheus_usage_update_freq + + +Upgrade instructions: + +The Prometheus usage report will be turned off by default after upgrade. +This means that the "cyrus_usage_..." metrics will no longer be reported. +To turn this back on, set prometheus_usage_update_freq to a suitable duration. + + +GitHub issue: + +None diff --git a/changes/next/proxy_protocol b/changes/next/proxy_protocol new file mode 100644 index 0000000000..a8321bb87b --- /dev/null +++ b/changes/next/proxy_protocol @@ -0,0 +1,22 @@ + +Description: + +Add support for the HAProxy protocol. + +https://github.com/haproxy/haproxy/blob/master/doc/proxy-protocol.txt + + +Config changes: + +None. + + +Upgrade instructions: + +To enable support for the HAProxy protocol in imapd, pop3d, lmtpd, nntpd, httpd, or +timesieved, add the 'H' command line option to the service in cyrus.conf. + + +GitHub issue: + +None. diff --git a/changes/next/remove_imap_xmove b/changes/next/remove_imap_xmove new file mode 100644 index 0000000000..b5dc196f71 --- /dev/null +++ b/changes/next/remove_imap_xmove @@ -0,0 +1,14 @@ + +Description: + +Support for the legacy IMAP XMOVE command has been removed. + + +Config changes: + +None. + + +Upgrade instructions: + +None. diff --git a/changes/next/replicaonly b/changes/next/replicaonly new file mode 100644 index 0000000000..8ce1e80af8 --- /dev/null +++ b/changes/next/replicaonly @@ -0,0 +1,24 @@ +Description: + +add 'replicaonly' config option that blocks all non-silent writes + +(Silent writes are those where the modseq is specified in the write, +so the highestmodseq doesn't get increased - these are the sort done +by sync_server) + +Config changes: + +the boolean config option `replicaonly` (default: false) can be set +to mark a server as only being a replica. This will stop calalarmd +from doing anything, and also deny any non-silent writes. + + +Upgrade instructions: + +No change required - you can keep running without setting this config +option on replicas, and they will behave as before (in particular, +you will still have to make sure not to run calalarmd on replicas). + +GitHub issue: + +none diff --git a/changes/next/replicate-userflags b/changes/next/replicate-userflags new file mode 100644 index 0000000000..fc0af4d3e0 --- /dev/null +++ b/changes/next/replicate-userflags @@ -0,0 +1,18 @@ +Description: + +User-defined flags are now replicated even when not in use on any messages. + + +Config changes: + +None + + +Upgrade instructions: + +Nothing required + + +GitHub issue: + +None diff --git a/changes/next/sieve-unicode-casemap b/changes/next/sieve-unicode-casemap new file mode 100644 index 0000000000..7be0962a34 --- /dev/null +++ b/changes/next/sieve-unicode-casemap @@ -0,0 +1,14 @@ + +Description: + +Adds support for comparator-i;unicode-casemap (RFC 5051) to Sieve + + +Config changes: + +None + + +Upgrade instructions: + +None diff --git a/changes/next/skipuser b/changes/next/skipuser new file mode 100644 index 0000000000..c9918f5a88 --- /dev/null +++ b/changes/next/skipuser @@ -0,0 +1,20 @@ +Description: + +Add a `skipuser-$userid` touchfile to sync directories + + +Config changes: + +None + + +Upgrade instructions: + +Nothing required. Once you've upgraded, you can touch a file in the sync/ +or sync/channel/ folder called 'skipuser-foo@example.com' which will skip any +replication for that named user. + + +GitHub issue: + +No github issue diff --git a/changes/next/toggleable-debug b/changes/next/toggleable-debug new file mode 100644 index 0000000000..4a0238d9db --- /dev/null +++ b/changes/next/toggleable-debug @@ -0,0 +1,19 @@ +Description: + +Running processes can now have debug logging toggled on/off by sending +them SIGUSR1 + + +Config changes: + +debug + + +Upgrade instructions: + +Nothing required + + +GitHub issue: + +None diff --git a/changes/next/xapian_add_messageid b/changes/next/xapian_add_messageid new file mode 100644 index 0000000000..54148b4f73 --- /dev/null +++ b/changes/next/xapian_add_messageid @@ -0,0 +1,19 @@ +Description: + +Adds JMAP Email/query filter conditions `messageId`, `references` and `inReplyTo`. + +Config changes: + +None. + + +Upgrade instructions: + +It is recommended to rebuild the Xapian index to make use of these filter +conditions. Otherwise, email queries having these filter fall back to +reading the MIME headers from disk, resulting in slower search. + + +GitHub issue: + +None. diff --git a/cmulocal/ax_cxx_compile_stdcxx.m4 b/cmulocal/ax_cxx_compile_stdcxx.m4 new file mode 100644 index 0000000000..8edf5152ec --- /dev/null +++ b/cmulocal/ax_cxx_compile_stdcxx.m4 @@ -0,0 +1,1018 @@ +# =========================================================================== +# https://www.gnu.org/software/autoconf-archive/ax_cxx_compile_stdcxx.html +# =========================================================================== +# +# SYNOPSIS +# +# AX_CXX_COMPILE_STDCXX(VERSION, [ext|noext], [mandatory|optional]) +# +# DESCRIPTION +# +# Check for baseline language coverage in the compiler for the specified +# version of the C++ standard. If necessary, add switches to CXX and +# CXXCPP to enable support. VERSION may be '11', '14', '17', or '20' for +# the respective C++ standard version. +# +# The second argument, if specified, indicates whether you insist on an +# extended mode (e.g. -std=gnu++11) or a strict conformance mode (e.g. +# -std=c++11). If neither is specified, you get whatever works, with +# preference for no added switch, and then for an extended mode. +# +# The third argument, if specified 'mandatory' or if left unspecified, +# indicates that baseline support for the specified C++ standard is +# required and that the macro should error out if no mode with that +# support is found. If specified 'optional', then configuration proceeds +# regardless, after defining HAVE_CXX${VERSION} if and only if a +# supporting mode is found. +# +# LICENSE +# +# Copyright (c) 2008 Benjamin Kosnik +# Copyright (c) 2012 Zack Weinberg +# Copyright (c) 2013 Roy Stogner +# Copyright (c) 2014, 2015 Google Inc.; contributed by Alexey Sokolov +# Copyright (c) 2015 Paul Norman +# Copyright (c) 2015 Moritz Klammler +# Copyright (c) 2016, 2018 Krzesimir Nowak +# Copyright (c) 2019 Enji Cooper +# Copyright (c) 2020 Jason Merrill +# Copyright (c) 2021 Jörn Heusipp +# +# Copying and distribution of this file, with or without modification, are +# permitted in any medium without royalty provided the copyright notice +# and this notice are preserved. This file is offered as-is, without any +# warranty. + +#serial 18 + +dnl This macro is based on the code from the AX_CXX_COMPILE_STDCXX_11 macro +dnl (serial version number 13). + +AC_DEFUN([AX_CXX_COMPILE_STDCXX], [dnl + m4_if([$1], [11], [ax_cxx_compile_alternatives="11 0x"], + [$1], [14], [ax_cxx_compile_alternatives="14 1y"], + [$1], [17], [ax_cxx_compile_alternatives="17 1z"], + [$1], [20], [ax_cxx_compile_alternatives="20"], + [m4_fatal([invalid first argument `$1' to AX_CXX_COMPILE_STDCXX])])dnl + m4_if([$2], [], [], + [$2], [ext], [], + [$2], [noext], [], + [m4_fatal([invalid second argument `$2' to AX_CXX_COMPILE_STDCXX])])dnl + m4_if([$3], [], [ax_cxx_compile_cxx$1_required=true], + [$3], [mandatory], [ax_cxx_compile_cxx$1_required=true], + [$3], [optional], [ax_cxx_compile_cxx$1_required=false], + [m4_fatal([invalid third argument `$3' to AX_CXX_COMPILE_STDCXX])]) + AC_LANG_PUSH([C++])dnl + ac_success=no + + m4_if([$2], [], [dnl + AC_CACHE_CHECK(whether $CXX supports C++$1 features by default, + ax_cv_cxx_compile_cxx$1, + [AC_COMPILE_IFELSE([AC_LANG_SOURCE([_AX_CXX_COMPILE_STDCXX_testbody_$1])], + [ax_cv_cxx_compile_cxx$1=yes], + [ax_cv_cxx_compile_cxx$1=no])]) + if test x$ax_cv_cxx_compile_cxx$1 = xyes; then + ac_success=yes + fi]) + + m4_if([$2], [noext], [], [dnl + if test x$ac_success = xno; then + for alternative in ${ax_cxx_compile_alternatives}; do + switch="-std=gnu++${alternative}" + cachevar=AS_TR_SH([ax_cv_cxx_compile_cxx$1_$switch]) + AC_CACHE_CHECK(whether $CXX supports C++$1 features with $switch, + $cachevar, + [ac_save_CXX="$CXX" + CXX="$CXX $switch" + AC_COMPILE_IFELSE([AC_LANG_SOURCE([_AX_CXX_COMPILE_STDCXX_testbody_$1])], + [eval $cachevar=yes], + [eval $cachevar=no]) + CXX="$ac_save_CXX"]) + if eval test x\$$cachevar = xyes; then + CXX="$CXX $switch" + if test -n "$CXXCPP" ; then + CXXCPP="$CXXCPP $switch" + fi + ac_success=yes + break + fi + done + fi]) + + m4_if([$2], [ext], [], [dnl + if test x$ac_success = xno; then + dnl HP's aCC needs +std=c++11 according to: + dnl http://h21007.www2.hp.com/portal/download/files/unprot/aCxx/PDF_Release_Notes/769149-001.pdf + dnl Cray's crayCC needs "-h std=c++11" + dnl MSVC needs -std:c++NN for C++17 and later (default is C++14) + for alternative in ${ax_cxx_compile_alternatives}; do + for switch in -std=c++${alternative} +std=c++${alternative} "-h std=c++${alternative}" MSVC; do + if test x"$switch" = xMSVC; then + dnl AS_TR_SH maps both `:` and `=` to `_` so -std:c++17 would collide + dnl with -std=c++17. We suffix the cache variable name with _MSVC to + dnl avoid this. + switch=-std:c++${alternative} + cachevar=AS_TR_SH([ax_cv_cxx_compile_cxx$1_${switch}_MSVC]) + else + cachevar=AS_TR_SH([ax_cv_cxx_compile_cxx$1_$switch]) + fi + AC_CACHE_CHECK(whether $CXX supports C++$1 features with $switch, + $cachevar, + [ac_save_CXX="$CXX" + CXX="$CXX $switch" + AC_COMPILE_IFELSE([AC_LANG_SOURCE([_AX_CXX_COMPILE_STDCXX_testbody_$1])], + [eval $cachevar=yes], + [eval $cachevar=no]) + CXX="$ac_save_CXX"]) + if eval test x\$$cachevar = xyes; then + CXX="$CXX $switch" + if test -n "$CXXCPP" ; then + CXXCPP="$CXXCPP $switch" + fi + ac_success=yes + break + fi + done + if test x$ac_success = xyes; then + break + fi + done + fi]) + AC_LANG_POP([C++]) + if test x$ax_cxx_compile_cxx$1_required = xtrue; then + if test x$ac_success = xno; then + AC_MSG_ERROR([*** A compiler with support for C++$1 language features is required.]) + fi + fi + if test x$ac_success = xno; then + HAVE_CXX$1=0 + AC_MSG_NOTICE([No compiler with C++$1 support was found]) + else + HAVE_CXX$1=1 + AC_DEFINE(HAVE_CXX$1,1, + [define if the compiler supports basic C++$1 syntax]) + fi + AC_SUBST(HAVE_CXX$1) +]) + + +dnl Test body for checking C++11 support + +m4_define([_AX_CXX_COMPILE_STDCXX_testbody_11], + _AX_CXX_COMPILE_STDCXX_testbody_new_in_11 +) + +dnl Test body for checking C++14 support + +m4_define([_AX_CXX_COMPILE_STDCXX_testbody_14], + _AX_CXX_COMPILE_STDCXX_testbody_new_in_11 + _AX_CXX_COMPILE_STDCXX_testbody_new_in_14 +) + +dnl Test body for checking C++17 support + +m4_define([_AX_CXX_COMPILE_STDCXX_testbody_17], + _AX_CXX_COMPILE_STDCXX_testbody_new_in_11 + _AX_CXX_COMPILE_STDCXX_testbody_new_in_14 + _AX_CXX_COMPILE_STDCXX_testbody_new_in_17 +) + +dnl Test body for checking C++20 support + +m4_define([_AX_CXX_COMPILE_STDCXX_testbody_20], + _AX_CXX_COMPILE_STDCXX_testbody_new_in_11 + _AX_CXX_COMPILE_STDCXX_testbody_new_in_14 + _AX_CXX_COMPILE_STDCXX_testbody_new_in_17 + _AX_CXX_COMPILE_STDCXX_testbody_new_in_20 +) + + +dnl Tests for new features in C++11 + +m4_define([_AX_CXX_COMPILE_STDCXX_testbody_new_in_11], [[ + +// If the compiler admits that it is not ready for C++11, why torture it? +// Hopefully, this will speed up the test. + +#ifndef __cplusplus + +#error "This is not a C++ compiler" + +// MSVC always sets __cplusplus to 199711L in older versions; newer versions +// only set it correctly if /Zc:__cplusplus is specified as well as a +// /std:c++NN switch: +// https://devblogs.microsoft.com/cppblog/msvc-now-correctly-reports-__cplusplus/ +#elif __cplusplus < 201103L && !defined _MSC_VER + +#error "This is not a C++11 compiler" + +#else + +namespace cxx11 +{ + + namespace test_static_assert + { + + template + struct check + { + static_assert(sizeof(int) <= sizeof(T), "not big enough"); + }; + + } + + namespace test_final_override + { + + struct Base + { + virtual ~Base() {} + virtual void f() {} + }; + + struct Derived : public Base + { + virtual ~Derived() override {} + virtual void f() override {} + }; + + } + + namespace test_double_right_angle_brackets + { + + template < typename T > + struct check {}; + + typedef check single_type; + typedef check> double_type; + typedef check>> triple_type; + typedef check>>> quadruple_type; + + } + + namespace test_decltype + { + + int + f() + { + int a = 1; + decltype(a) b = 2; + return a + b; + } + + } + + namespace test_type_deduction + { + + template < typename T1, typename T2 > + struct is_same + { + static const bool value = false; + }; + + template < typename T > + struct is_same + { + static const bool value = true; + }; + + template < typename T1, typename T2 > + auto + add(T1 a1, T2 a2) -> decltype(a1 + a2) + { + return a1 + a2; + } + + int + test(const int c, volatile int v) + { + static_assert(is_same::value == true, ""); + static_assert(is_same::value == false, ""); + static_assert(is_same::value == false, ""); + auto ac = c; + auto av = v; + auto sumi = ac + av + 'x'; + auto sumf = ac + av + 1.0; + static_assert(is_same::value == true, ""); + static_assert(is_same::value == true, ""); + static_assert(is_same::value == true, ""); + static_assert(is_same::value == false, ""); + static_assert(is_same::value == true, ""); + return (sumf > 0.0) ? sumi : add(c, v); + } + + } + + namespace test_noexcept + { + + int f() { return 0; } + int g() noexcept { return 0; } + + static_assert(noexcept(f()) == false, ""); + static_assert(noexcept(g()) == true, ""); + + } + + namespace test_constexpr + { + + template < typename CharT > + unsigned long constexpr + strlen_c_r(const CharT *const s, const unsigned long acc) noexcept + { + return *s ? strlen_c_r(s + 1, acc + 1) : acc; + } + + template < typename CharT > + unsigned long constexpr + strlen_c(const CharT *const s) noexcept + { + return strlen_c_r(s, 0UL); + } + + static_assert(strlen_c("") == 0UL, ""); + static_assert(strlen_c("1") == 1UL, ""); + static_assert(strlen_c("example") == 7UL, ""); + static_assert(strlen_c("another\0example") == 7UL, ""); + + } + + namespace test_rvalue_references + { + + template < int N > + struct answer + { + static constexpr int value = N; + }; + + answer<1> f(int&) { return answer<1>(); } + answer<2> f(const int&) { return answer<2>(); } + answer<3> f(int&&) { return answer<3>(); } + + void + test() + { + int i = 0; + const int c = 0; + static_assert(decltype(f(i))::value == 1, ""); + static_assert(decltype(f(c))::value == 2, ""); + static_assert(decltype(f(0))::value == 3, ""); + } + + } + + namespace test_uniform_initialization + { + + struct test + { + static const int zero {}; + static const int one {1}; + }; + + static_assert(test::zero == 0, ""); + static_assert(test::one == 1, ""); + + } + + namespace test_lambdas + { + + void + test1() + { + auto lambda1 = [](){}; + auto lambda2 = lambda1; + lambda1(); + lambda2(); + } + + int + test2() + { + auto a = [](int i, int j){ return i + j; }(1, 2); + auto b = []() -> int { return '0'; }(); + auto c = [=](){ return a + b; }(); + auto d = [&](){ return c; }(); + auto e = [a, &b](int x) mutable { + const auto identity = [](int y){ return y; }; + for (auto i = 0; i < a; ++i) + a += b--; + return x + identity(a + b); + }(0); + return a + b + c + d + e; + } + + int + test3() + { + const auto nullary = [](){ return 0; }; + const auto unary = [](int x){ return x; }; + using nullary_t = decltype(nullary); + using unary_t = decltype(unary); + const auto higher1st = [](nullary_t f){ return f(); }; + const auto higher2nd = [unary](nullary_t f1){ + return [unary, f1](unary_t f2){ return f2(unary(f1())); }; + }; + return higher1st(nullary) + higher2nd(nullary)(unary); + } + + } + + namespace test_variadic_templates + { + + template + struct sum; + + template + struct sum + { + static constexpr auto value = N0 + sum::value; + }; + + template <> + struct sum<> + { + static constexpr auto value = 0; + }; + + static_assert(sum<>::value == 0, ""); + static_assert(sum<1>::value == 1, ""); + static_assert(sum<23>::value == 23, ""); + static_assert(sum<1, 2>::value == 3, ""); + static_assert(sum<5, 5, 11>::value == 21, ""); + static_assert(sum<2, 3, 5, 7, 11, 13>::value == 41, ""); + + } + + // http://stackoverflow.com/questions/13728184/template-aliases-and-sfinae + // Clang 3.1 fails with headers of libstd++ 4.8.3 when using std::function + // because of this. + namespace test_template_alias_sfinae + { + + struct foo {}; + + template + using member = typename T::member_type; + + template + void func(...) {} + + template + void func(member*) {} + + void test(); + + void test() { func(0); } + + } + +} // namespace cxx11 + +#endif // __cplusplus >= 201103L + +]]) + + +dnl Tests for new features in C++14 + +m4_define([_AX_CXX_COMPILE_STDCXX_testbody_new_in_14], [[ + +// If the compiler admits that it is not ready for C++14, why torture it? +// Hopefully, this will speed up the test. + +#ifndef __cplusplus + +#error "This is not a C++ compiler" + +#elif __cplusplus < 201402L && !defined _MSC_VER + +#error "This is not a C++14 compiler" + +#else + +namespace cxx14 +{ + + namespace test_polymorphic_lambdas + { + + int + test() + { + const auto lambda = [](auto&&... args){ + const auto istiny = [](auto x){ + return (sizeof(x) == 1UL) ? 1 : 0; + }; + const int aretiny[] = { istiny(args)... }; + return aretiny[0]; + }; + return lambda(1, 1L, 1.0f, '1'); + } + + } + + namespace test_binary_literals + { + + constexpr auto ivii = 0b0000000000101010; + static_assert(ivii == 42, "wrong value"); + + } + + namespace test_generalized_constexpr + { + + template < typename CharT > + constexpr unsigned long + strlen_c(const CharT *const s) noexcept + { + auto length = 0UL; + for (auto p = s; *p; ++p) + ++length; + return length; + } + + static_assert(strlen_c("") == 0UL, ""); + static_assert(strlen_c("x") == 1UL, ""); + static_assert(strlen_c("test") == 4UL, ""); + static_assert(strlen_c("another\0test") == 7UL, ""); + + } + + namespace test_lambda_init_capture + { + + int + test() + { + auto x = 0; + const auto lambda1 = [a = x](int b){ return a + b; }; + const auto lambda2 = [a = lambda1(x)](){ return a; }; + return lambda2(); + } + + } + + namespace test_digit_separators + { + + constexpr auto ten_million = 100'000'000; + static_assert(ten_million == 100000000, ""); + + } + + namespace test_return_type_deduction + { + + auto f(int& x) { return x; } + decltype(auto) g(int& x) { return x; } + + template < typename T1, typename T2 > + struct is_same + { + static constexpr auto value = false; + }; + + template < typename T > + struct is_same + { + static constexpr auto value = true; + }; + + int + test() + { + auto x = 0; + static_assert(is_same::value, ""); + static_assert(is_same::value, ""); + return x; + } + + } + +} // namespace cxx14 + +#endif // __cplusplus >= 201402L + +]]) + + +dnl Tests for new features in C++17 + +m4_define([_AX_CXX_COMPILE_STDCXX_testbody_new_in_17], [[ + +// If the compiler admits that it is not ready for C++17, why torture it? +// Hopefully, this will speed up the test. + +#ifndef __cplusplus + +#error "This is not a C++ compiler" + +#elif __cplusplus < 201703L && !defined _MSC_VER + +#error "This is not a C++17 compiler" + +#else + +#include +#include +#include + +namespace cxx17 +{ + + namespace test_constexpr_lambdas + { + + constexpr int foo = [](){return 42;}(); + + } + + namespace test::nested_namespace::definitions + { + + } + + namespace test_fold_expression + { + + template + int multiply(Args... args) + { + return (args * ... * 1); + } + + template + bool all(Args... args) + { + return (args && ...); + } + + } + + namespace test_extended_static_assert + { + + static_assert (true); + + } + + namespace test_auto_brace_init_list + { + + auto foo = {5}; + auto bar {5}; + + static_assert(std::is_same, decltype(foo)>::value); + static_assert(std::is_same::value); + } + + namespace test_typename_in_template_template_parameter + { + + template typename X> struct D; + + } + + namespace test_fallthrough_nodiscard_maybe_unused_attributes + { + + int f1() + { + return 42; + } + + [[nodiscard]] int f2() + { + [[maybe_unused]] auto unused = f1(); + + switch (f1()) + { + case 17: + f1(); + [[fallthrough]]; + case 42: + f1(); + } + return f1(); + } + + } + + namespace test_extended_aggregate_initialization + { + + struct base1 + { + int b1, b2 = 42; + }; + + struct base2 + { + base2() { + b3 = 42; + } + int b3; + }; + + struct derived : base1, base2 + { + int d; + }; + + derived d1 {{1, 2}, {}, 4}; // full initialization + derived d2 {{}, {}, 4}; // value-initialized bases + + } + + namespace test_general_range_based_for_loop + { + + struct iter + { + int i; + + int& operator* () + { + return i; + } + + const int& operator* () const + { + return i; + } + + iter& operator++() + { + ++i; + return *this; + } + }; + + struct sentinel + { + int i; + }; + + bool operator== (const iter& i, const sentinel& s) + { + return i.i == s.i; + } + + bool operator!= (const iter& i, const sentinel& s) + { + return !(i == s); + } + + struct range + { + iter begin() const + { + return {0}; + } + + sentinel end() const + { + return {5}; + } + }; + + void f() + { + range r {}; + + for (auto i : r) + { + [[maybe_unused]] auto v = i; + } + } + + } + + namespace test_lambda_capture_asterisk_this_by_value + { + + struct t + { + int i; + int foo() + { + return [*this]() + { + return i; + }(); + } + }; + + } + + namespace test_enum_class_construction + { + + enum class byte : unsigned char + {}; + + byte foo {42}; + + } + + namespace test_constexpr_if + { + + template + int f () + { + if constexpr(cond) + { + return 13; + } + else + { + return 42; + } + } + + } + + namespace test_selection_statement_with_initializer + { + + int f() + { + return 13; + } + + int f2() + { + if (auto i = f(); i > 0) + { + return 3; + } + + switch (auto i = f(); i + 4) + { + case 17: + return 2; + + default: + return 1; + } + } + + } + + namespace test_template_argument_deduction_for_class_templates + { + + template + struct pair + { + pair (T1 p1, T2 p2) + : m1 {p1}, + m2 {p2} + {} + + T1 m1; + T2 m2; + }; + + void f() + { + [[maybe_unused]] auto p = pair{13, 42u}; + } + + } + + namespace test_non_type_auto_template_parameters + { + + template + struct B + {}; + + B<5> b1; + B<'a'> b2; + + } + + namespace test_structured_bindings + { + + int arr[2] = { 1, 2 }; + std::pair pr = { 1, 2 }; + + auto f1() -> int(&)[2] + { + return arr; + } + + auto f2() -> std::pair& + { + return pr; + } + + struct S + { + int x1 : 2; + volatile double y1; + }; + + S f3() + { + return {}; + } + + auto [ x1, y1 ] = f1(); + auto& [ xr1, yr1 ] = f1(); + auto [ x2, y2 ] = f2(); + auto& [ xr2, yr2 ] = f2(); + const auto [ x3, y3 ] = f3(); + + } + + namespace test_exception_spec_type_system + { + + struct Good {}; + struct Bad {}; + + void g1() noexcept; + void g2(); + + template + Bad + f(T*, T*); + + template + Good + f(T1*, T2*); + + static_assert (std::is_same_v); + + } + + namespace test_inline_variables + { + + template void f(T) + {} + + template inline T g(T) + { + return T{}; + } + + template<> inline void f<>(int) + {} + + template<> int g<>(int) + { + return 5; + } + + } + +} // namespace cxx17 + +#endif // __cplusplus < 201703L && !defined _MSC_VER + +]]) + + +dnl Tests for new features in C++20 + +m4_define([_AX_CXX_COMPILE_STDCXX_testbody_new_in_20], [[ + +#ifndef __cplusplus + +#error "This is not a C++ compiler" + +#elif __cplusplus < 202002L && !defined _MSC_VER + +#error "This is not a C++20 compiler" + +#else + +#include + +namespace cxx20 +{ + +// As C++20 supports feature test macros in the standard, there is no +// immediate need to actually test for feature availability on the +// Autoconf side. + +} // namespace cxx20 + +#endif // __cplusplus < 202002L && !defined _MSC_VER + +]]) diff --git a/cmulocal/ax_cxx_compile_stdcxx_11.m4 b/cmulocal/ax_cxx_compile_stdcxx_11.m4 index 516da37e82..1733fd85f9 100644 --- a/cmulocal/ax_cxx_compile_stdcxx_11.m4 +++ b/cmulocal/ax_cxx_compile_stdcxx_11.m4 @@ -1,26 +1,23 @@ -# ============================================================================ -# http://www.gnu.org/software/autoconf-archive/ax_cxx_compile_stdcxx_11.html -# ============================================================================ +# ============================================================================= +# https://www.gnu.org/software/autoconf-archive/ax_cxx_compile_stdcxx_11.html +# ============================================================================= # # SYNOPSIS # -# AX_CXX_COMPILE_STDCXX_11([ext|noext],[mandatory|optional]) +# AX_CXX_COMPILE_STDCXX_11([ext|noext], [mandatory|optional]) # # DESCRIPTION # # Check for baseline language coverage in the compiler for the C++11 -# standard; if necessary, add switches to CXXFLAGS to enable support. +# standard; if necessary, add switches to CXX and CXXCPP to enable +# support. # -# The first argument, if specified, indicates whether you insist on an -# extended mode (e.g. -std=gnu++11) or a strict conformance mode (e.g. -# -std=c++11). If neither is specified, you get whatever works, with -# preference for an extended mode. -# -# The second argument, if specified 'mandatory' or if left unspecified, -# indicates that baseline C++11 support is required and that the macro -# should error out if no mode with that support is found. If specified -# 'optional', then configuration proceeds regardless, after defining -# HAVE_CXX11 if and only if a supporting mode is found. +# This macro is a convenience alias for calling the AX_CXX_COMPILE_STDCXX +# macro with the version set to C++11. The two optional arguments are +# forwarded literally as the second and third argument respectively. +# Please see the documentation for the AX_CXX_COMPILE_STDCXX macro for +# more information. If you want to use this macro, you also need to +# download the ax_cxx_compile_stdcxx.m4 file. # # LICENSE # @@ -29,144 +26,14 @@ # Copyright (c) 2013 Roy Stogner # Copyright (c) 2014, 2015 Google Inc.; contributed by Alexey Sokolov # Copyright (c) 2015 Paul Norman +# Copyright (c) 2015 Moritz Klammler # # Copying and distribution of this file, with or without modification, are # permitted in any medium without royalty provided the copyright notice # and this notice are preserved. This file is offered as-is, without any # warranty. -#serial 13 - -m4_define([_AX_CXX_COMPILE_STDCXX_11_testbody], [[ - template - struct check - { - static_assert(sizeof(int) <= sizeof(T), "not big enough"); - }; - - struct Base { - virtual void f() {} - }; - struct Child : public Base { - virtual void f() override {} - }; - - typedef check> right_angle_brackets; - - int a; - decltype(a) b; - - typedef check check_type; - check_type c; - check_type&& cr = static_cast(c); - - auto d = a; - auto l = [](){}; - // Prevent Clang error: unused variable 'l' [-Werror,-Wunused-variable] - struct use_l { use_l() { l(); } }; - - // http://stackoverflow.com/questions/13728184/template-aliases-and-sfinae - // Clang 3.1 fails with headers of libstd++ 4.8.3 when using std::function because of this - namespace test_template_alias_sfinae { - struct foo {}; - - template - using member = typename T::member_type; - - template - void func(...) {} - - template - void func(member*) {} - - void test(); - - void test() { - func(0); - } - } - - // Check for C++11 attribute support - void noret [[noreturn]] () { throw 0; } -]]) - -AC_DEFUN([AX_CXX_COMPILE_STDCXX_11], [dnl - m4_if([$1], [], [], - [$1], [ext], [], - [$1], [noext], [], - [m4_fatal([invalid argument `$1' to AX_CXX_COMPILE_STDCXX_11])])dnl - m4_if([$2], [], [ax_cxx_compile_cxx11_required=true], - [$2], [mandatory], [ax_cxx_compile_cxx11_required=true], - [$2], [optional], [ax_cxx_compile_cxx11_required=false], - [m4_fatal([invalid second argument `$2' to AX_CXX_COMPILE_STDCXX_11])]) - AC_LANG_PUSH([C++])dnl - ac_success=no - AC_CACHE_CHECK(whether $CXX supports C++11 features by default, - ax_cv_cxx_compile_cxx11, - [AC_COMPILE_IFELSE([AC_LANG_SOURCE([_AX_CXX_COMPILE_STDCXX_11_testbody])], - [ax_cv_cxx_compile_cxx11=yes], - [ax_cv_cxx_compile_cxx11=no])]) - if test x$ax_cv_cxx_compile_cxx11 = xyes; then - ac_success=yes - fi - - m4_if([$1], [noext], [], [dnl - if test x$ac_success = xno; then - for switch in -std=gnu++11 -std=gnu++0x; do - cachevar=AS_TR_SH([ax_cv_cxx_compile_cxx11_$switch]) - AC_CACHE_CHECK(whether $CXX supports C++11 features with $switch, - $cachevar, - [ac_save_CXXFLAGS="$CXXFLAGS" - CXXFLAGS="$CXXFLAGS $switch" - AC_COMPILE_IFELSE([AC_LANG_SOURCE([_AX_CXX_COMPILE_STDCXX_11_testbody])], - [eval $cachevar=yes], - [eval $cachevar=no]) - CXXFLAGS="$ac_save_CXXFLAGS"]) - if eval test x\$$cachevar = xyes; then - CXXFLAGS="$CXXFLAGS $switch" - ac_success=yes - break - fi - done - fi]) - - m4_if([$1], [ext], [], [dnl - if test x$ac_success = xno; then - dnl HP's aCC needs +std=c++11 according to: - dnl http://h21007.www2.hp.com/portal/download/files/unprot/aCxx/PDF_Release_Notes/769149-001.pdf - dnl Cray's crayCC needs "-h std=c++11" - for switch in -std=c++11 -std=c++0x +std=c++11 "-h std=c++11"; do - cachevar=AS_TR_SH([ax_cv_cxx_compile_cxx11_$switch]) - AC_CACHE_CHECK(whether $CXX supports C++11 features with $switch, - $cachevar, - [ac_save_CXXFLAGS="$CXXFLAGS" - CXXFLAGS="$CXXFLAGS $switch" - AC_COMPILE_IFELSE([AC_LANG_SOURCE([_AX_CXX_COMPILE_STDCXX_11_testbody])], - [eval $cachevar=yes], - [eval $cachevar=no]) - CXXFLAGS="$ac_save_CXXFLAGS"]) - if eval test x\$$cachevar = xyes; then - CXXFLAGS="$CXXFLAGS $switch" - ac_success=yes - break - fi - done - fi]) - AC_LANG_POP([C++]) - if test x$ax_cxx_compile_cxx11_required = xtrue; then - if test x$ac_success = xno; then - AC_MSG_ERROR([*** A compiler with support for C++11 language features is required.]) - fi - else - if test x$ac_success = xno; then - HAVE_CXX11=0 - AC_MSG_NOTICE([No compiler with C++11 support was found]) - else - HAVE_CXX11=1 - AC_DEFINE(HAVE_CXX11,1, - [define if the compiler supports basic C++11 syntax]) - fi +#serial 18 - AC_SUBST(HAVE_CXX11) - fi -]) +AX_REQUIRE_DEFINED([AX_CXX_COMPILE_STDCXX]) +AC_DEFUN([AX_CXX_COMPILE_STDCXX_11], [AX_CXX_COMPILE_STDCXX([11], [$1], [$2])]) diff --git a/cmulocal/cyr_inttype.m4 b/cmulocal/cyr_inttype.m4 new file mode 100644 index 0000000000..80eb4addff --- /dev/null +++ b/cmulocal/cyr_inttype.m4 @@ -0,0 +1,97 @@ +# SYNOPSIS +# +# CYR_INTTYPE([TYPE-NAME], [BUILD-PREREQS]) +# +# DESCRIPTION +# +# Figures out the underlying integer type of TYPE-NAME and the appropriate +# printf format string and strtol-like parse function to use for it. +# +# BUILD-PREREQS is whatever C code is required for this type to be defined. +# This is probably a #include statement! +# +# Sets three cache variables: +# +# * $cyr_cv_type_foo => the underlying integer type for the type "foo" +# * $cyr_cv_format_foo => the format string to use for type "foo" +# * $cyr_cv_parse_foo => the strtol-like parse function to use for type "foo" +# +# Example: +# +# CYR_INTTYPE([time_t], [#include ]) +# AC_DEFINE_UNQUOTED([TIME_T_FMT], ["$cyr_cv_format_time_t"], [...]) +# AC_DEFINE_UNQUOTED([strtotimet(a,b,c)], [$cyr_cv_parse_time_t(a,b,c)], [...]) +# +AC_DEFUN([CYR_INTTYPE],[ + dnl First, figure out what type of integer it is, by exploiting the + dnl behaviour that redefining a variable name as the same type is only + dnl a warning, but redefining it as a different type is an error. + AC_CACHE_CHECK( + [underlying integer type of `$1'], + [AS_TR_SH([cyr_cv_type_$1])], + [ + AC_LANG_PUSH([C]) + saved_CFLAGS=$CFLAGS + CFLAGS=-Wno-error + saved_CPPFLAGS=$CPPFLAGS + CPPFLAGS=-Wno-error + found=no + for t in "int" "long int" "long long int" \ + "unsigned int" "unsigned long int" "unsigned long long int" + do + AC_COMPILE_IFELSE( + [AC_LANG_PROGRAM([$2], [extern $1 foo; extern $t foo;])], + [AS_TR_SH([cyr_cv_type_$1])=$t; found=yes; break] + ) + done + AS_IF([test "x$found" != "xyes"], + [eval AS_TR_SH([cyr_cv_type_$1])=unknown]) + CFLAGS=$saved_CFLAGS + CPPFLAGS=$saved_CPPFLAGS + AC_LANG_POP([C]) + ] + ) + AS_IF([test "x$AS_TR_SH([cyr_cv_type_$1])" = "xunknown"], + [AC_MSG_ERROR([Unable to determine underlying integer type of `$1'])]) + + dnl Then, a quick table lookup to turn the known types into the + dnl appropriate format string. + AC_CACHE_CHECK( + [printf format string for `$1'], + [AS_TR_SH([cyr_cv_format_$1])], + [ + AS_CASE([$AS_TR_SH([cyr_cv_type_$1])], + ["int"], [eval AS_TR_SH([cyr_cv_format_$1])=%d], + ["long int"], [eval AS_TR_SH([cyr_cv_format_$1])=%ld], + ["long long int"], [eval AS_TR_SH([cyr_cv_format_$1])=%lld], + ["unsigned int"], [eval AS_TR_SH([cyr_cv_format_$1])=%u], + ["unsigned long int"], [eval AS_TR_SH([cyr_cv_format_$1])=%lu], + ["unsigned long long int"], [eval AS_TR_SH([cyr_cv_format_$1])=%llu], + [eval AS_TR_SH([cyr_cv_format_$1])=unknown] + ) + ] + ) + AS_IF([test "x$AS_TR_SH([cyr_cv_format_$1])" = "xunknown"], + [AC_MSG_ERROR([Unable to determine printf format string for `$1'])]) + + dnl And another quick table lookup to turn the known types into the + dnl appropriate strtol-like parse function + dnl Note that this cheats a little for int/unsigned int + AC_CACHE_CHECK( + [strtol-like parse function for `$1'], + [AS_TR_SH([cyr_cv_parse_$1])], + [ + AS_CASE([$AS_TR_SH([cyr_cv_type_$1])], + ["int"], [eval AS_TR_SH([cyr_cv_parse_$1])=strtol], + ["long int"], [eval AS_TR_SH([cyr_cv_parse_$1])=strtol], + ["long long int"], [eval AS_TR_SH([cyr_cv_parse_$1])=strtoll], + ["unsigned int"], [eval AS_TR_SH([cyr_cv_parse_$1])=strtoul], + ["unsigned long int"], [eval AS_TR_SH([cyr_cv_parse_$1])=strtoul], + ["unsigned long long int"], [eval AS_TR_SH([cyr_cv_parse_$1])=strtoull], + [eval AS_TR_SH([cyr_cv_parse_$1])=unknown] + ) + ] + ) + AS_IF([test "x$AS_TR_SH([cyr_cv_parse_$1])" = "xunknown"], + [AC_MSG_ERROR([Unable to determine strtol-like parse function for `$1'])]) +]) diff --git a/cmulocal/ipv6.m4 b/cmulocal/ipv6.m4 index fa3b0fa051..58b763a144 100644 --- a/cmulocal/ipv6.m4 +++ b/cmulocal/ipv6.m4 @@ -68,7 +68,7 @@ AC_MSG_CHECKING([whether you have ss_family in struct sockaddr_storage]) AC_CACHE_VAL(ipv6_cv_ss_family, [dnl AC_TRY_COMPILE([#include #include ], - [struct sockaddr_storage ss; int i = ss.ss_family;], + [struct sockaddr_storage ss = {0}; int i = ss.ss_family; (void) i;], [ipv6_cv_ss_family=yes], [ipv6_cv_ss_family=no])])dnl if test $ipv6_cv_ss_family = yes; then ifelse([$1], , AC_DEFINE(HAVE_SS_FAMILY,[],[Is there an ss_family in sockaddr_storage?]), [$1]) @@ -84,7 +84,7 @@ AC_MSG_CHECKING([whether you have sa_len in struct sockaddr]) AC_CACHE_VAL(ipv6_cv_sa_len, [dnl AC_TRY_COMPILE([#include #include ], - [struct sockaddr sa; int i = sa.sa_len;], + [struct sockaddr sa = {0}; int i = sa.sa_len; (void) i;], [ipv6_cv_sa_len=yes], [ipv6_cv_sa_len=no])])dnl if test $ipv6_cv_sa_len = yes; then ifelse([$1], , AC_DEFINE(HAVE_SOCKADDR_SA_LEN,[],[Does sockaddr have an sa_len?]), [$1]) diff --git a/cmulocal/nadine.m4 b/cmulocal/nadine.m4 deleted file mode 100644 index 7c3a49d198..0000000000 --- a/cmulocal/nadine.m4 +++ /dev/null @@ -1,163 +0,0 @@ -dnl nadine.m4--The nadine event library -dnl Derrick Brashear -dnl from KTH kafs and Arla - -AC_DEFUN([CMU_NADINE_INC_WHERE1], [ -saved_CPPFLAGS=$CPPFLAGS -CPPFLAGS="$saved_CPPFLAGS -I$1" -CMU_CHECK_HEADER_NOCACHE(libevent/libevent.h, -ac_cv_found_event_inc=yes, -ac_cv_found_event_inc=no) -CPPFLAGS=$saved_CPPFLAGS -]) - -AC_DEFUN([CMU_NADINE_INC_WHERE], [ - for i in $1; do - AC_MSG_CHECKING(for nadine headers in $i) - CMU_NADINE_INC_WHERE1($i) -dnl CMU_TEST_INCPATH($i, ssl) -dnl CMU_TEST_INCPATH isn't very versatile - if test "$ac_cv_found_event_inc" = "yes"; then - if test \! -f $i/libevent/libevent.h ; then - ac_cv_found_event_inc=no - fi - fi - if test "$ac_cv_found_event_inc" = "yes"; then - ac_cv_event_where_inc=$i - AC_MSG_RESULT(found) - break - else - AC_MSG_RESULT(not found) - fi - done -]) - -AC_DEFUN([CMU_NADINE_LIB_WHERE1], [ -saved_LIBS=$LIBS -LIBS="$saved_LIBS -L$1 -levent" -AC_TRY_LINK(, -[libevent_Initialize();], -[ac_cv_found_event_lib=yes], -ac_cv_found_event_lib=no) -LIBS=$saved_LIBS -]) - -AC_DEFUN([CMU_NADINE_LIB_WHERE], [ - for i in $1; do - AC_MSG_CHECKING(for event libraries in $i) - CMU_NADINE_LIB_WHERE1($i) - dnl deal with false positives from implicit link paths - CMU_TEST_LIBPATH($i, event) - if test "$ac_cv_found_event_lib" = "yes" ; then - ac_cv_event_where_lib=$i - AC_MSG_RESULT(found) - break - else - AC_MSG_RESULT(not found) - fi - done -]) - -AC_DEFUN([CMU_NADINE], [ -AC_REQUIRE([CMU_SOCKETS]) -AC_ARG_WITH(nadine, - [AS_HELP_STRING([--with-nadine=PREFIX], [Compile with nadine libevent support])], - [if test "X$with_nadine" = "X"; then - with_nadine=yes - fi]) -AC_ARG_WITH(nadine-lib, - [AS_HELP_STRING([--with-nadine-lib=DIR], [use nadine libraries in DIR])], - [if test "$withval" = "yes" -o "$withval" = "no"; then - AC_MSG_ERROR([No argument for --with-nadine-lib]) - fi]) -AC_ARG_WITH(nadine-include, - [AS_HELP_STRING([--with-nadine-include=DIR], [use nadine headers in DIR])], - [if test "$withval" = "yes" -o "$withval" = "no"; then - AC_MSG_ERROR([No argument for --with-nadine-include]) - fi]) - - if test "$with_ucdsnmp" = "no" ; then - AC_MSG_WARN([Nadine requires UCD SNMP. Disabling Nadine support]) - with_nadine=no - with_nadine_lib=no - with_nadine_include=no - fi - if test "X$with_nadine" != "X"; then - if test "$with_nadine" != "yes" -a "$with_nadine" != no; then - ac_cv_event_where_lib=$with_nadine/lib - ac_cv_event_where_inc=$with_nadine/include - fi - fi - - if test "$with_nadine" != "no"; then - if test "X$with_nadine_lib" != "X"; then - ac_cv_event_where_lib=$with_nadine_lib - fi - if test "X$ac_cv_event_where_lib" = "X"; then - CMU_NADINE_LIB_WHERE(/usr/local/lib /usr/ng/lib /usr/lib) - fi - - if test "X$with_nadine_include" != "X"; then - ac_cv_event_where_inc=$with_nadine_include - fi - if test "X$ac_cv_event_where_inc" = "X"; then - CMU_NADINE_INC_WHERE(/usr/local/include /usr/ng/include /usr/include) - fi - fi - - AC_MSG_CHECKING(whether to include nadine) - if test "X$ac_cv_event_where_lib" = "X" -a "X$ac_cv_event_where_inc" = "X"; then - ac_cv_found_event=no - AC_MSG_RESULT(no) - else - ac_cv_found_event=yes - AC_MSG_RESULT(yes) - NADINE_INC_DIR=$ac_cv_event_where_inc - NADINE_LIB_DIR=$ac_cv_event_where_lib - NADINE_INC_FLAGS="-I${NADINE_INC_DIR}" - NADINE_LIB_FLAGS="-L${NADINE_LIB_DIR} -levent" - if test "X$RPATH" = "X"; then - RPATH="" - fi - case "${host}" in - *-*-linux*) - if test "X$RPATH" = "X"; then - RPATH="-Wl,-rpath,${NADINE_LIB_DIR}" - else - RPATH="${RPATH}:${NADINE_LIB_DIR}" - fi - ;; - *-*-hpux*) - if test "X$RPATH" = "X"; then - RPATH="-Wl,+b${NADINE_LIB_DIR}" - else - RPATH="${RPATH}:${NADINE_LIB_DIR}" - fi - ;; - *-*-irix*) - if test "X$RPATH" = "X"; then - RPATH="-Wl,-rpath,${NADINE_LIB_DIR}" - else - RPATH="${RPATH}:${NADINE_LIB_DIR}" - fi - ;; - *-*-solaris2*) - if test "$ac_cv_prog_gcc" = yes; then - if test "X$RPATH" = "X"; then - RPATH="-Wl,-R${NADINE_LIB_DIR}" - else - RPATH="${RPATH}:${NADINE_LIB_DIR}" - fi - else - RPATH="${RPATH} -R${NADINE_LIB_DIR}" - fi - ;; - esac - AC_SUBST(RPATH) - fi - AC_SUBST(NADINE_INC_DIR) - AC_SUBST(NADINE_LIB_DIR) - AC_SUBST(NADINE_INC_FLAGS) - AC_SUBST(NADINE_LIB_FLAGS) - ]) - diff --git a/cmulocal/telnet.m4 b/cmulocal/telnet.m4 deleted file mode 100644 index a830590d86..0000000000 --- a/cmulocal/telnet.m4 +++ /dev/null @@ -1,179 +0,0 @@ -dnl telnet.m4--telnet special macros -dnl Derrick Brashear - -AC_DEFUN([CMU_TELNET_WHICH_TERM], [ -AC_CHECK_LIB(termlib, setupterm, [ -AC_DEFINE(HAVE_SETUPTERM,, [Define to 1 if you have the `setupterm' function.]) -AC_CHECK_LIB(c, setupterm, TCLIB="/usr/ccs/lib/libtermlib.a",TCLIB="-ltermlib","/usr/ccs/lib/libtermlib.a") -], TCLIB="-ltermcap") -]) - -AC_DEFUN([CMU_TELNET_CC_T], -[ -AC_MSG_CHECKING(for cc_t definition) -AC_CACHE_VAL(cmu_cv_cc_t_definition, [ -AC_TRY_COMPILE( -[ -#ifdef HAVE_SYS_TERMIOS_H -#include -#else -#ifdef HAVE_SYS_TERMIO_H -#include -#endif -#endif -], -[cc_t ffoo;], -cmu_cv_cc_t_definition=yes, -cmu_cv_cc_t_definition=no) -]) -if test "$cmu_cv_cc_t_definition" = "no"; then - AC_DEFINE(NO_CC_T,, [The type `cc_t' is not available]) -fi -AC_MSG_RESULT($cmu_cv_cc_t_definition) -]) - -AC_DEFUN([CMU_STREAMS], [ -if test "$ac_cv_header_sys_stropts_h" = "yes" -o "$ac_cv_header_stropts_h" = "yes"; then - AC_DEFINE(HAVE_STREAMS,, [STREAMS are available])dnl -fi -]) - -AC_DEFUN([CMU_TERMIO_MODEL], [ -if test "$ac_cv_header_sys_termio_h" = "yes" -o "$ac_cv_header_sys_termios_h" = "yes"; then - AC_DEFINE(USE_TERMIO,, [Use termios for tty configuration])dnl - if test "$ac_cv_header_sys_termios_h" = "no"; then - AC_DEFINE(SYSV_TERMIO,, [Use SysV termios])dnl - fi -fi -]) - -AC_DEFUN([CMU_TELNET_DES_STRING_TO_KEY_PROTO], [ -AC_MSG_CHECKING(for des_string_to_key prototype) -AC_CACHE_VAL(cmu_cv_des_string_to_key_proto, [ -AC_TRY_COMPILE( -[#include -typedef unsigned char Block[8]; -int des_string_to_key(char *, Block);], -[int foo = des_string_to_key(NULL, NULL);], -cmu_cv_des_string_to_key_proto=no, -cmu_cv_des_string_to_key_proto=yes) -]) -if test "$cmu_cv_des_string_to_key_proto" = yes; then - AC_DEFINE(HAVE_DES_STRING_TO_KEY_PROTO,, [define to 1 if `des_string_to_key' has a prototype])dnl -fi -AC_MSG_RESULT($cmu_cv_des_string_to_key_proto) -]) - -AC_DEFUN([CMU_TELNET_DES_KEY_SCHED_PROTO], [ -AC_MSG_CHECKING(for des_key_sched prototype) -AC_CACHE_VAL(cmu_cv_des_key_sched_proto, [ -AC_TRY_COMPILE( -[ -#include -char des_key_sched(int foo, int bar); -], -[des_key_sched(NULL, NULL);], -cmu_cv_des_key_sched_proto=no, -cmu_cv_des_key_sched_proto=yes) -]) -if test "$cmu_cv_des_key_sched_proto" = yes; then - AC_DEFINE(HAVE_DES_KEY_SCHED_PROTO,, [define to 1 if `des_key_sched' has a prototype])dnl -fi -AC_MSG_RESULT($cmu_cv_des_key_sched_proto) -]) - -AC_DEFUN([CMU_TELNET_DES_SET_RANDOM_GENERATOR_SEED_PROTO], [ -AC_MSG_CHECKING(for des_set_random_generator_seed prototype) -AC_CACHE_VAL(cmu_cv_des_set_random_generator_seed_proto, [ -AC_TRY_COMPILE( -[ -#include -char des_set_random_generator_seed(int foo, int bar); -], -[des_set_random_generator_seed(NULL, NULL);], -cmu_cv_des_set_random_generator_seed_proto=no, -cmu_cv_des_set_random_generator_seed_proto=yes) -]) -if test "$cmu_cv_des_set_random_generator_seed_proto" = yes; then - AC_DEFINE(HAVE_DES_SET_RANDOM_GENERATOR_SEED_PROTO,, [define to 1 if `des_set_random_generator_seed' has a prototype])dnl -fi -AC_MSG_RESULT($cmu_cv_des_set_random_generator_seed_proto) -]) - -AC_DEFUN([CMU_TELNET_DES_NEW_RANDOM_KEY_PROTO], [ -AC_MSG_CHECKING(for des_new_random_key prototype) -AC_CACHE_VAL(cmu_cv_des_new_random_key_proto, [ -AC_TRY_COMPILE( -[ -#include -char des_new_random_key(int foo, int bar); -], -[des_new_random_key(NULL, NULL);], -cmu_cv_des_new_random_key_proto=no, -cmu_cv_des_new_random_key_proto=yes) -]) -if test "$cmu_cv_des_new_random_key_proto" = yes; then - AC_DEFINE(HAVE_DES_NEW_RANDOM_KEY_PROTO,, [define to 1 if `des_new_random_key' has a prototype])dnl -fi -AC_MSG_RESULT($cmu_cv_des_new_random_key_proto) -]) - -AC_DEFUN([CMU_TELNET_DES_ECB_ENCRYPT_PROTO], [ -AC_MSG_CHECKING(for des_ecb_encrypt prototype) -AC_CACHE_VAL(cmu_cv_des_ecb_encrypt_proto, [ -AC_TRY_COMPILE( -[#include -typedef unsigned char Block[8]; -typedef struct { Block _; } Schedule[16]; -void des_ecb_encrypt(Block, Block, Schedule, int);], -[int foo = des_ecb_encrypt(NULL, NULL, NULL, 0);], -cmu_cv_des_ecb_encrypt_proto=no, -cmu_cv_des_ecb_encrypt_proto=yes) -]) -if test "$cmu_cv_des_ecb_encrypt_proto" = yes; then - AC_DEFINE(HAVE_DES_ECB_ENCRYPT_PROTO,, [define to 1 if `des_ecb_encrypt' has a prototype])dnl -fi -AC_MSG_RESULT($cmu_cv_des_ecb_encrypt_proto) -]) - -AC_DEFUN([CMU_TELNET_GETTYTAB], [ - if test -f "/etc/gettytab"; then - AC_CHECK_FUNCS(getent getstr) - if test "X$ac_cv_func_getent" != "Xyes"; then - AC_DEFINE(HAVE_GETTYTAB,, [gettytab support is present]) - if test "X$ac_cv_func_getstr" = "Xyes"; then - CFLAGS="$CFLAGS -Dgetstr=ggetstr" - fi - fi - else - AC_CHECK_FUNCS(cgetent) - fi - ]) - -AC_DEFUN([CMU_TELNET_ISSUE], [ - if test -f "/etc/issue.net"; then - AC_DEFINE(ISSUE_FILE, "/etc/issue.net", [path of issue file to use]) - else - if test -f "/etc/issue"; then - AC_DEFINE(ISSUE_FILE, "/etc/issue", [path of issue file to use]) - fi - fi - ]) - -AC_DEFUN([CMU_TELNET_PTYDIR], [ - - if test -d /dev/pts -o -d /dev/pty; then - case "${host}" in - *-*-irix*) - ;; - *-*-linux*) - AC_DEFINE(PTYDIR,, [Has /dev/ptX and pty allocation funcs]) - ;; - *) - AC_DEFINE(PTYDIR,, [Has /dev/ptX and pty allocation funcs]) - AC_DEFINE(STREAMSPTY,, [ptys are streams devices]) - ;; - esac - fi - ]) - diff --git a/cmulocal/ucdsnmp.m4 b/cmulocal/ucdsnmp.m4 deleted file mode 100644 index 07f37b8e2d..0000000000 --- a/cmulocal/ucdsnmp.m4 +++ /dev/null @@ -1,71 +0,0 @@ -dnl look for the (ucd|net)snmp libraries - -AC_DEFUN([CMU_UCDSNMP], [ -AC_REQUIRE([CMU_FIND_LIB_SUBDIR]) - AC_REQUIRE([CMU_SOCKETS]) - AC_ARG_WITH(snmp, - [AS_HELP_STRING([--with-snmp=DIR], [use ucd|net snmp (rooted in DIR) [yes]])], - with_snmp=$withval, with_snmp=yes) - - dnl - dnl Maintain backwards compatibility with old --with-ucdsnmp option - dnl - AC_ARG_WITH(ucdsnmp,, with_snmp=$withval,) - -if test "$with_snmp" != "no"; then - - dnl - dnl Try net-snmp first - dnl - if test "$with_snmp" = "yes"; then - AC_PATH_PROG(SNMP_CONFIG,net-snmp-config,,[/usr/local/bin:$PATH]) - else - SNMP_CONFIG="$with_snmp/bin/net-snmp-config" - fi - - if test -x "$SNMP_CONFIG"; then - AC_MSG_CHECKING(NET SNMP libraries) - - SNMP_LIBS=`$SNMP_CONFIG --agent-libs` - SNMP_PREFIX=`$SNMP_CONFIG --prefix` - - if test -n "$SNMP_LIBS" && test -n "$SNMP_PREFIX"; then - CPPFLAGS="$CPPFLAGS -I${SNMP_PREFIX}/include" - LIB_UCDSNMP=$SNMP_LIBS - AC_DEFINE(HAVE_NETSNMP,1,[Do we have Net-SNMP support?]) - AC_SUBST(LIB_UCDSNMP) - AC_MSG_RESULT(yes) - AC_CHECK_HEADERS(net-snmp/agent/agent_module_config.h,,) - else - AC_MSG_RESULT(no) - AC_MSG_WARN([Could not find the required paths. Please check your net-snmp installation.]) - fi - else - dnl - dnl Try ucd-snmp if net-snmp test failed - dnl - if test "$with_snmp" != no; then - if test -d "$with_snmp"; then - CPPFLAGS="$CPPFLAGS -I${with_snmp}/include" - LDFLAGS="$LDFLAGS -L${with_snmp}/$CMU_LIB_SUBDIR" - fi - cmu_save_LIBS="$LIBS" - AC_CHECK_LIB(snmp, sprint_objid, [ - AC_CHECK_HEADER(ucd-snmp/version.h,, with_snmp=no)], - with_snmp=no, ${LIB_SOCKET}) - LIBS="$cmu_save_LIBS" - fi - AC_MSG_CHECKING(UCD SNMP libraries) - AC_MSG_RESULT($with_snmp) - LIB_UCDSNMP="" - if test "$with_snmp" != no; then - AC_DEFINE(HAVE_UCDSNMP,1,[Do we have UCD-SNMP support?]) - LIB_UCDSNMP="-lucdagent -lucdmibs -lsnmp" - AC_CHECK_LIB(rpm, rpmdbOpen, - LIB_UCDSNMP="${LIB_UCDSNMP} -lrpm -lpopt",,-lpopt) - fi - AC_SUBST(LIB_UCDSNMP) - fi -fi - -]) diff --git a/cmulocal/visibility.m4 b/cmulocal/visibility.m4 index a7d4d8c1dd..ecf0968683 100644 --- a/cmulocal/visibility.m4 +++ b/cmulocal/visibility.m4 @@ -1,5 +1,6 @@ -# visibility.m4 serial 5 (gettext-0.18.2) -dnl Copyright (C) 2005, 2008, 2010-2012 Free Software Foundation, Inc. +# visibility.m4 +# serial 9 +dnl Copyright (C) 2005, 2008, 2010-2024 Free Software Foundation, Inc. dnl This file is free software; the Free Software Foundation dnl gives unlimited permission to copy and/or distribute it, dnl with or without modifications, as long as this notice is preserved. @@ -29,42 +30,47 @@ AC_DEFUN([gl_VISIBILITY], dnl First, check whether -Werror can be added to the command line, or dnl whether it leads to an error because of some other option that the dnl user has put into $CC $CFLAGS $CPPFLAGS. - AC_MSG_CHECKING([whether the -Werror option is usable]) - AC_CACHE_VAL([gl_cv_cc_vis_werror], [ - gl_save_CFLAGS="$CFLAGS" - CFLAGS="$CFLAGS -Werror" - AC_COMPILE_IFELSE( - [AC_LANG_PROGRAM([[]], [[]])], - [gl_cv_cc_vis_werror=yes], - [gl_cv_cc_vis_werror=no]) - CFLAGS="$gl_save_CFLAGS"]) - AC_MSG_RESULT([$gl_cv_cc_vis_werror]) + AC_CACHE_CHECK([whether the -Werror option is usable], + [gl_cv_cc_vis_werror], + [gl_saved_CFLAGS="$CFLAGS" + CFLAGS="$CFLAGS -Werror" + AC_COMPILE_IFELSE( + [AC_LANG_PROGRAM([[]], [[]])], + [gl_cv_cc_vis_werror=yes], + [gl_cv_cc_vis_werror=no]) + CFLAGS="$gl_saved_CFLAGS" + ]) dnl Now check whether visibility declarations are supported. - AC_MSG_CHECKING([for simple visibility declarations]) - AC_CACHE_VAL([gl_cv_cc_visibility], [ - gl_save_CFLAGS="$CFLAGS" - CFLAGS="$CFLAGS -fvisibility=hidden" - dnl We use the option -Werror and a function dummyfunc, because on some - dnl platforms (Cygwin 1.7) the use of -fvisibility triggers a warning - dnl "visibility attribute not supported in this configuration; ignored" - dnl at the first function definition in every compilation unit, and we - dnl don't want to use the option in this case. - if test $gl_cv_cc_vis_werror = yes; then - CFLAGS="$CFLAGS -Werror" - fi - AC_COMPILE_IFELSE( - [AC_LANG_PROGRAM( - [[extern __attribute__((__visibility__("hidden"))) int hiddenvar; - extern __attribute__((__visibility__("default"))) int exportedvar; - extern __attribute__((__visibility__("hidden"))) int hiddenfunc (void); - extern __attribute__((__visibility__("default"))) int exportedfunc (void); - void dummyfunc (void) {} - ]], - [[]])], - [gl_cv_cc_visibility=yes], - [gl_cv_cc_visibility=no]) - CFLAGS="$gl_save_CFLAGS"]) - AC_MSG_RESULT([$gl_cv_cc_visibility]) + AC_CACHE_CHECK([for simple visibility declarations], + [gl_cv_cc_visibility], + [gl_saved_CFLAGS="$CFLAGS" + CFLAGS="$CFLAGS -fvisibility=hidden" + dnl We use the option -Werror and a function dummyfunc, because on some + dnl platforms (Cygwin 1.7) the use of -fvisibility triggers a warning + dnl "visibility attribute not supported in this configuration; ignored" + dnl at the first function definition in every compilation unit, and we + dnl don't want to use the option in this case. + if test $gl_cv_cc_vis_werror = yes; then + CFLAGS="$CFLAGS -Werror" + fi + AC_COMPILE_IFELSE( + [AC_LANG_PROGRAM( + [[extern __attribute__((__visibility__("hidden"))) int hiddenvar; + extern __attribute__((__visibility__("default"))) int exportedvar; + extern __attribute__((__visibility__("hidden"))) int hiddenfunc (void); + extern __attribute__((__visibility__("default"))) int exportedfunc (void); + void dummyfunc (void); + int hiddenvar; + int exportedvar; + int hiddenfunc (void) { return 51; } + int exportedfunc (void) { return 1225736919; } + void dummyfunc (void) {} + ]], + [[]])], + [gl_cv_cc_visibility=yes], + [gl_cv_cc_visibility=no]) + CFLAGS="$gl_saved_CFLAGS" + ]) if test $gl_cv_cc_visibility = yes; then CFLAG_VISIBILITY="-fvisibility=hidden" HAVE_VISIBILITY=1 diff --git a/com_err/et/com_err.c b/com_err/et/com_err.c index 58096b64f8..7fdcb08e8d 100644 --- a/com_err/et/com_err.c +++ b/com_err/et/com_err.c @@ -86,6 +86,7 @@ extern char * INTERFACE error_message (); #endif static void +__attribute__((format(printf, 3, 0))) #if defined(__STDC__) || defined(_WINDOWS) default_com_err_proc (const char *whoami, long code, const char *fmt, va_list args) #else @@ -122,14 +123,17 @@ static void } #if defined(__STDC__) || defined(_WINDOWS) -typedef void (*errf) (const char *, long, const char *, va_list); +typedef void (*errf) (const char *, long, const char *, va_list) + __attribute__((format(printf, 3, 0))); #else typedef void (*errf) (); #endif errf com_err_hook = default_com_err_proc; -void com_err_va (whoami, code, fmt, args) +void +__attribute__((format(printf, 3, 0))) +com_err_va (whoami, code, fmt, args) const char *whoami; long code; const char *fmt; @@ -139,9 +143,11 @@ void com_err_va (whoami, code, fmt, args) } #ifndef VARARGS -EXPORTED void INTERFACE_C com_err (const char *whoami, - long code, - const char *fmt, ...) +EXPORTED void +__attribute__((format(printf, 3, 4))) +INTERFACE_C com_err (const char *whoami, + long code, + const char *fmt, ...) { #else EXPORTED void INTERFACE_C com_err (va_alist) diff --git a/com_err/et/com_err.h b/com_err/et/com_err.h index 72875bdd49..ce7fb1f733 100644 --- a/com_err/et/com_err.h +++ b/com_err/et/com_err.h @@ -87,7 +87,8 @@ #if defined(__STDC__) || defined(_WINDOWS) /* ANSI C -- use prototypes etc */ -extern void INTERFACE_C com_err (const char FAR *, long, const char FAR *, ...); +extern void INTERFACE_C com_err (const char FAR *, long, const char FAR *, ...) + __attribute__((format(printf, 3, 4))); extern char const FAR * INTERFACE error_message (long); extern void (*com_err_hook) (const char *, long, const char *, va_list); extern void (*set_com_err_hook (void (*) (const char *, long, const char *, va_list))) diff --git a/com_err/et/init_et.c b/com_err/et/init_et.c index 2652c51ce8..6f1e46997c 100644 --- a/com_err/et/init_et.c +++ b/com_err/et/init_et.c @@ -50,11 +50,6 @@ #include "error_table.h" #include "mit-sipb-copyright.h" -struct foobar { - struct et_list etl; - struct error_table et; -}; - extern struct et_list * _et_list; int init_error_table(msgs, base, count) @@ -62,20 +57,25 @@ int init_error_table(msgs, base, count) int base; int count; { - struct foobar * new_et; + struct et_list *etl; + struct error_table *et; if (!base || !count || !msgs) return 0; - new_et = (struct foobar *) malloc(sizeof(struct foobar)); - if (!new_et) - return errno; /* oops */ - new_et->etl.table = &new_et->et; - new_et->et.msgs = msgs; - new_et->et.base = base; - new_et->et.n_msgs= count; + etl = malloc(sizeof *etl); + et = malloc(sizeof *et); + if (!etl || !et) { + free(etl); + free(et); + return errno; /* oops */ + } + etl->table = et; + et->msgs = msgs; + et->base = base; + et->n_msgs = count; - new_et->etl.next = _et_list; - _et_list = &new_et->etl; + etl->next = _et_list; + _et_list = etl; return 0; } diff --git a/com_err/et/internal.h b/com_err/et/internal.h index bcb35d3b23..0010ac8963 100644 --- a/com_err/et/internal.h +++ b/com_err/et/internal.h @@ -46,7 +46,7 @@ #ifdef NEED_SYS_ERRLIST extern char const * const sys_errlist[]; -extern const int sys_nerr; +extern int sys_nerr; #endif #if defined(__STDC__) && !defined(HDR_HAS_PERROR) && !defined(_WINDOWS) diff --git a/configure.ac b/configure.ac index b62554a92e..4dc312629c 100644 --- a/configure.ac +++ b/configure.ac @@ -79,6 +79,7 @@ PKG_PROG_PKG_CONFIG AM_INIT_AUTOMAKE([-Wall -Werror -Wno-portability foreign dist-bzip2 no-installinfo subdir-objects silent-rules tar-ustar]) +AM_SILENT_RULES([yes]) AC_CONFIG_LIBOBJ_DIR([lib]) dnl Useful hook for distributions @@ -129,9 +130,16 @@ fi dnl for some reason AC_PROG_CXX can succeed even though c++ compiler is missing dnl if it is invoked after AC_PROG_CC, so instead invoke it first dnl ref: https://lists.gnu.org/archive/html/bug-autoconf/2010-05/msg00001.html +saved_CFLAGS="$CFLAGS" +saved_CXXFLAGS="$CXXFLAGS" +CFLAGS="$CFLAGS -Wno-error" +CXXFLAGS="$CXXFLAGS -Wno-error" AC_PROG_CXX AC_PROG_CC AM_PROG_CC_C_O +CFLAGS="$saved_CFLAGS" +CXXFLAGS="$saved_CXXFLAGS" + AC_PROG_LN_S AC_PROG_MAKE_SET AC_PROG_INSTALL @@ -144,6 +152,16 @@ if test $ac_cv_sys_long_file_names = no; then fi AC_C_INLINE +dnl check for libm -- sets LIBM +dnl LT_LIB_M breaks under -Werror, so work around that +saved_CFLAGS="$CFLAGS" +saved_CPPFLAGS="$CPPFLAGS" +CFLAGS="$CFLAGS -Wno-error" +CPPFLAGS="$CPPFLAGS -Wno-error" +LT_LIB_M +CFLAGS="$saved_CFLAGS" +CPPFLAGS="$saved_CPPFLAGS" + gl_VISIBILITY AH_BOTTOM([#if HAVE_VISIBILITY #define EXPORTED __attribute__((__visibility__("default"))) @@ -161,7 +179,13 @@ AH_BOTTOM([#if defined __GNUC__ && __GNUC__ > 6 ]) LT_PREREQ([2.2.6]) +saved_CFLAGS="$CFLAGS" +saved_CXXFLAGS="$CXXFLAGS" +CFLAGS="$CFLAGS -Wno-error" +CXXFLAGS="$CXXFLAGS -Wno-error" LT_INIT([disable-static]) +CFLAGS="$saved_CFLAGS" +CXXFLAGS="$saved_CXXFLAGS" AC_SUBST([LIBTOOL_DEPS]) dnl Check the size of various types @@ -171,6 +195,11 @@ AC_CHECK_SIZEOF(size_t) AC_CHECK_SIZEOF(off_t) AC_CHECK_SIZEOF(time_t) +dnl Warn if time_t is only 32 bits +AS_IF([test "$ac_cv_sizeof_time_t" -lt 8], + [AC_MSG_WARN([Your platform's time_t is less than 64 bits]) +]) + dnl Check that `long long int' is available AC_CHECK_SIZEOF(long long int) AC_CHECK_SIZEOF(unsigned long long int) @@ -181,13 +210,22 @@ else AC_MSG_ERROR(The Cyrus IMAPD requires support for long long int) fi +dnl Check the required alignment for various types +AC_CHECK_ALIGNOF(uint32_t) + dnl check for -R, etc. switch CMU_GUESS_RUNPATH_SWITCH -AC_CHECK_HEADERS(unistd.h sys/select.h sys/param.h stdarg.h) -AC_REPLACE_FUNCS(memmove strcasecmp ftruncate strerror posix_fadvise strsep memmem) -AC_CHECK_FUNCS(strlcat strlcpy strnchr getgrouplist fmemopen pselect) +saved_CFLAGS="$CFLAGS" +saved_CXXFLAGS="$CXXFLAGS" +CFLAGS="$CFLAGS -Wno-error" +CXXFLAGS="$CXXFLAGS -Wno-error" +AC_CHECK_HEADERS(unistd.h sys/select.h sys/param.h stdalign.h stdarg.h) +AC_REPLACE_FUNCS(memmove strcasecmp ftruncate strerror posix_fadvise strsep memmem memrchr) +AC_CHECK_FUNCS(strlcat strlcpy strnchr getgrouplist fmemopen pselect futimens futimes getline) AC_HEADER_DIRENT +CFLAGS="$saved_CFLAGS" +CXXFLAGS="$saved_CXXFLAGS" dnl check whether to use getpassphrase or getpass AC_CHECK_HEADERS(stdlib.h) @@ -234,7 +272,37 @@ AC_CHECK_FUNCS(timegm) AC_SUBST(CPPFLAGS) AC_SUBST(LOCALDEFS) +saved_CFLAGS="$CFLAGS" +saved_CXXFLAGS="$CXXFLAGS" +CFLAGS="$CFLAGS -Wno-error" +CXXFLAGS="$CXXFLAGS -Wno-error" AC_FUNC_VPRINTF +CFLAGS="$saved_CFLAGS" +CXXFLAGS="$saved_CXXFLAGS" + +CYR_INTTYPE([time_t], [#include ]) +AC_DEFINE_UNQUOTED([TIME_T_FMT], ["$cyr_cv_format_time_t"], + [printf formatter for time_t]) +AC_DEFINE_UNQUOTED([strtotimet(a,b,c)], [$cyr_cv_parse_time_t(a,b,c)], + [strtol-like parse function for time_t]) + +CYR_INTTYPE([size_t], [#include ]) +AC_DEFINE_UNQUOTED([SIZE_T_FMT], ["$cyr_cv_format_size_t"], + [printf formatter for size_t]) +AC_DEFINE_UNQUOTED([strtosizet(a,b,c)], [$cyr_cv_parse_size_t(a,b,c)], + [strtol-like parse function for size_t]) + +CYR_INTTYPE([off_t], [#include ]) +AC_DEFINE_UNQUOTED([OFF_T_FMT], ["$cyr_cv_format_off_t"], + [printf formatter for off_t]) +AC_DEFINE_UNQUOTED([strtoofft(a,b,c)], [$cyr_cv_parse_off_t(a,b,c)], + [strtol-like parse function for off_t]) + +CYR_INTTYPE([rlim_t], [#include ]) +AC_DEFINE_UNQUOTED([RLIM_T_FMT], ["$cyr_cv_format_rlim_t"], + [printf formatter for rlim_t]) +AC_DEFINE_UNQUOTED([strtorlimt(a,b,c)], [$cyr_cv_parse_rlim_t(a,b,c)], + [strtol-like parse function for rlim_t]) dnl function for doing each of the database backends dnl parameters: backend name, variable to set, withval @@ -347,7 +415,7 @@ dnl which means if your sqlite is in a non-default location and you need httpd dnl or object storage etc, you're suddenly also supporting it as a cyrusdb dnl backend, possibly unexpectedly. AC_ARG_WITH(sqlite, - [AS_HELP_STRING([--with-sqlite=DIR], [use SQLite (in DIR) [no]])], + [AS_HELP_STRING([--with-sqlite=DIR], [use SQLite (in DIR) (check)])], with_sqlite=$withval, with_sqlite=check) case "$with_sqlite" in @@ -519,7 +587,6 @@ AM_CONDITIONAL([WITH_CARINGO], [test "x$with_caringo" != xno]) AM_CONDITIONAL([WITH_OPENIO], [test "x$use_openio" != xno]) AM_CONDITIONAL([WITH_OBJSTR_DUMMY], [test "x$with_objectstore_dummy" != xno]) - dnl dnl Search engines - SQUAT dnl @@ -537,13 +604,20 @@ dnl dnl Search engines - Xapian dnl -xapian_flavor=none +xapian_cjk_tokens=none AC_ARG_ENABLE(xapian, [AS_HELP_STRING([--enable-xapian], [enable Xapian support])],, [enable_xapian=no]) if test "x$enable_xapian" != xno ; then + AC_ARG_VAR(RSYNC_BIN, [Location of rsync]) + AC_PATH_PROG(RSYNC_BIN, [rsync], []) + AS_IF([test -z "$RSYNC_BIN"],[ + AC_MSG_ERROR([Can't find the 'rsync' binary]) + ]) + AC_DEFINE_UNQUOTED([RSYNC_BIN], ["$RSYNC_BIN"], [Path to 'rsync' binary]) + AC_ARG_VAR(XAPIAN_CONFIG, [Location of xapian-config]) - AC_PATH_PROG(XAPIAN_CONFIG, xapian-config, []) + AC_PATH_PROGS(XAPIAN_CONFIG, [xapian-config-1.5 xapian-config], []) if test -z "$XAPIAN_CONFIG"; then AC_MSG_ERROR([Can't find Xapian library]) else @@ -559,7 +633,7 @@ if test "x$enable_xapian" != xno ; then fi dnl Xapian requires CXX11 - AX_CXX_COMPILE_STDCXX_11([noext], [mandatory]) + AX_CXX_COMPILE_STDCXX_11([], [mandatory]) dnl If AC_PROG_LIBTOOL (or the deprecated older version AM_PROG_LIBTOOL) dnl has already been expanded, enable libtool support now, otherwise add @@ -590,20 +664,35 @@ if test "x$enable_xapian" != xno ; then ORIG_CXXFLAGS=$CXXFLAGS LDFLAGS="$LDFLAGS $XAPIAN_LIBS" CXXFLAGS="$CXXFLAGS $XAPIAN_CXXFLAGS" - AC_MSG_CHECKING([for Xapian cyruslibs extensions]) + AC_MSG_CHECKING([for Xapian cjk word tokenization]) AC_LINK_IFELSE( [AC_LANG_PROGRAM( [[#include ]], - [[unsigned cjk_flags = Xapian::TermGenerator::FLAG_CJK_WORDS | Xapian::QueryParser::FLAG_CJK_WORDS; (void) cjk_flags; ]])], - [xapian_cyrusexts="yes"], - [xapian_cyrusexts="no"]) - AC_MSG_RESULT($xapian_cyrusexts) - if test $xapian_cyrusexts = yes; then - AC_DEFINE([USE_XAPIAN_CYRUS_EXTENSIONS], [], [Use Xapian cyruslibs extensions?]) - xapian_flavor=cyruslibs + [[unsigned cjk_flags = Xapian::TermGenerator::FLAG_WORD_BREAKS | Xapian::QueryParser::FLAG_WORD_BREAKS | Xapian::MSet::SNIPPET_WORD_BREAKS; (void) cjk_flags; ]])], + [xapian_cjkwords="yes"], + [xapian_cjkwords="no"]) + if test $xapian_cjkwords = yes; then + AC_DEFINE([USE_XAPIAN_WORD_BREAKS], [], [Use Xapian CJK word tokenizer, rather than n-grams?]) + xapian_cjk_tokens=words + AC_MSG_RESULT($xapian_cjkwords) else - AC_MSG_NOTICE([Your Xapian does not support the Cyrus extensions for Xapian. Consider installing cyruslibs Xapian]) - xapian_flavor=vanilla + dnl Xapian upstream version 1.5 used different flag names to enable + dnl word break tokenization until March 2023. + dnl See https://github.com/xapian/xapian/commit/13295e9142f56911d4876fb92271df348759c34b + AC_LINK_IFELSE( + [AC_LANG_PROGRAM( + [[#include ]], + [[unsigned cjk_flags = Xapian::TermGenerator::FLAG_CJK_WORDS | Xapian::QueryParser::FLAG_CJK_WORDS | Xapian::MSet::SNIPPET_CJK_WORDS; (void) cjk_flags; ]])], + [xapian_cjkwords="yes"], + [xapian_cjkwords="no"]) + AC_MSG_RESULT($xapian_cjkwords) + if test $xapian_cjkwords = yes; then + AC_DEFINE([USE_XAPIAN_CJK_WORDS], [], [Use Xapian CJK word tokenizer, rather than n-grams?]) + xapian_cjk_tokens=words + else + AC_MSG_NOTICE([Your Xapian does not support CJK word tokenization. CJK ngram tokenization will be used instead.]) + xapian_cjk_tokens=ngrams + fi fi LDFLAGS=$ORIG_LDFLAGS CXXFLAGS=$ORIG_CXXFLAGS @@ -614,79 +703,143 @@ if test "x$enable_xapian" != xno ; then AC_SUBST(XAPIAN_VERSION) fi AM_CONDITIONAL([USE_XAPIAN], [test "${enable_xapian}" != "no"]) -AC_DEFINE_UNQUOTED([XAPIAN_FLAVOR], ["$xapian_flavor"], [Which Xapian are we running on?]) - +AC_DEFINE_UNQUOTED([XAPIAN_CJK_TOKENS], ["$xapian_cjk_tokens"], [Which Xapian CJK tokenzation are we using?]) +dnl +dnl Search - Squatter +dnl +if test "${enable_squat}" != "no" -o "${enable_xapian}" != "no"; then + AC_CHECK_FUNC(getopt_long, [], [AC_MSG_ERROR([squatter executable requires getopt_long])]) +fi AM_CONDITIONAL([SQUATTER], [test "${enable_squat}" != "no" -o "${enable_xapian}" != "no" ]) AC_ARG_ENABLE(sieve, - [AS_HELP_STRING([--disable-sieve], [disable Sieve support])],,[enable_sieve="yes";]) -AC_ARG_ENABLE(pcre, - [AS_HELP_STRING([--disable-pcre], [disable PCRE library])],[cyrus_cv_pcre_utf8="$enableval"]) + [AS_HELP_STRING([--disable-sieve], [disable Sieve support])],,[enable_sieve="yes";]) if test "$enable_sieve" != "no"; then - AC_DEFINE(USE_SIEVE,[],[Build in Sieve support?]) + AC_DEFINE(USE_SIEVE,[],[Build in Sieve support?]) - dnl Sieve configure stuff - AC_PROG_YACC - AM_PROG_LEX + if test "x$HAVE_SQLITE" != x1; then + AC_MSG_ERROR([Need sqlite3 for sieve]) + else + use_sqlite="yes" + fi - if test -z "$ac_cv_prog_YACC"; then - AC_MSG_ERROR([Sieve requires bison/byacc/yacc, but none is installed]) - fi + dnl Sieve configure stuff + AC_PROG_YACC + AM_PROG_LEX - if test -z "$ac_cv_prog_LEX"; then - AC_MSG_ERROR([Sieve requires flex/lex, but none is installed]) - fi + if test -z "$ac_cv_prog_YACC"; then + AC_MSG_ERROR([Sieve requires bison/byacc/yacc, but none is installed]) + fi + + if test -z "$ac_cv_prog_LEX"; then + AC_MSG_ERROR([Sieve requires flex/lex, but none is installed]) + fi fi AM_CONDITIONAL([SIEVE], [test "${enable_sieve}" != "no"]) -if test "$enable_pcre" != "no"; then - AC_CHECK_HEADER(pcreposix.h) - if test "$ac_cv_header_pcreposix_h" = "yes"; then - AC_MSG_CHECKING(for utf8 enabled pcre) - AC_CACHE_VAL(cyrus_cv_pcre_utf8, AC_TRY_CPP([#include -#ifndef REG_UTF8 -#include -#endif],cyrus_cv_pcre_utf8=yes,cyrus_cv_pcre_utf8=no)) - AC_MSG_RESULT($cyrus_cv_pcre_utf8) - else - cyrus_cv_pcre_utf8="no" - fi -fi +AC_ARG_ENABLE(pcre, + [AS_HELP_STRING([--disable-pcre], [disable PCRE library])],[cyrus_cv_pcre_utf8="$enableval"]) +AC_ARG_ENABLE(pcre2, + [AS_HELP_STRING([--disable-pcre2], [disable PCRE2 library])],[cyrus_cv_pcre2_utf8="$enableval"]) -if test "$cyrus_cv_pcre_utf8" = "yes"; then - LIBS="$LIBS -lpcre -lpcreposix"; - AC_DEFINE(ENABLE_REGEX, [], [Do we have a regex library?]) - AC_DEFINE(HAVE_PCREPOSIX_H, [], [Do we have usable pcre library?]) +if test "$enable_pcre" != "no"; then + PKG_CHECK_MODULES([PCRE], + [libpcreposix libpcre], + [ AC_MSG_CHECKING(for utf8 enabled pcre) + saved_CFLAGS="$CFLAGS" + saved_LIBS="$LIBS" + CFLAGS="$CFLAGS PCRE_CFLAGS" + LIBS="$LIBS PCRE_LIBS" + AC_CACHE_VAL(cyrus_cv_pcre_utf8, + AC_TRY_CPP([ #include + #ifndef REG_UTF8 + #include + #endif + ], + [cyrus_cv_pcre_utf8=yes], + [cyrus_cv_pcre_utf8=no])) + AC_MSG_RESULT($cyrus_cv_pcre_utf8) + CFLAGS="$saved_CFLAGS" + LIBS="$saved_LIBS" + ], + [cyrus_cv_pcre_utf8="no"]) +fi + +if test "$enable_pcre2" != "no"; then + PKG_CHECK_MODULES([PCRE2], + [libpcre2-posix libpcre2-8], + [ AC_MSG_CHECKING(for utf8 enabled pcre2) + saved_CFLAGS="$CFLAGS" + saved_LIBS="$LIBS" + CFLAGS="$CFLAGS PCRE2_CFLAGS" + LIBS="$LIBS PCRE2_LIBS" + AC_CACHE_VAL(cyrus_cv_pcre2_utf8, + AC_TRY_CPP([ #include + #ifndef REG_UTF + #include + #endif + ], + [cyrus_cv_pcre2_utf8=yes], + [cyrus_cv_pcre2_utf8=no])) + AC_MSG_RESULT($cyrus_cv_pcre2_utf8) + CFLAGS="$saved_CFLAGS" + LIBS="$saved_LIBS" + ], + [cyrus_cv_pcre2_utf8="no"]) +fi + +LIB_REGEX= +CFLAGS_REGEX= +if test "$cyrus_cv_pcre2_utf8" = "yes"; then + CFLAGS_REGEX="$PCRE2_CFLAGS" + LIB_REGEX="$PCRE2_LIBS" + AC_DEFINE(ENABLE_REGEX, [], [Do we have a regex library?]) + AC_DEFINE(HAVE_PCRE2POSIX_H, [], [Do we have usable pcre2 library?]) + cyrus_cv_pcre_utf8="no" +elif test "$cyrus_cv_pcre_utf8" = "yes"; then + CFLAGS_REGEX="$PCRE_CFLAGS" + LIB_REGEX="$PCRE_LIBS"; + AC_DEFINE(ENABLE_REGEX, [], [Do we have a regex library?]) + AC_DEFINE(HAVE_PCREPOSIX_H, [], [Do we have usable pcre library?]) + cyrus_cv_pcre2_utf8="no" else - AC_CHECK_HEADERS(rxposix.h) - if test "$ac_cv_header_rxposix_h" = "yes"; then - LIBS="$LIBS -lrx" - AC_DEFINE(ENABLE_REGEX, [], [Do we have a regex library?]) - else - AC_SEARCH_LIBS(regcomp, regex, - AC_DEFINE(ENABLE_REGEX, [], [Do we have a regex library?]), []) - fi + AC_CHECK_HEADERS(rxposix.h) + if test "$ac_cv_header_rxposix_h" = "yes"; then + LIB_REGEX="-lrx" + AC_DEFINE(ENABLE_REGEX, [], [Do we have a regex library?]) + else + AC_SEARCH_LIBS(regcomp, regex, + AC_DEFINE(ENABLE_REGEX, [], [Do we have a regex library?]), []) + fi fi +AC_SUBST(LIB_REGEX) +AC_SUBST(CFLAGS_REGEX) +LIBS="$LIBS $LIB_REGEX" +CFLAGS="$CFLAGS $CFLAGS_REGEX" dnl dnl see if we're compiling with SRS dnl AC_MSG_CHECKING([whether to use SRS]) AC_ARG_ENABLE(srs, - [AS_HELP_STRING([--enable-srs], [enable SRS support])],, + [AS_HELP_STRING([--enable-srs], [enable Sender Rewriting Scheme support])],, [enable_srs=no]) +AC_MSG_RESULT($enable_srs) +AM_CONDITIONAL([USE_SRS], [test "${enable_srs}" != "no"]) if test "x$enable_srs" != xno; then AC_CHECK_HEADER([srs2.h],, [AC_MSG_ERROR([cannot find SRS headers])]) AC_CHECK_LIB(srs2, srs_forward,, [AC_MSG_ERROR([cannot find SRS lib])]) LIBS="$LIBS -lsrs2" + AC_DEFINE(USE_SRS,[],[Build with srs functionality]) +else + AC_MSG_NOTICE([Outgoing messages will not support the Sender Rewriting Scheme. Consider installing libsrs2 and --enable-srs]) fi -AM_CONDITIONAL([USE_SRS], [test "${enable_srs}" != "no"]) dnl look for an option to disable sign-comparison warnings (needed for dnl flex-generated sieve sources when building with -Werror) @@ -781,7 +934,7 @@ AC_CACHE_VAL(cyrus_cv_sys_nonblock,AC_TRY_LINK([#include #include #ifndef FNDELAY #define FNDELAY O_NDELAY -#endif],[fcntl(0, F_GETFL, 0)&FNDELAY], +#endif],[(void)(fcntl(0, F_GETFL, 0) & FNDELAY)], cyrus_cv_sys_nonblock=fcntl,cyrus_cv_sys_nonblock=ioctl)) AM_CONDITIONAL([NONBLOCK_FCNTL], [test "$cyrus_cv_sys_nonblock" = "fcntl"]) AC_MSG_RESULT($cyrus_cv_sys_nonblock) @@ -790,6 +943,7 @@ AC_MSG_CHECKING(timezone GMT offset method) AC_CACHE_VAL(cyrus_cv_struct_sys_gmtoff,AC_TRY_COMPILE([ #include ],[struct tm tm; tm.tm_gmtoff = 0; +(void) tm; ],cyrus_cv_struct_sys_gmtoff=tm,cyrus_cv_struct_sys_gmtoff=gmtime)) AM_CONDITIONAL([GMTOFF_TM], [test "$cyrus_cv_struct_sys_gmtoff" = "tm"]) AC_MSG_RESULT($cyrus_cv_struct_sys_gmtoff) @@ -1059,8 +1213,6 @@ AC_SUBST(LDAP_LDFLAGS) AC_SUBST(LDAP_LIBS) AM_CONDITIONAL([HAVE_LDAP], [test "$have_ldap" = "yes"]) -AM_CONDITIONAL([PTCLIENT], [test "x$enable_afs" = "xyes" -o "$have_ldap" = "yes"]) - AC_ARG_WITH([clamav], AS_HELP_STRING([--without-clamav], [ignore presence of ClamAV and disable it])) AS_IF([test "x$with_clamav" != "xno"], @@ -1121,8 +1273,7 @@ fi if test "$with_krb" != "no"; then dnl Do we need DES for kerberos? AC_ARG_WITH(krbdes, - [AS_HELP_STRING([--with-krbdes], [use Kerberos DES implementation [yes]])], - with_krbdes="$withval", with_krbdes="yes") + [AS_HELP_STRING([--without-krbdes], [disable Kerberos DES implementation])],, with_krbdes="yes") if test "$with_krbdes" = "yes"; then AC_CHECK_LIB(des,des_ecb_encrypt, if test "$with_statickrb" = "yes"; then @@ -1326,6 +1477,9 @@ if test "$with_zlib" != "no"; then AC_DEFINE(HAVE_ZLIB,[],[Do we have zlib?]) ZLIB="-lz" HAVE_ZLIB=1 + + AC_CHECK_LIB(z, deflatePending, + AC_DEFINE(HAVE_DEFLATE_PENDING,[], [Do we have deflatePending()?])) else CPPFLAGS=$save_CPPFLAGS LDFLAGS=$save_LDFLAGS @@ -1371,6 +1525,7 @@ AC_ARG_WITH([libcap], with_libcap="${withval}") have_libcap=no +LIBCAP_LIBS="" case $host_os in linux*) if test "x$with_libcap" = "xyes"; then @@ -1378,13 +1533,14 @@ linux*) AC_CHECK_HEADERS([sys/capability.h sys/prctl.h], , have_libcap=no) if test "$have_libcap" = "yes"; then AC_DEFINE(HAVE_LIBCAP, [], [Do we have libcap system capabilities handling (Linux systems only)?]) - LIBS="$LIBS -lcap" + LIBCAP_LIBS="-lcap" fi fi ;; *) ;; esac +AC_SUBST(LIBCAP_LIBS) AC_MSG_CHECKING(for libcap) AC_MSG_RESULT($have_libcap) @@ -1392,15 +1548,28 @@ AC_MSG_RESULT($have_libcap) dnl dnl Check for jansson library, needed for JSON support dnl -PKG_CHECK_MODULES([JANSSON], [jansson >= 2.3], +PKG_CHECK_MODULES([JANSSON], [jansson >= 2.10], [ AC_DEFINE(HAVE_JANSSON, [], [Do we have Jansson?]) with_jansson=yes ], - with_jansson=no) + AC_MSG_ERROR([libjansson is required])) dnl call AC_SUBST macro to support pkg-config version older than 0.24 AC_SUBST([JANSSON_LIBS]) AC_SUBST([JANSSON_CFLAGS]) AM_CONDITIONAL([USE_JANSSON], [test "x$with_jansson" = xyes]) +dnl +dnl Check for ICU library, needed for charset conversions and non-gregorian +dnl rscale conversions +dnl +with_icu4c=no +PKG_CHECK_MODULES([ICU], [icu-i18n >= 55 icu-uc >= 55], [ + AC_DEFINE(HAVE_ICU, [], [Build with ICU support]) + with_icu4c=yes + ], + [AC_MSG_ERROR([Need ICU4C])]) +AC_SUBST([ICU_LIBS]) +AC_SUBST([ICU_CFLAGS]) + dnl dnl Check for zeroskip library, needed for zeroskip support dnl @@ -1425,23 +1594,47 @@ AC_ARG_WITH([chardet], [], [with_chardet=yes]) AS_IF([test "x$with_chardet" = "xyes"], - [ PKG_CHECK_MODULES([LIBCHARDET], [chardet], + [ PKG_CHECK_MODULES([LIBCHARDET], [chardet >= 1.0.5], [ AC_DEFINE(HAVE_LIBCHARDET, [], [Build with chardet library support]) with_chardet=yes ], with_chardet=no)]) +AS_IF([test "x$with_chardet" != "xyes"], + [AC_MSG_NOTICE([JMAP will not have character set detection support. Consider installing chardet >= 1.0.5])]) AC_SUBST([LIBCHARDET_LIBS]) AC_SUBST([LIBCHARDET_CFLAGS]) AM_CONDITIONAL([USE_LIBCHARDET], [test "x$with_chardet" = xyes]) dnl -dnl Set pidfile location +dnl Check for cld2 library dnl -AC_ARG_WITH(pidfile, - [AS_HELP_STRING([--with-pidfile=DIR], [pidfile in DIR [/var/run/cyrus-master.pid]])], - [MASTERPIDFILE="$withval"], - [MASTERPIDFILE="/var/run/cyrus-master.pid"]) -MASTERPIDFILE="\"$MASTERPIDFILE\"" -AC_DEFINE_UNQUOTED(MASTER_PIDFILE, $MASTERPIDFILE,[Name of the pidfile for master]) +AC_ARG_WITH([cld2], + [AS_HELP_STRING([--without-cld2], [ignore presence of cld2 library and disable it])], + [], + [with_cld2=yes]) +AS_IF([test "x$with_cld2" = "xyes"], + [ PKG_CHECK_MODULES([CLD2], [cld2], + [ AC_DEFINE(HAVE_CLD2, [], [Build with cld2 library support]) + with_cld2=yes ], + with_cld2=no)]) +AC_SUBST([CLD2_LIBS]) +AC_SUBST([CLD2_CFLAGS]) +AM_CONDITIONAL([HAVE_CLD2], [test "x$with_cld2" = xyes]) + +dnl +dnl Check for guesstz library +dnl +AC_ARG_WITH([guesstz], + [AS_HELP_STRING([--without-guesstz], [ignore presence of guesstz library and disable it])], + [], + [with_guesstz=yes]) +AS_IF([test "x$with_guesstz" = "xyes"], + [ PKG_CHECK_MODULES([GUESSTZ], [guesstz], + [ AC_DEFINE(HAVE_GUESSTZ, [], [Build with guesstz library support]) + with_guesstz=yes ], + with_guesstz=no)]) +AC_SUBST([GUESSTZ_LIBS]) +AC_SUBST([GUESSTZ_CFLAGS]) +AM_CONDITIONAL([HAVE_GUESSTZ], [test "x$with_guesstz" = xyes]) dnl dnl see if we're compiling with autocreate support @@ -1485,6 +1678,13 @@ if test "x$enable_murder" = "xyes"; then AC_DEFINE(USE_MURDER,[],[Build with Murder functionality]) fi +dnl +dnl See if we have an xxd available +dnl +AC_ARG_VAR([XXD], [Location of xxd]) +AC_PATH_PROG([XXD],[xxd]) +AM_CONDITIONAL([HAVE_XXD], [test -n "$XXD"]) + dnl dnl see if we're compiling with HTTP support dnl @@ -1494,13 +1694,6 @@ AM_CONDITIONAL([HTTPD], [test "$enable_http" != "no"]) HTTP_CPPFLAGS= HTTP_LIBS= -with_nghttp2=no -with_wslay=no -with_xml2=no -with_ical=no -with_icu4c=no -with_shapelib=no -with_brotli=no if test "$enable_http" != no; then dnl dnl make sure all the modules we need are present @@ -1512,6 +1705,8 @@ dnl AC_DEFINE(WITH_DAV,[],[Build *DAV support into httpd?]) fi + AS_IF([test -z "$XXD"], [AC_MSG_ERROR([Need xxd for http])]) + PKG_CHECK_MODULES([XML2], [libxml-2.0], [ AC_DEFINE(HAVE_XML2, [], [Build with libxml support]) LIBS="$LIBS ${XML2_LIBS}" @@ -1528,88 +1723,48 @@ dnl [Do we have support for xmlBufferDetach()?])) ]) - PKG_CHECK_MODULES([ICAL], [libical], [ + PKG_CHECK_MODULES([ICAL], [libical >= 3.0.10], [ AC_DEFINE(HAVE_ICAL,[],[Build CalDAV support into httpd?]) LIBS="$LIBS ${ICAL_LIBS}" with_ical=yes ], - AC_MSG_ERROR([Need libical for http])) - - AC_CHECK_DECLS([icalproperty_get_parent, ICAL_STATUS_DELETED, - icalrecur_freq_to_string, icalrecur_weekday_to_string], - [], [], [[#include ]]) - - AC_CHECK_MEMBER(icaltimetype.is_utc, - AC_DEFINE(ICALTIME_HAS_IS_UTC,[], - [Does icaltimetype have is_utc field?]), - [], [#include ]) - - AC_CHECK_LIB(ical, icalproperty_new_tzuntil, - AC_DEFINE(HAVE_TZDIST_PROPS,[], - [Do we have built-in support for TZdist props?])) - - AC_CHECK_LIB(ical, icalproperty_new_acknowledged, - AC_DEFINE(HAVE_VALARM_EXT_PROPS,[], - [Do we have built-in support for VALARM extensions props?])) - - AC_CHECK_LIB(ical, icalproperty_new_name, - AC_DEFINE(HAVE_RFC7986_PROPS,[], - [Do we have built-in support for RFC7986 props?])) - - AC_CHECK_LIB(ical, icalparameter_new_iana, [ - AC_DEFINE(HAVE_IANA_PARAMS,[], - [Do we have support for IANA params?]) - - AC_CHECK_LIB(ical, icalparameter_new_schedulestatus, - AC_DEFINE(HAVE_SCHEDULING_PARAMS,[], - [Do we have built-in support for scheduling params?])) - - AC_CHECK_LIB(ical, icalparameter_new_managedid, - AC_DEFINE(HAVE_MANAGED_ATTACH_PARAMS,[], - [Do we have built-in support for managed attachment params?])) + AC_MSG_ERROR([Need libical 3.0.10 for http])) + + dnl libical may have been built without RSCALE support + AC_CHECK_LIB(ical, icalrecurrencetype_rscale_is_supported, + [AC_DEFINE(HAVE_RSCALE,[], [Build RSCALE support into httpd?])], + [AC_MSG_NOTICE([Your version of libical can not support non-gregorian recurrences])]) + + dnl icalcomponent_clone will be new in libical 3.1.0 + AC_CHECK_LIB(ical, icalcomponent_clone, + AC_DEFINE(HAVE_NEW_CLONE_API,[], + [Do we have support for new clone API?])) + + dnl ical_set_invalid_rrule_handling_setting will be new in libical 3.1.0 + AC_CHECK_LIB(ical, ical_set_invalid_rrule_handling_setting, + AC_DEFINE(HAVE_INVALID_RRULE_HANDLING,[], + [Do we have support for controlling invalid RRULE handline?])) + + dnl icalcomponent_get_component_name will be new in libical 3.1.0 + AC_CHECK_LIB(ical, icalcomponent_get_component_name, + AC_DEFINE(HAVE_GET_COMPONENT_NAME,[], + [Do we have support for fetching X- component names?])) + + dnl Check if libical parser allows setting CONTROL char handling + AC_CHECK_LIB(ical, icalparser_get_ctrl, + [ + AC_DEFINE(HAVE_ICALPARSER_CTRL,[], [Can we omit CONTROL characters?]) + have_ical_ctrl=yes + ], + have_ical_ctrl=no) + + dnl Check for libicalvcard + AC_CHECK_LIB(icalvcard, vcardcomponent_new, [ + AC_DEFINE(HAVE_LIBICALVCARD,[],[Do we have the icalvcard library?]) + LIBS="$LIBS -licalvcard" + with_icalvcard=yes ], - AC_MSG_NOTICE([Your version of libical can not support scheduling or managed attachments. Consider upgrading to libical >= 0.48])) - - AC_CHECK_LIB(ical, icaltimezone_set_builtin_tzdata, - AC_DEFINE(HAVE_TZ_BY_REF,[], - [Build timezones by reference support into httpd?]), - AC_MSG_NOTICE([Your version of libical can not support timezones by reference. Consider upgrading to libical >= 1.0.1])) - - AC_CHECK_LIB(ical, icalcomponent_new_vavailability, - AC_DEFINE(HAVE_VAVAILABILITY,[], - [Build VAVAILABILITY support into httpd?]), - AC_MSG_NOTICE([Your version of libical can not support availability. Consider upgrading to libical >= 1.0.1])) - - AC_CHECK_LIB(ical, icalcomponent_new_vvoter, - AC_DEFINE(HAVE_VPOLL,[], [Build VPOLL support into httpd?]), - AC_MSG_NOTICE([Your version of libical can not support consensus scheduling. Consider upgrading to libical >= 2.0])) - - AC_CHECK_LIB(ical, icalrecurrencetype_rscale_is_supported, [ - PKG_CHECK_MODULES([ICU4C], [icu-i18n], [ - AC_DEFINE(HAVE_RSCALE,[], [Build RSCALE support into httpd?]) - LIBS="$LIBS ${ICU4C_LIBS}" - with_icu4c=yes - ], - AC_MSG_ERROR([Need ICU4C for RSCALE support in httpd])) - ], - AC_MSG_NOTICE([Your version of libical can not support non-gregorian recurrences. Consider upgrading to libical >= 2.0])) - - AC_CHECK_LIB(ical, icalcomponent_new_vpatch, - AC_DEFINE(HAVE_VPATCH,[], - [Build VPATCH support into httpd?]), - AC_MSG_NOTICE([Your version of libical can not support per-user alarms on shared resources. Consider upgrading to libical >= 3.0])) - - AC_CHECK_LIB(ical, icalrecur_iterator_set_start, - AC_DEFINE(HAVE_RECUR_ITERATOR_START,[], - [Do we have support for setting start point of recurrences?])) - - PKG_CHECK_VAR(CYRUS_TIMEZONES_ZONEINFO_DIR, cyrus-timezones, zoneinfo_dir, [ - AC_SUBST(CYRUS_TIMEZONES_ZONEINFO_DIR) - with_cyrus_timezones=yes - CYRUS_TIMEZONES_CFLAGS=-DCYRUS_TIMEZONES_ZONEINFO_DIR="\\\"$CYRUS_TIMEZONES_ZONEINFO_DIR\\\"" - ], - AC_MSG_NOTICE([Cyrus timezones could not be found. Consider installing the cyrus-timezones package for httpd])) - + with_icalvcard=no) dnl Don't bother checking for DKIM until iSchedule gains traction dnl PKG_CHECK_MODULES([DKIM], [opendkim >= 2.7.0], @@ -1619,35 +1774,64 @@ dnl [Build DKIM support into iSchedule?]), dnl AC_MSG_WARN([Your version of OpenDKIM can not support iSchedule. Consider patching OpenDKIM with contrib/dkim_canon_ischedule.patch])), dnl AC_MSG_WARN([Your version of OpenDKIM can not support iSchedule. Consider upgrading to OpenDKIM >= 2.7.0])) - PKG_CHECK_MODULES([NGHTTP2], [libnghttp2 >= 1.5], [ - AC_DEFINE(HAVE_NGHTTP2,[], - [Build HTTP/2 support into httpd?]) - AC_CHECK_DECLS([NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL], - [], [], [[#include ]]) - with_nghttp2=yes - ], - AC_MSG_NOTICE([httpd will not have support for HTTP/2. Consider installing libnghttp2 >= 1.5])) - + AC_ARG_WITH(nghttp2, [AS_HELP_STRING([--without-nghttp2], [disable HTTP/2 support (check)])],,[with_nghttp2="check"]) + if test "x$with_nghttp2" = "xyes" -o "x$with_nghttp2" = "xcheck"; then + PKG_CHECK_MODULES([NGHTTP2], [libnghttp2 >= 1.34.0], [ + AC_DEFINE(HAVE_NGHTTP2,[], + [Build HTTP/2 support into httpd?]) + with_nghttp2=yes + ], [ + if test "x$with_nghttp2" = "xyes"; then + AC_MSG_ERROR([HTTP/2 explicitly requested, but libnghttp2 was not found]) + fi + AC_MSG_NOTICE([httpd will not have support for HTTP/2. Consider installing libnghttp2 >= 1.5]) + with_nghttp2=no + ]) + fi - PKG_CHECK_MODULES([WSLAY], [libwslay >= 1.1.1], [ - AC_DEFINE(HAVE_WSLAY,[], - [Build WebSockets support into httpd?]) - with_wslay=yes - ], - AC_MSG_NOTICE([httpd will not have support for WebSockets. Consider installing libwslay])) + AC_ARG_WITH(wslay, [AS_HELP_STRING([--without-wslay], [disable WebSockets support (check)])],,[with_wslay="check"]) + if test "x$with_wslay" = "xyes" -o "x$with_wslay" = "xcheck"; then + PKG_CHECK_MODULES([WSLAY], [libwslay >= 1.1.1], [ + AC_DEFINE(HAVE_WSLAY,[], + [Build WebSockets support into httpd?]) + with_wslay=yes + ], [ + if test "x$with_wslay" = "xyes"; then + AC_MSG_ERROR([WebSockets explicitly requested, but libwslay was not found]) + fi + AC_MSG_NOTICE([httpd will not have support for WebSockets. Consider installing libwslay]) + with_wslay=no + ]) + fi - PKG_CHECK_MODULES([BROTLI], [libbrotlienc], [ - AC_DEFINE(HAVE_BROTLI,[], - [Build Brotli compression support into httpd?]) - with_brotli=yes - ], - AC_MSG_NOTICE([httpd will not have support for Brotli compression. Consider installing libbrotli])) + AC_ARG_WITH(brotli, [AS_HELP_STRING([--without-brotli], [disable brotli compression (check)])]) + if test "x$with_brotli" = xyes -o "${with_brotli+x}" != x; then + PKG_CHECK_MODULES([BROTLI], [libbrotlienc], [ + AC_DEFINE(HAVE_BROTLI,[], + [Build Brotli compression support into httpd?]) + with_brotli=yes + ], [ + if test "x$with_brotli" = xyes; then + AC_MSG_ERROR([brotli compression explicitly requested, but libbrotlienc was not found]) + fi + with_brotli="no (libbrotlienc not found)" + ]) + fi - dnl httpd needs libmath in a few places - dnl XXX really should check for this properly but AC_SEARCH_LIBS/AC_CHECK_LIB - dnl XXX break under -Werror(!) - LIB_MATH="-lm" + AC_ARG_WITH(zstd, [AS_HELP_STRING([--without-zstd], [disable Zstandard compression (check)])]) + if test "x$with_zstd" = xyes -o "${with_zstd+x}" != x; then + PKG_CHECK_MODULES([ZSTD], [libzstd >= 1.4.0], [ + AC_DEFINE(HAVE_ZSTD,[], + [Build Zstandard compression support into httpd?]) + with_zstd=yes + ], [ + if test "x$with_zstd" = xyes; then + AC_MSG_ERROR([Zstandard compression explicitly requested, but libzstd was not found]) + fi + with_zstd="no (libzstd >= 1.4.0 not found)" + ]) + fi PKG_CHECK_MODULES([SHAPELIB], [shapelib >= 1.3.0],[ AC_DEFINE(HAVE_SHAPELIB,[], @@ -1657,8 +1841,17 @@ dnl AC_MSG_WARN([Your version of OpenDKIM can not support iSchedu ], AC_MSG_NOTICE([tzdist will not have geolocation support. Consider installing shapelib])) - HTTP_CPPFLAGS="${XML2_CFLAGS} ${SQLITE3_CFLAGS} ${ICAL_CFLAGS} ${GLIB_CFLAGS} ${JANSSON_CFLAGS} ${NGHTTP2_CFLAGS} ${WSLAY_CFLAGS} ${BROTLI_CFLAGS} ${SHAPELIB_CFLAGS} ${CYRUS_TIMEZONES_CFLAGS}" - HTTP_LIBS="${XML2_LIBS} ${SQLITE3_LIBS} ${ICAL_LIBS} ${GLIB_LIBS} ${JANSSON_LIBS} ${NGHTTP2_LIBS} ${WSLAY_LIBS} ${BROTLI_LIBS} ${SHAPELIB_LIBS} ${LIB_MATH}" + HTTP_CPPFLAGS="${XML2_CFLAGS} ${SQLITE3_CFLAGS} ${ICAL_CFLAGS} ${GLIB_CFLAGS} ${JANSSON_CFLAGS} ${NGHTTP2_CFLAGS} ${WSLAY_CFLAGS} ${BROTLI_CFLAGS} ${ZSTD_CFLAGS} ${SHAPELIB_CFLAGS}" + HTTP_LIBS="${XML2_LIBS} ${SQLITE3_LIBS} ${ICAL_LIBS} ${GLIB_LIBS} ${JANSSON_LIBS} ${NGHTTP2_LIBS} ${WSLAY_LIBS} ${BROTLI_LIBS} ${ZSTD_LIBS} ${SHAPELIB_LIBS} ${LIBM}" +else + with_nghttp2="no (httpd is not built)" + with_wslay="no (httpd is not built)" + with_brotli="no (httpd is not built)" + with_zstd="no (httpd is not built)" + with_xml2="no (httpd is not built)" + with_ical="no (httpd is not built)" + with_icalvcard="no (httpd is not built)" + with_shapelib="no (httpd is not built)" fi AC_SUBST(HTTP_CPPFLAGS) AC_SUBST(HTTP_LIBS) @@ -1700,7 +1893,7 @@ dnl dnl see if we're compiling replication support programs dnl AC_ARG_ENABLE(replication, - [AS_HELP_STRING([--enable-replication], [enable replication support (experimental)])],,[enable_replication="no";]) + [AS_HELP_STRING([--enable-replication], [enable replication support])],,[enable_replication="no";]) AM_CONDITIONAL([REPLICATION], [test "$enable_replication" != no]) if test "x$enable_replication" = "xyes"; then AC_DEFINE(USE_REPLICATION,[],[Build with replication functionality]) @@ -1912,14 +2105,16 @@ dnl Make sure perl modules are in the build directory (which isn't necessarily dnl the source directory) AC_CONFIG_LINKS([perl/sieve/managesieve/managesieve.pm:perl/sieve/managesieve/managesieve.pm]) AC_CONFIG_FILES([perl/sieve/managesieve/MANIFEST]) - AC_CONFIG_LINKS([perl/imap/Cyrus/HeaderFile.pm:perl/imap/Cyrus/HeaderFile.pm]) + AC_CONFIG_LINKS([perl/imap/Cyrus/AccountSync.pm:perl/imap/Cyrus/AccountSync.pm]) AC_CONFIG_LINKS([perl/imap/Cyrus/CacheFile.pm:perl/imap/Cyrus/CacheFile.pm]) - AC_CONFIG_LINKS([perl/imap/Cyrus/IndexFile.pm:perl/imap/Cyrus/IndexFile.pm]) AC_CONFIG_LINKS([perl/imap/Cyrus/DList.pm:perl/imap/Cyrus/DList.pm]) + AC_CONFIG_LINKS([perl/imap/Cyrus/HeaderFile.pm:perl/imap/Cyrus/HeaderFile.pm]) AC_CONFIG_LINKS([perl/imap/Cyrus/ImapClone.pm:perl/imap/Cyrus/ImapClone.pm]) + AC_CONFIG_LINKS([perl/imap/Cyrus/IndexFile.pm:perl/imap/Cyrus/IndexFile.pm]) + AC_CONFIG_LINKS([perl/imap/Cyrus/Mbentry.pm:perl/imap/Cyrus/Mbentry.pm]) + AC_CONFIG_LINKS([perl/imap/Cyrus/Mbname.pm:perl/imap/Cyrus/Mbname.pm]) AC_CONFIG_LINKS([perl/imap/Cyrus/SyncProto.pm:perl/imap/Cyrus/SyncProto.pm]) AC_CONFIG_LINKS([perl/imap/IMAP/Shell.pm:perl/imap/IMAP/Shell.pm]) - AC_CONFIG_LINKS([perl/imap/IMAP/IMSP.pm:perl/imap/IMAP/IMSP.pm]) AC_CONFIG_LINKS([perl/imap/IMAP/Admin.pm:perl/imap/IMAP/Admin.pm]) AC_CONFIG_LINKS([perl/imap/IMAP.pm:perl/imap/IMAP.pm]) AC_CONFIG_FILES([perl/imap/MANIFEST]) @@ -1932,7 +2127,7 @@ dnl add perl cccdlflags when building libraries -- this ensures that the dnl libraries will be compiled as PIC if perl requires PIC objects dnl -- this is needed on NetBSD and Linux, but seems to cause problems on atleast Solaris -- case "${target_os}" in - linux*|netbsd*|freebsd*|dragonfly*) + linux*|netbsd*|freebsd*|dragonfly*|openbsd*) AC_MSG_CHECKING(for perl cccdlflags needed on "${target_os}") eval `${PERL} -V:cccdlflags` PERL_CCCDLFLAGS="$cccdlflags" @@ -1948,7 +2143,6 @@ dnl -- this is needed on NetBSD and Linux, but seems to cause problems on atleas fi CMU_LIBWRAP -CMU_UCDSNMP # Figure out what directories we're linking against. # Lots of fun for the whole family. @@ -1993,12 +2187,24 @@ AC_ARG_ENABLE(unit-tests, dnl Unit tests need the CUnit library, so check if we dnl have both the header and the library. -if test "$enable_unit_tests" = "yes" ; then +dnl +dnl XXX cunit.pl is not compatible with vpath build, so auto-enabling +dnl XXX unit tests breaks make distcheck. Shutting it up for now by not +dnl XXX auto-enabling them, because validating the rest of the distribution +dnl XXX with distcheck is more important than having it automatically run +dnl XXX the unit tests. Once cunit.pl can handle vpath builds, make this +dnl XXX automatic again! +if test "x$enable_unit_tests" = xyes ; then AC_CHECK_LIB(cunit,CU_initialize_registry,found_lib=yes,found_lib=no) AC_CHECK_HEADER([CUnit/CUnit.h],found_hdr=yes,found_hdr=no) if test "$found_lib$found_hdr" != "yesyes" ; then + if test "x$enable_unit_tests" = xyes; then + AC_MSG_ERROR([Unit tests explicitly requested, but CUnit library is not installed]) + fi AC_MSG_NOTICE([Disabling unit tests because the required CUnit library is not installed]) enable_unit_tests=no + else + enable_unit_tests=yes fi AC_CHECK_HEADER([CUnit/Basic.h], AC_CHECK_TYPE([CU_SetUpFunc],AC_DEFINE(HAVE_CU_SETUPFUNC,[],[Do we have CU_SetUpFunc?]),, @@ -2031,6 +2237,17 @@ fi AM_CONDITIONAL([CUNIT], [test "$enable_unit_tests" = "yes"]) +dnl +dnl Enable/disable I/O throttling (for testing) +dnl +AC_ARG_ENABLE(debug-slowio, + [AS_HELP_STRING([--enable-debug-slowio], [enable I/O throttling])]) +AS_IF( + [test "x$enable_debug_slowio" = "xyes"], + [AC_DEFINE(ENABLE_DEBUG_SLOWIO,[], + [Build with slowio support])] +) + dnl dnl Enable/disable cyrusdb benchmarks. dnl @@ -2062,25 +2279,104 @@ if test "x$use_sqlite" = xyes; then fi AM_CONDITIONAL([USE_SQLITE], [test "x$use_sqlite" = xyes]) +dnl compiler feature checking +AC_MSG_CHECKING(for function nesting) +AC_CACHE_VAL(cyrus_cv_function_nesting, AC_TRY_COMPILE([ +#include +void func(void *data, size_t nmemb, size_t size, + int (*compar)(const void *, const void *, void *), + void *thunk) +{ + int compar_func(const void *a, const void *b) + { + return compar(a, b, thunk); + } + qsort(data, nmemb, size, compar_func); +} +int cval(const void *a, const void *b, void *prev) +{ + char **ref = (char **)prev; + char *ac = (char *)a; + char *bc = (char *)b; + if (*ac < *bc) return -1; + if (*ac == *bc) return 0; + *ref = bc; + return 1; +} +],[ + char *dest; + func("abc", 3, 1, cval, &dest); +],cyrus_cv_function_nesting=yes, cyrus_cv_function_nesting=no)) +AC_MSG_RESULT($cyrus_cv_function_nesting) +if test "$cyrus_cv_function_nesting" = "yes"; then + AC_DEFINE(HAVE_FUNCTION_NESTING,[],[Do we have function nesting support?]) +fi + +AC_CACHE_CHECK( + [for optimisation support], + [cyrus_cv_declare_optimize], + [ + AC_LANG_PUSH([C]) + saved_CFLAGS="$CFLAGS" + CFLAGS="$CFLAGS -Werror" + AC_COMPILE_IFELSE( + [AC_LANG_PROGRAM( + [ + #include + static inline int numcmp(int n1, int n2) + __attribute__((pure, always_inline, optimize("-O3"))); + static int numcmp(int n1, int n2) { return n1 - n2; } + ], + [ if (numcmp(1, 2)) exit(1); ]) + ], + [cyrus_cv_declare_optimize=yes], + [cyrus_cv_declare_optimize=no]) + CFLAGS="$saved_CFLAGS" + AC_LANG_POP([C]) + ]) +AS_IF( + [test "x$cyrus_cv_declare_optimize" = "xyes"], + [AC_DEFINE(HAVE_DECLARE_OPTIMIZE,[], + [Do we have support for optimize __attribute__?])] +) -dnl CRC32 optimisations +AC_CACHE_CHECK( + [whether the C compiler supports alignof(expression)], + [cyrus_cv_gnu_alignof_expression], + [ + AC_LANG_PUSH([C]) + saved_CFLAGS="$CFLAGS" + saved_CPPFLAGS="$CPPFLAGS" + CFLAGS="$CFLAGS -Wall -Werror -Wno-unused" + CPPFLAGS="$CPPFLAGS -Wall -Werror -Wno-unused" + AC_COMPILE_IFELSE( + [AC_LANG_PROGRAM( + [ + #include + #include + ], + [ + int x; + struct { float y; } y; + size_t a, b; -dnl if the compiler has support for SSE4.2 then we can compile a hardware -dnl implementation of CRC32C -AC_MSG_CHECKING(for compiler support for SSE4.2 instruction) -AC_CACHE_VAL(cyrus_cv_sse42, AC_TRY_COMPILE([ - #include -],[ - uint64_t a = 0, b = 1; - __asm__("crc32q\t" "(%1), %0" - : "=r"(b) - : "r"(b), "0"(a)); - return b; -], cyrus_cv_sse42=yes, cyrus_cv_sse42=no)) -if test "$cyrus_cv_sse42" = "yes" ; then - AC_DEFINE(HAVE_SSE42,1,"Compiler has support for SSE4.2 extensions") -fi -AC_MSG_RESULT($cyrus_cv_sse42) + a = alignof(x); + b = alignof(y.y); + ]) + ], + [cyrus_cv_gnu_alignof_expression=yes], + [cyrus_cv_gnu_alignof_expression=no] + ) + CFLAGS="$saved_CFLAGS" + CPPFLAGS="$saved_CPPFLAGS" + AC_LANG_POP([C]) + ]) +AS_IF( + [test "x$cyrus_cv_gnu_alignof_expression" = "xyes"], + [AC_DEFINE(HAVE_GNU_ALIGNOF_EXPRESSION,[], + [Do we have support for alignof(expression)?] + )] +) dnl documentation generation (sphinx, perl2rst, gitpython) AC_ARG_VAR(SPHINX_BUILD, [Location of sphinx-build]) @@ -2098,12 +2394,21 @@ AC_SUBST([SPHINX_BUILD]) AX_PROG_PERL_MODULES([Pod::POM::View::Restructured], [have_ppvr=yes], - [AC_MSG_WARN([No Pod::POM::View::Restructured, won't be able to regenerate docs])]) + [AC_MSG_WARN([No Pod::POM::View::Restructured, won't be able to regenerate docs]) + have_ppvr=no + ]) AX_PYTHON_MODULE([git], [], [python2]) AS_CASE([$HAVE_PYMOD_GIT], [yes], [], - [*], [AC_MSG_WARN([GitPython not found, won't be able to regenerate docs])]) + [*], [ + unset PYTHON # work around AX_PYTHON_MODULE not cleaning up + AX_PYTHON_MODULE([git], [], [python3]) + AS_CASE([$HAVE_PYMOD_GIT], + [yes], [], + [*], [ + AC_MSG_WARN([GitPython not found, won't be able to regenerate docs])]) +]) AM_CONDITIONAL([HAVE_SPHINX_BUILD], [ test -n "$SPHINX_BUILD" -a x"$have_ppvr" = xyes -a x"$HAVE_PYMOD_GIT" = xyes]) @@ -2254,27 +2559,6 @@ struct sockaddr_storage { #define shutdown(fd, mode) 0 #endif -/* *printf() macros */ -#if (SIZEOF_SIZE_T == SIZEOF_INT) -#define SIZE_T_FMT "%u" -#elif (SIZEOF_SIZE_T == SIZEOF_LONG) -#define SIZE_T_FMT "%lu" -#elif (SIZEOF_SIZE_T == SIZEOF_LONG_LONG_INT) -#define SIZE_T_FMT "%llu" -#else -#error dont know what to use for SIZE_T_FMT -#endif - -#if (SIZEOF_OFF_T == SIZEOF_LONG) -#define OFF_T_FMT "%ld" -#define strtoofft(nptr, endptr, base) strtol(nptr, endptr, base) -#elif (SIZEOF_OFF_T == SIZEOF_LONG_LONG_INT) -#define OFF_T_FMT "%lld" -#define strtoofft(nptr, endptr, base) strtoll(nptr, endptr, base) -#else -#error dont know what to use for OFF_T_FMT -#endif - #ifndef HAVE_POSIX_FADVISE #define POSIX_FADV_WILLNEED 0 extern int posix_fadvise(int fd, off_t offset, off_t len, int advice); @@ -2288,6 +2572,10 @@ extern char *strsep(char **, const char *); extern void *memmem(const void *, size_t, const void *, size_t); #endif +#ifndef HAVE_MEMRCHR +extern void *memrchr(const void *, int, size_t); +#endif + /* compile time options; think carefully before modifying */ enum { /* should we send an UNAVAILABLE message to master when @@ -2343,11 +2631,6 @@ CMU_PERL_MAKEMAKER(perl/imap) CMU_PERL_MAKEMAKER(perl/sieve/managesieve) fi -dnl check for libicu. we need this for charset conversions. -PKG_CHECK_MODULES([ICU], [icu-uc], AC_DEFINE(HAVE_ICU, [], [Build with ICU support]), []) -AC_SUBST([ICU_LIBS]) -AC_SUBST([ICU_CFLAGS]) - AC_OUTPUT echo " Cyrus Server configured components @@ -2374,8 +2657,8 @@ External dependencies: zlib: $with_zlib jansson: $with_jansson pcre: $cyrus_cv_pcre_utf8 + pcre2: $cyrus_cv_pcre2_utf8 clamav: $with_clamav - snmp: $with_snmp ----------------------- caringo: $with_caringo openio: $with_openio @@ -2383,11 +2666,15 @@ External dependencies: nghttp2: $with_nghttp2 wslay: $with_wslay brotli: $with_brotli + zstd: $with_zstd xml2: $with_xml2 ical: $with_ical - icu4c: $with_icu4c + icalvcard: $with_icalvcard shapelib: $with_shapelib + icu4c: $with_icu4c chardet: $with_chardet + cld2: $with_cld2 + guesstz: $with_guesstz Database support: mysql: $with_mysql @@ -2398,10 +2685,10 @@ Database support: Search engine: squat: $enable_squat xapian: $enable_xapian - xapian_flavor: $xapian_flavor + xapian_cjk_tokens: $xapian_cjk_tokens -Hardware support: - SSE4.2: $cyrus_cv_sse42 +iCalendar features: + ctrl: $have_ical_ctrl Documentation dependencies: sphinx-build: $SPHINX_BUILD @@ -2411,7 +2698,6 @@ Documentation dependencies: Installation directories: prefix: $prefix sysconfdir: $sysconfdir - zoneinfo_dir: $CYRUS_TIMEZONES_ZONEINFO_DIR Build info: shared: $enable_shared @@ -2420,4 +2706,7 @@ Build info: cxxflags: $CXXFLAGS libs: $LIBS ldflags: $LDFLAGS + libm: $LIBM + unit tests (cunit): $enable_unit_tests + xxd: $XXD " diff --git a/contrib/cyrus-graphtools.1.0/README b/contrib/cyrus-graphtools.1.0/README deleted file mode 100644 index a7eafca903..0000000000 --- a/contrib/cyrus-graphtools.1.0/README +++ /dev/null @@ -1,71 +0,0 @@ -cyrus-graphtools v1.0 -Alison Greenwald - -Please send all comments/questions/bugs to -https://github.com/cyrusimap/cyrus-imapd/issues - --------- - -This archive contains the scripts necessary for collecting data from -CMU cyrus server with UCD SNMP support. It contains: - -script/cyrus.pl - script for retrieving data from the server and - dumping it into an rrdtool database -script/cyrusrc - configuration options for rrdtool database -script/run - a shell script to automatically run cyrus.pl every - 4 minutes. Due to rrdtool eccentricities, a cron - job that runs every 5 minutes is not sufficient. - -cgi-bin/cyrus_master.pl - a cgi script that determines what rrdtool - are available and provides links to them -cgi-bin/graph_cyrus_db.pl - a cgi script that graphs the rrdtool db - passed to it - -html/index.html - sample html script that links to - cyrus_master.pl and graph_cyrus_db-sum.pl - --------- - -cyrus-graphtools requires that you have rrdtool version 1.0.28 or -better installed. It may work with other 1.0+ versions, but I have -not tested it. It is available here: -http://ee-staff.ethz.ch/~oetiker/webtools/rrdtool/pub - - -Also required is net-snmp 4.2 (formerly ucd-snmp), however this is -required for the cyrus snmp functionality as well. It is available at -http://net-snmp.sourceforge.net/ - -or maybe -http://sourceforge.net/project/showfiles.php?group_id=12694 - -------- - -Data collection: - -For data collection you must create a program directory and a data -directory. The scripts assume /data/prog/cyrus and /data/cyrus, -respectively. This can be changed by editing script/cyrusrc. - -Move the contents of script/ to your data directory and run "run" and -your data is being collected. - -------- - -Web Display: - -Web graphing also assumes that data is in /data/cyrus, so if this is -different, edit $DDIR in all three cgi scripts. - -In addition, it assumes that apache document root is /usr/www/tree and -there is a directory called current/tainted that the "nobody" user (or -whatever uid httpd runs as) can write to. $picdir and $hpicdir store -the internal and external names for these directories. - -Copy the cgi-bin's into your cgi-bin directory and the html into your -html directory and you should be ready to go. - -------- - -Please send any comments or suggestions to -https://github.com/cyrusimap/cyrus-imapd/issues diff --git a/contrib/cyrus-graphtools.1.0/cgi-bin/cyrus_master.pl b/contrib/cyrus-graphtools.1.0/cgi-bin/cyrus_master.pl deleted file mode 100755 index eb55d10b24..0000000000 --- a/contrib/cyrus-graphtools.1.0/cgi-bin/cyrus_master.pl +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/perl - -# -# Created by Alison Greenwald 21 Sep 2000 -# -# - -#use strict; -use CGI qw(:standard escapeHTML); -use Time::Local; - -$DDIR="/data/cyrus"; -$GRAPH="/cgi-bin/graph_cyrus_db.pl"; - -$q= new CGI; -print $q->header(); - -print("Cyrus Stats"); -print(""); - -opendir(DH, $DDIR) or die "Could not find data"; -@files = readdir(DH); -closedir(DH); - -%hash=(); -$n=0; -foreach (@files){ - $server = ""; - $ds = ""; - $n++; - ($server, $end) = split /\:/, $_, 2; - ($ds,$throwaway) = split /\./,$end,2; -# print("$server $ds $throwaway
"); - if($ds ne "" && $ds ne ${$hash{"$server"}}[-1]){ - #this if statement checks to see if $server is the same as the last - #element in the array specified by this hash - push @{$hash{"$server"}}, "$ds"; - } -} - -print("\n"); - -foreach $key( sort %hash){ - if($hash{$key}){ - print("

$key

    \n"); - } - foreach $service (@{$hash{$key}}){ - print("
  • $service\n"); - } - print("
\n"); -} - -print("
\n"); - -print(""); - -#print head(), start_html("blah"), end-html(); diff --git a/contrib/cyrus-graphtools.1.0/cgi-bin/graph_cyrus_db-sum.pl b/contrib/cyrus-graphtools.1.0/cgi-bin/graph_cyrus_db-sum.pl deleted file mode 100755 index 6d7a29d073..0000000000 --- a/contrib/cyrus-graphtools.1.0/cgi-bin/graph_cyrus_db-sum.pl +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/perl - -# -# Created by Alison Greenwald 21 Sep 2000 -# -# - -#use strict; -use CGI qw(:standard escapeHTML); -use Time::Local; -use RRDs; - -$DDIR="/data/cyrus"; -$GRAPH="/cgi-bin/graph_cyrus_db.pl"; -$picdir="/usr/www/tree/current/tainted"; -$hpicdir="/current/tainted"; - -$q= new CGI; -print $q->header(); - -print("Cyrus Stats"); -print(""); - -opendir(DH, $DDIR) or die "Could not find data"; -@files = readdir(DH); -closedir(DH); - -%hash=(); -$n=0; -foreach (@files){ - $server = ""; - $ds = ""; - $n++; - ($server, $end) = split /\:/, $_, 2; - ($ds,$throwaway) = split /\./,$end,2; - if($ds ne ""){ - push @{$hash{"$ds"}}, "$server"; - } -} - -print("\n"); - -foreach $service( sort %hash){ - if(!$hash{$service}){ - next; - } - print("

$service

    \n"); - $cdef.="CDEF:sum=0"; - print("on "); - foreach $server(@{$hash{$service}}){ - print("$server "); - ($name, @throwaway)=split /\./, $server; - push(@args1,"DEF:$name=$DDIR/$server\\\:$service.rrd:current:MAX,"); - push(@args2,"DEF:$name=$DDIR/$server\\\:$service.rrd:total:MAX,"); - $cdef.=",$name,+"; - } - chomp(@args1, @args2); - RRDs::graph("$picdir/$service-1.gif",@args1,"$cdef", - "AREA:sum#FF0000"); -# RRDs::graph("$picdir/$service-1.gif", -# "DEF:mail1=$DDIR/mail1.andrew.cmu.edu\\\:$service.rrd:current:MAX", -# "DEF:mail2=$DDIR/mail2.andrew.cmu.edu\\\:$service.rrd:current:MAX", -# "CDEF:sum=mail1,mail2,+", -# "AREA:sum#FF0000"); - - $error1=RRDs::error; - RRDs::graph("$picdir/$service-2.gif", @args2, - $cdef, "CDEF:throw=sum,10000,GT","CDEF:med=throw,0,sum,IF", - "CDEF:msum=med,300,* ", "AREA:msum#FF0000"); - $error2=RRDs::error; - print("
    "); - print(""); - if ($error1) {print $error1} - if ($error2) {print $error2} - @args1=(); @args2=(); $cdef=(); $error1=(); $error2=(); - print("
\n"); -} - -print("
\n"); - -print(""); - -#print head(), start_html("blah"), end-html(); diff --git a/contrib/cyrus-graphtools.1.0/cgi-bin/graph_cyrus_db.pl b/contrib/cyrus-graphtools.1.0/cgi-bin/graph_cyrus_db.pl deleted file mode 100755 index 4b9e58c4d2..0000000000 --- a/contrib/cyrus-graphtools.1.0/cgi-bin/graph_cyrus_db.pl +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/perl - -# -# Created by Alison Greenwald 21 Sep 2000 -# - -use Time::Local; -use CGI qw(:standard escapeHTML); -use RRDs; -srand(timelocal(localtime)); - -%periods = ( "daily" => 86400, - "weekly" => 604800, - "monthly" => 2419200, - "yearly" => 31536000, -); - - -$DDIR="/data/cyrus"; -$SERVER=param("server"); -$SERVICE=param("service"); -$FNAME="$SERVER-$SERVICE"; -$picdir="/usr/www/tree/current/tainted"; -$hpicdir="/current/tainted"; - -$etime=timelocal((localtime)[0,1,2,3,4,5]); - -$RNDNUM = rand()*1024; -$TITLEC="$FNAME in use"; -$TITLET="$FNAME connections"; - - -$q= new CGI; -print $q->header(); - -print("Graphs"); -print(""); -print("

$SERVICE usage on $SERVER

\n"); - -foreach $period (sort {$periods{$a} <=> $periods{$b}}keys %periods){ - $sttime = $etime - $periods{$period}; - $DPICNAME="$FNAME-$period-$RNDNUM.gif"; - - RRDs::graph("$picdir/cur-$DPICNAME","-t $TITLEC", - "-s $sttime","-e $etime","-l 0", - "DEF:a=$DDIR/$SERVER\\\:$SERVICE.rrd:current:MAX", - "AREA:a#0000FF","COMMENT:Maximum\:","GPRINT:a:MAX:%lf", - "COMMENT:Minimum\:","GPRINT:a:MIN:%lf"); - $ERROR=RRDs::error; - print $ERROR if $ERROR; - $RRDARGD.=" CDEF:throw=b,5000,GT "; - $RRDARGD.=" CDEF:med=throw,0,b,IF "; - $RRDARGD.=" CDEF:a=med,300,\*,FLOOR "; - - RRDs::graph("$picdir/tot-$DPICNAME","-t $TITLET", "-s $sttime","-e $etime", - "DEF:a=$DDIR/$SERVER\\\:$SERVICE.rrd:total:MAX", - "CDEF:throw=a,5000,GT","CDEF:med=throw,0,a,IF", - "CDEF:b=med,300,*,FLOOR", "AREA:b#0000FF","COMMENT:Maximum\:", - "GPRINT:b:MAX:%lf", "COMMENT:Minimum\:","GPRINT:b:MIN:%lf"); - $ERROR=RRDs::error; - print $ERROR if $ERROR; - - print("

$period

"); - print("

Current

"); - print("

Total

"); - -} - -print(""); - - diff --git a/contrib/cyrus-graphtools.1.0/html/index.html b/contrib/cyrus-graphtools.1.0/html/index.html deleted file mode 100644 index 9865959456..0000000000 --- a/contrib/cyrus-graphtools.1.0/html/index.html +++ /dev/null @@ -1,11 +0,0 @@ -Andrew System Graphs - - -

This is a sample html page for the cyrus graphs

-

Cyrus Data

-All services by server
-Aggregate graphs for all servers by service - - - - diff --git a/contrib/cyrus-graphtools.1.0/script/cyrus.pl b/contrib/cyrus-graphtools.1.0/script/cyrus.pl deleted file mode 100755 index 167af57e9a..0000000000 --- a/contrib/cyrus-graphtools.1.0/script/cyrus.pl +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/perl - -# This will read information from the cyrus MIB for all devices specified -# in cyrusrc -# -# Copyright (c) 1994-2008 Carnegie Mellon University. All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in -# the documentation and/or other materials provided with the -# distribution. -# -# 3. The name "Carnegie Mellon University" must not be used to -# endorse or promote products derived from this software without -# prior written permission. For permission or any legal -# details, please contact -# Carnegie Mellon University -# Center for Technology Transfer and Enterprise Creation -# 4615 Forbes Avenue -# Suite 302 -# Pittsburgh, PA 15213 -# (412) 268-7393, fax: (412) 268-7395 -# innovation@andrew.cmu.edu -# -# 4. Redistributions of any form whatsoever must retain the following -# acknowledgment: -# "This product includes software developed by Computing Services -# at Carnegie Mellon University (http://www.cmu.edu/computing/)." -# -# CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO -# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -# AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE -# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN -# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING -# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -# -# Author: Alison Greenwald - -use RRDs; -use SNMP 1.8; - -do "/data/prog/cyrus/cyrusrc"; -get_data(); - -sub get_data{ - foreach $hname (keys %HOSTS){ - $MAX = 0; - %walk=snmp_walk($hname, $HOSTS{$hname}, $MASTER); - foreach $OID (sort keys %walk){ - @O = split /\./, $OID; - $RVAL=$walk{$OID}; - chomp $RVAL; - if($O[-1] > $MAX){ - $MAX = $O[-1]; - } - $STUFF{"$O[$#O-1]-$O[$#O]"} = $RVAL; - } #foreach oid - - for($i=1; $i<=$MAX; $i++){ - $blah[0]=$STUFF{"3-$i"}; - $blah[1]=$STUFF{"2-$i"}; - $blah[2]=$STUFF{"1-$i"}; - print "$hname:$blah[0]: $blah[2], $blah[1]\n"; - populate_dbs(); - } #for - } #foreach hname -} #sub - -sub populate_dbs{ - if(open(DB, "< $DPTH/$hname:$blah[0].rrd")){ - close(DB); - } else { - print("Making $DPTH/$hname:$blah[0]:daily.rrd\n"); - RRDs::create("$DPTH/$hname:$blah[0].rrd","-s 1", - "DS:current:GAUGE:300:U:U", - "DS:total:COUNTER:300:U:U", - "RRA:MAX:0.5:300:4320", - "RRA:MAX:0.5:1800:336", - "RRA:MAX:0.5:14400:168", - "RRA:MAX:0.5:86400:364"); - $ERROR=RRDs::error; - print $ERROR if $ERROR; - } - - RRDs::update("$DPTH/$hname:$blah[0].rrd", "N:$blah[1]:$blah[2]"); - -}#find_dbs - -sub snmp_walk{ - my ($server, $comm, $rootoid) = @_; - my %walk=(); - my $sess = new SNMP::Session ( DestHost => $server, - Community => $comm, - UseNumeric => 1, - UseLongNames => 1 - ); - - my @orig=split /\./, $rootoid; # original oid for comparison - - my $var = new SNMP::Varbind(["$rootoid"]); - my $val = $sess->getnext($var); - my $name = $var->[$SNMP::Varbind::tag_f]; - $name .= ".$var->[$SNMP::Varbind::iid_f]" if $var->[$SNMP::Varbind::iid_f]; - my @current=split /\./, $name; - - while (!$sess->{ErrorStr} && $orig[$#orig] eq $current[$#orig] - && $#current > $#orig){ - my $value=$var->[$SNMP::Varbind::val_f]; - - $walk{"$name"} = $value; - $val = $sess->getnext($var); - $name=$var->[$SNMP::Varbind::tag_f]; - $name .= ".$var->[$SNMP::Varbind::iid_f]" if $var->[$SNMP::Varbind::iid_f]; - @current=split /\./, $name; - } #while - - print("$sess->{ErrorStr}\n") if $sess->{ErrorStr}; - return(%walk); - -} diff --git a/contrib/cyrus-graphtools.1.0/script/cyrusrc b/contrib/cyrus-graphtools.1.0/script/cyrusrc deleted file mode 100644 index 457d77a4c5..0000000000 --- a/contrib/cyrus-graphtools.1.0/script/cyrusrc +++ /dev/null @@ -1,13 +0,0 @@ -$MASTER=".1.3.6.1.4.1.3.6.1.2.1"; #snmp root for data -$RRD="/usr/local/bin/rrdtool"; #rrdtool program -$DPTH="/data/cyrus"; #data root -$PROG_PATH="/data/prog/cyrus"; #program root -%HOSTS=("mail-fe2.andrew.cmu.edu" => "public", - "mail-fe3.andrew.cmu.edu" => "public", - "mail-fe4.andrew.cmu.edu" => "public", - "mail1.andrew.cmu.edu" => "public", - "mail2.andrew.cmu.edu" => "public", - "mail3.andrew.cmu.edu" => "public", - "mail4.andrew.cmu.edu" => "public", -); #host => read-community string - diff --git a/contrib/cyrus-graphtools.1.0/script/run b/contrib/cyrus-graphtools.1.0/script/run deleted file mode 100755 index ee52c6f7f9..0000000000 --- a/contrib/cyrus-graphtools.1.0/script/run +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/contributed/bin/bash - -while [ 1 ]; do -/data/prog/cyrus/cyrus.pl 2&>/dev/null -sleep 240 -done - diff --git a/contrib/sieve-spamassassin b/contrib/sieve-spamassassin index 60c9ad290d..9f9dbe22c5 100644 --- a/contrib/sieve-spamassassin +++ b/contrib/sieve-spamassassin @@ -241,7 +241,7 @@ diff -cr cyrus-imapd-2.1.3-orig/imap/lmtpd.c cyrus-imapd-2.1.3/imap/lmtpd.c + free (msg_buf); + close (s); + -+ syslog(LOG_DEBUG, "spam result: %d\n", ret); ++ syslog(LOG_DEBUG, "spam result: %d", ret); + return ret; + } + @@ -257,13 +257,13 @@ diff -cr cyrus-imapd-2.1.3-orig/imap/lmtpd.c cyrus-imapd-2.1.3/imap/lmtpd.c + res = sieve_register_spam(sieve_interp, &spam); + if (res != SIEVE_OK) { -+ syslog(LOG_ERR, "sieve_register_spam() returns %d\n", res); ++ syslog(LOG_ERR, "sieve_register_spam() returns %d", res); + fatal("sieve_register_spam()", EX_SOFTWARE); + } + res = sieve_register_parse_error(sieve_interp, &sieve_parse_error_handler); if (res != SIEVE_OK) { - syslog(LOG_ERR, "sieve_register_parse_error() returns %d\n", res); + syslog(LOG_ERR, "sieve_register_parse_error() returns %d", res); *************** *** 1148,1154 **** mydata.notifyheader = generate_notify(msgdata); @@ -452,11 +452,11 @@ diff -cr cyrus-imapd-2.1.3-orig/timsieved/scripttest.c cyrus-imapd-2.1.3/timsiev + res = sieve_register_spam(i, (sieve_spam *) &foo); + if (res != SIEVE_OK) { -+ syslog (LOG_ERR, "sieve_register_spam() returns %d\n", res); ++ syslog (LOG_ERR, "sieve_register_spam() returns %d", res); + return TIMSIEVE_FAIL; + } + res = sieve_register_parse_error(i, &mysieve_error); if (res != SIEVE_OK) { - syslog(LOG_ERR, "sieve_register_parse_error() returns %d\n", res); + syslog(LOG_ERR, "sieve_register_parse_error() returns %d", res); diff --git a/cunit/aaa-db.testc b/cunit/aaa-db.testc index 43901723d4..b8abdbc039 100644 --- a/cunit/aaa-db.testc +++ b/cunit/aaa-db.testc @@ -23,18 +23,6 @@ static char *backend = CUNIT_PARAM("skiplist,flat,twoskip,zeroskip"); static char *filename; static char *filename2; -static void config_read_string(const char *s) -{ - char *fname = xstrdup("/tmp/cyrus-cunit-configXXXXXX"); - int fd = mkstemp(fname); - retry_write(fd, s, strlen(s)); - config_reset(); - config_read(fname, 0); - unlink(fname); - free(fname); - close(fd); -} - static int fexists(const char *fname) { struct stat sb; @@ -1167,6 +1155,8 @@ static void test_binary_keys(void) 0x10, 0x20, 0x40, 0x80, 0x00/*unused*/}; static const char DATA5[] = "farm-to-table"; + static const char KEY6[] = " BLANK\x07\xa0"; + static const char DATA6[] = "magic blank in key!"; struct binary_result *results = NULL; int r; @@ -1182,6 +1172,7 @@ static void test_binary_keys(void) CANNOTFETCH(KEY3, sizeof(KEY3)-1, CYRUSDB_NOTFOUND); CANNOTFETCH(KEY4, sizeof(KEY4)-1, CYRUSDB_NOTFOUND); CANNOTFETCH(KEY5, sizeof(KEY5)-1, CYRUSDB_NOTFOUND); + CANNOTFETCH(KEY6, sizeof(KEY6)-1, CYRUSDB_NOTFOUND); /* store()ing a record succeeds */ CANSTORE(KEY1, sizeof(KEY1)-1, DATA1, sizeof(DATA1)-1); @@ -1189,6 +1180,7 @@ static void test_binary_keys(void) CANSTORE(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); CANSTORE(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); CANSTORE(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + CANSTORE(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); /* the record can be fetched back */ CANFETCH(KEY1, sizeof(KEY1)-1, DATA1, sizeof(DATA1)-1); @@ -1196,6 +1188,7 @@ static void test_binary_keys(void) CANFETCH(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); CANFETCH(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); CANFETCH(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + CANFETCH(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); /* commit succeeds */ CANCOMMIT(); @@ -1206,6 +1199,7 @@ static void test_binary_keys(void) CANFETCH(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); CANFETCH(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); CANFETCH(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + CANFETCH(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); /* close the txn - it doesn't matter here if we commit or abort */ CANCOMMIT(); @@ -1216,6 +1210,7 @@ static void test_binary_keys(void) /* got the expected keys in the expected order */ GOTRESULT(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + GOTRESULT(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); GOTRESULT(KEY2, sizeof(KEY2)-1, DATA2, sizeof(DATA2)-1); GOTRESULT(KEY1, sizeof(KEY1)-1, DATA1, sizeof(DATA1)-1); GOTRESULT(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); @@ -1235,6 +1230,7 @@ static void test_binary_keys(void) CANFETCH(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); CANFETCH(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); CANFETCH(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + CANFETCH(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); /* close the txn - it doesn't matter here if we commit or abort */ CANCOMMIT(); @@ -1245,6 +1241,7 @@ static void test_binary_keys(void) /* got the expected keys in the expected order */ GOTRESULT(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + GOTRESULT(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); GOTRESULT(KEY2, sizeof(KEY2)-1, DATA2, sizeof(DATA2)-1); GOTRESULT(KEY1, sizeof(KEY1)-1, DATA1, sizeof(DATA1)-1); GOTRESULT(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); @@ -1275,6 +1272,8 @@ static void test_binary_data(void) static const char DATA5[] = { 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00/*unused*/}; + static const char KEY6[] = "magic blank in data!"; + static const char DATA6[] = " BLANK\x07\xa0"; struct binary_result *results = NULL; int r; @@ -1290,6 +1289,7 @@ static void test_binary_data(void) CANNOTFETCH(KEY3, sizeof(KEY3)-1, CYRUSDB_NOTFOUND); CANNOTFETCH(KEY4, sizeof(KEY4)-1, CYRUSDB_NOTFOUND); CANNOTFETCH(KEY5, sizeof(KEY5)-1, CYRUSDB_NOTFOUND); + CANNOTFETCH(KEY6, sizeof(KEY6)-1, CYRUSDB_NOTFOUND); /* store()ing a record succeeds */ CANSTORE(KEY1, sizeof(KEY1)-1, DATA1, sizeof(DATA1)-1); @@ -1297,6 +1297,7 @@ static void test_binary_data(void) CANSTORE(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); CANSTORE(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); CANSTORE(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + CANSTORE(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); /* the record can be fetched back */ CANFETCH(KEY1, sizeof(KEY1)-1, DATA1, sizeof(DATA1)-1); @@ -1304,6 +1305,7 @@ static void test_binary_data(void) CANFETCH(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); CANFETCH(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); CANFETCH(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + CANFETCH(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); /* commit succeeds */ CANCOMMIT(); @@ -1314,6 +1316,7 @@ static void test_binary_data(void) CANFETCH(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); CANFETCH(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); CANFETCH(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + CANFETCH(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); /* close the txn - it doesn't matter here if we commit or abort */ CANCOMMIT(); @@ -1326,6 +1329,7 @@ static void test_binary_data(void) GOTRESULT(KEY2, sizeof(KEY2)-1, DATA2, sizeof(DATA2)-1); GOTRESULT(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); GOTRESULT(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + GOTRESULT(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); GOTRESULT(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); GOTRESULT(KEY1, sizeof(KEY1)-1, DATA1, sizeof(DATA1)-1); /* foreach iterated over exactly all the keys */ @@ -1343,6 +1347,7 @@ static void test_binary_data(void) CANFETCH(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); CANFETCH(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); CANFETCH(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + CANFETCH(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); /* foreach still succeeds */ r = cyrusdb_foreach(db, NULL, 0, NULL, foreacher, &results, &txn); @@ -1352,6 +1357,7 @@ static void test_binary_data(void) GOTRESULT(KEY2, sizeof(KEY2)-1, DATA2, sizeof(DATA2)-1); GOTRESULT(KEY3, sizeof(KEY3)-1, DATA3, sizeof(DATA3)-1); GOTRESULT(KEY5, sizeof(KEY5)-1, DATA5, sizeof(DATA5)-1); + GOTRESULT(KEY6, sizeof(KEY6)-1, DATA6, sizeof(DATA6)-1); GOTRESULT(KEY4, sizeof(KEY4)-1, DATA4, sizeof(DATA4)-1); GOTRESULT(KEY1, sizeof(KEY1)-1, DATA1, sizeof(DATA1)-1); /* foreach iterated over exactly all the keys */ @@ -1573,7 +1579,6 @@ static int set_up(void) { char buf[PATH_MAX]; static const char * const reldirs[] = { - "db", "conf", "conf/lock/", "conf/lock/user", @@ -1603,6 +1608,8 @@ static int tear_down(void) cyrusdb_done(); + config_reset(); + if (basedir) { char buf[PATH_MAX]; snprintf(buf, sizeof(buf), "rm -rf \"%s\"", basedir); diff --git a/cunit/annotate.testc b/cunit/annotate.testc index f61648c283..ae07db01c3 100644 --- a/cunit/annotate.testc +++ b/cunit/annotate.testc @@ -6,6 +6,7 @@ #include "xmalloc.h" #include "retry.h" #include "util.h" +#include "xunlink.h" #include "imap/global.h" #include "libcyr_cfg.h" #include "imap/annotate.h" @@ -20,6 +21,7 @@ #define PARTITION "default" #define COMMENT "/comment" #define EXENTRY "/vendor/example.com/a-non-default-entry" +#define UCEXENTRY "/vendor/EXAMPLE.COM/a-non-default-entry" #define VALUE_SHARED "value.shared" #define SIZE_SHARED "size.shared" #define VALUE0 "Hello World" @@ -36,18 +38,6 @@ static const char *userid; static struct auth_state *auth_state; static const char *old_annotation_definitions = NULL; -static void config_read_string(const char *s) -{ - char *fname = xstrdup("/tmp/cyrus-cunit-configXXXXXX"); - int fd = mkstemp(fname); - retry_write(fd, s, strlen(s)); - config_reset(); - config_read(fname, 0); - unlink(fname); - free(fname); - close(fd); -} - static void set_annotation_definitions(const char *s) { static const char *fname = DBDIR"/conf/annotations.def"; @@ -60,7 +50,7 @@ static void set_annotation_definitions(const char *s) close(fd); } else { - unlink(fname); + xunlink(fname); } imapopts[IMAPOPT_ANNOTATION_DEFINITIONS].val.s = fname; @@ -517,7 +507,7 @@ static void test_getset_message_shared(void) #undef EXPECTED strarray_truncate(&results, 0); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val); + r = annotatemore_msg_lookup(mailbox, 1, COMMENT, /*userid*/"", &val); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NULL(val.s); @@ -545,7 +535,7 @@ static void test_getset_message_shared(void) #undef EXPECTED strarray_truncate(&results, 0); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 1, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); @@ -576,7 +566,7 @@ static void test_getset_message_shared(void) #undef EXPECTED strarray_truncate(&results, 0); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 1, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); @@ -612,7 +602,7 @@ static void test_getset_message_shared(void) #undef EXPECTED strarray_truncate(&results, 0); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 1, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); @@ -653,7 +643,7 @@ static void test_getset_message_shared(void) strarray_truncate(&results, 0); buf_free(&val); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val); + r = annotatemore_msg_lookup(mailbox, 1, COMMENT, /*userid*/"", &val); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NULL(val.s); @@ -676,11 +666,10 @@ static void test_delete(void) int r; annotate_state_t *astate = NULL; struct mboxlist_entry mbentry; - strarray_t entries = STRARRAY_INITIALIZER; - strarray_t attribs = STRARRAY_INITIALIZER; struct entryattlist *ealist = NULL; struct buf val = BUF_INITIALIZER; struct buf val2 = BUF_INITIALIZER; + struct buf path = BUF_INITIALIZER; struct mailbox *mailbox = NULL; annotate_init(NULL, NULL); @@ -692,11 +681,10 @@ static void test_delete(void) memset(&mbentry, 0, sizeof(mbentry)); mbentry.name = MBOXNAME1_INT; + mbentry.uniqueid = (char *)mailbox_uniqueid(mailbox); mbentry.mbtype = 0; mbentry.partition = PARTITION; mbentry.acl = ACL; - strarray_append(&entries, COMMENT); - strarray_append(&attribs, VALUE_SHARED); /* set some values */ @@ -738,65 +726,33 @@ static void test_delete(void) r = mailbox_commit(mailbox); CU_ASSERT_EQUAL(r, 0); - mailbox_close(&mailbox); /* check that we can fetch the values back */ - r = annotatemore_msg_lookup(MBOXNAME1_INT, 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 1, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE1); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 3, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 3, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE2); buf_free(&val2); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), 0); - - /* delete all the entries associated with the mailbox */ - - r = mailbox_open_iwl(MBOXNAME1_INT, &mailbox); - CU_ASSERT_EQUAL_FATAL(r, 0); - - r = annotate_delete_mailbox(mailbox); - CU_ASSERT_EQUAL(r, 0); - - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), -ENOENT); - - /* check that the values are gone */ - - r = annotatemore_msg_lookup(MBOXNAME1_INT, 0, COMMENT, /*userid*/"", &val2); - CU_ASSERT_EQUAL_FATAL(r, 0); - CU_ASSERT_PTR_NULL_FATAL(val2.s); - buf_free(&val2); - - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val2); - CU_ASSERT_EQUAL_FATAL(r, 0); - CU_ASSERT_PTR_NULL_FATAL(val2.s); - buf_free(&val2); - - r = annotatemore_msg_lookup(MBOXNAME1_INT, 3, COMMENT, /*userid*/"", &val2); - CU_ASSERT_EQUAL_FATAL(r, 0); - CU_ASSERT_PTR_NULL_FATAL(val2.s); - buf_free(&val2); - - annotatemore_close(); - - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), -ENOENT); - - strarray_fini(&entries); - strarray_fini(&attribs); - buf_free(&val); + buf_printf(&path, "%s/data/uuid/%c/%c/%s/cyrus.annotations", DBDIR, + mailbox_uniqueid(mailbox)[0], mailbox_uniqueid(mailbox)[1], mailbox_uniqueid(mailbox)); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path)), 0); mailbox_close(&mailbox); + buf_free(&val); + buf_free(&path); } static void test_rename(void) @@ -808,9 +764,10 @@ static void test_rename(void) struct entryattlist *ealist = NULL; struct buf val = BUF_INITIALIZER; struct buf val2 = BUF_INITIALIZER; + struct buf path1 = BUF_INITIALIZER; + struct buf path3 = BUF_INITIALIZER; struct mailbox *mailbox1 = NULL; struct mailbox *mailbox3 = NULL; - const char *uniqueid = makeuuid(); annotate_init(NULL, NULL); @@ -871,79 +828,87 @@ static void test_rename(void) /* check that we can fetch the values back */ - r = annotatemore_msg_lookup(MBOXNAME1_INT, 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 2, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 2, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE1); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 3, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 3, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE2); buf_free(&val2); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), 0); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurfine/cyrus.annotations"), -ENOENT); + const char *u1 = mailbox_uniqueid(mailbox1); + buf_printf(&path1, "%s/data/uuid/%c/%c/%s/cyrus.annotations", DBDIR, + u1[0], u1[1], u1); + const char *u3 = mailbox_uniqueid(mailbox3); + buf_printf(&path3, "%s/data/uuid/%c/%c/%s/cyrus.annotations", DBDIR, + u3[0], u3[1], u3); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path1)), 0); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path3)), -ENOENT); /* rename MBOXNAME1 -> MBOXNAME3 */ r = annotate_rename_mailbox(mailbox1, mailbox3); CU_ASSERT_EQUAL(r, 0); - r = mailbox_copy_files(mailbox1, PARTITION, MBOXNAME3_INT, uniqueid); + r = mailbox_copy_files(mailbox1, PARTITION, MBOXNAME3_INT, mailbox_uniqueid(mailbox3)); CU_ASSERT_EQUAL(r, 0); - r = mailbox_rename_cleanup(&mailbox1, 0); + r = mailbox_rename_cleanup(&mailbox1); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NULL(mailbox1); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), -ENOENT); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurfine/cyrus.annotations"), 0); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path1)), -ENOENT); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path3)), 0); /* check that the values are gone under the old name */ - r = annotatemore_msg_lookup(MBOXNAME1_INT, 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_lookup(MBOXNAME1_INT, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 2, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 2, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 3, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 3, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); buf_free(&val2); /* check that the values are present under the new name */ - r = annotatemore_msg_lookup(MBOXNAME3_INT, 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox3, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME3_INT, 2, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox3, 2, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE1); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME3_INT, 3, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox3, 3, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE2); buf_free(&val2); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), -ENOENT); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurfine/cyrus.annotations"), 0); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path1)), -ENOENT); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path3)), 0); + buf_free(&path1); + buf_free(&path3); annotatemore_close(); @@ -978,17 +943,17 @@ static void test_abort(void) /* check that the values we'll be setting are not already present */ buf_free(&val2); - r = annotatemore_msg_lookup("", 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(NULL, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 42, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 42, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); @@ -1011,7 +976,7 @@ static void test_abort(void) /* check that the values are still not present */ buf_free(&val2); - r = annotatemore_msg_lookup("", 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(NULL, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); @@ -1034,12 +999,12 @@ static void test_abort(void) /* check that the values are still not present */ buf_free(&val2); - r = annotatemore_msg_lookup("", 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(NULL, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); @@ -1062,17 +1027,17 @@ static void test_abort(void) /* check that the values are still not present */ buf_free(&val2); - r = annotatemore_msg_lookup("", 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(NULL, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 0, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 0, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 42, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox, 42, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); @@ -1095,6 +1060,8 @@ static void test_msg_copy(void) struct entryattlist *ealist = NULL; struct buf val = BUF_INITIALIZER; struct buf val2 = BUF_INITIALIZER; + struct buf path1 = BUF_INITIALIZER; + struct buf path2 = BUF_INITIALIZER; struct mailbox *mailbox1 = NULL; struct mailbox *mailbox2 = NULL; @@ -1146,35 +1113,41 @@ static void test_msg_copy(void) r = mailbox_commit(mailbox1); CU_ASSERT_EQUAL(r, 0); - mailbox_close(&mailbox1); /* check that we can fetch the values back */ buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 1, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 2, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 2, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE1); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 3, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 3, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE2); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME2_INT, 1, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox2, 1, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL_FATAL(val2.s); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), 0); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurfette/cyrus.annotations"), 0); + const char *u1 = mailbox_uniqueid(mailbox1); + buf_printf(&path1, "%s/data/uuid/%c/%c/%s/cyrus.annotations", DBDIR, + u1[0], u1[1], u1); + const char *u2 = mailbox_uniqueid(mailbox2); + buf_printf(&path2, "%s/data/uuid/%c/%c/%s/cyrus.annotations", DBDIR, + u2[0], u2[1], u2); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path1)), 0); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path2)), 0); + mailbox_close(&mailbox1); /* copy MBOXNAME1,1 -> MBOXNAME2,1 */ r = mailbox_open_iwl(MBOXNAME1_INT, &mailbox1); @@ -1191,22 +1164,20 @@ static void test_msg_copy(void) r = mailbox_commit(mailbox2); CU_ASSERT_EQUAL(r, 0); - mailbox_close(&mailbox2); - mailbox_close(&mailbox1); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), 0); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurfette/cyrus.annotations"), 0); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path1)), 0); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path2)), 0); /* check that the values copied are present for both mailboxes */ buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 1, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 1, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME2_INT, 1, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox2, 1, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); @@ -1215,29 +1186,31 @@ static void test_msg_copy(void) * mailbox */ buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 2, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 2, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE1); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME1_INT, 3, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox1, 3, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE2); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME2_INT, 2, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox2, 2, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL(val2.s); buf_free(&val2); - r = annotatemore_msg_lookup(MBOXNAME2_INT, 3, COMMENT, /*userid*/"", &val2); + r = annotatemore_msg_lookup(mailbox2, 3, COMMENT, /*userid*/"", &val2); CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NULL(val2.s); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurf/cyrus.annotations"), 0); - CU_ASSERT_EQUAL(fexists(DBDIR"/data/user/smurfette/cyrus.annotations"), 0); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path1)), 0); + CU_ASSERT_EQUAL(fexists(buf_cstring(&path2)), 0); + buf_free(&path1); + buf_free(&path2); annotatemore_close(); @@ -1677,6 +1650,199 @@ static void test_getset_server_defined(void) buf_free(&val); } +static void test_getset_server_defined_ucase(void) +{ + int r; + annotate_state_t *astate = NULL; + strarray_t entries = STRARRAY_INITIALIZER; + strarray_t attribs = STRARRAY_INITIALIZER; + strarray_t results = STRARRAY_INITIALIZER; + struct entryattlist *ealist = NULL; + struct buf val = BUF_INITIALIZER; + struct buf val2 = BUF_INITIALIZER; + + /* set the definition in uppercase, as if pasted from rfc examples */ + set_annotation_definitions( + UCEXENTRY",server,string,backend,value.shared,\n"); + + annotate_init(NULL, NULL); + + annotatemore_open(); + + astate = annotate_state_new(); + r = annotate_state_set_server(astate); + CU_ASSERT_EQUAL(r, 0); + annotate_state_set_auth(astate, isadmin, userid, auth_state); + + /* should be able to set/get it in lowercase, because these are + * case insensitive! */ + strarray_append(&entries, EXENTRY); + strarray_append(&attribs, VALUE_SHARED); + strarray_append(&attribs, SIZE_SHARED); + + /* check that there is no value initially */ + + r = annotate_state_fetch(astate, + &entries, &attribs, + fetch_cb, &results); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL_FATAL(results.count, 1); +#define EXPECTED \ + "mboxname=\"\" " \ + "uid=0 " \ + "entry=\"" EXENTRY "\" " \ + VALUE_SHARED "=NIL " \ + SIZE_SHARED "=\"0\"" + CU_ASSERT_STRING_EQUAL(results.data[0], EXPECTED); +#undef EXPECTED + strarray_truncate(&results, 0); + + r = annotatemore_lookup(/*mboxname*/"", EXENTRY, /*userid*/"", &val); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NULL(val.s); + + /* set a value */ + + buf_appendcstr(&val, VALUE0); + setentryatt(&ealist, EXENTRY, VALUE_SHARED, &val); + annotate_state_set_auth(astate, /*isadmin*/1, userid, auth_state); + r = annotate_state_store(astate, ealist); + annotate_state_set_auth(astate, /*isadmin*/0, userid, auth_state); + CU_ASSERT_EQUAL(r, 0); + freeentryatts(ealist); + ealist = NULL; + + /* check that we can fetch the value back in the same txn */ + r = annotate_state_fetch(astate, + &entries, &attribs, + fetch_cb, &results); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL_FATAL(results.count, 1); +#define EXPECTED \ + "mboxname=\"\" " \ + "uid=0 " \ + "entry=\"" EXENTRY "\" " \ + VALUE_SHARED "=\"" VALUE0 "\" " \ + SIZE_SHARED "=\"" LENGTH0 "\"" + CU_ASSERT_STRING_EQUAL(results.data[0], EXPECTED); +#undef EXPECTED + strarray_truncate(&results, 0); + + r = annotatemore_lookup(/*mboxname*/"", EXENTRY, /*userid*/"", &val2); + CU_ASSERT_EQUAL_FATAL(r, 0); + CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); + CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); + buf_free(&val2); + + r = annotate_state_commit(&astate); + CU_ASSERT_EQUAL(r, 0); + + /* check that we can fetch the value back in a new txn */ + astate = annotate_state_new(); + r = annotate_state_set_server(astate); + CU_ASSERT_EQUAL(r, 0); + annotate_state_set_auth(astate, isadmin, userid, auth_state); + + r = annotate_state_fetch(astate, + &entries, &attribs, + fetch_cb, &results); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL_FATAL(results.count, 1); +#define EXPECTED \ + "mboxname=\"\" " \ + "uid=0 " \ + "entry=\"" EXENTRY "\" " \ + VALUE_SHARED "=\"" VALUE0 "\" " \ + SIZE_SHARED "=\"" LENGTH0 "\"" + CU_ASSERT_STRING_EQUAL(results.data[0], EXPECTED); +#undef EXPECTED + strarray_truncate(&results, 0); + + r = annotatemore_lookup(/*mboxname*/"", EXENTRY, /*userid*/"", &val2); + CU_ASSERT_EQUAL_FATAL(r, 0); + CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); + CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); + buf_free(&val2); + + annotate_state_abort(&astate); + annotatemore_close(); + + /* check that we can fetch the value back after close and re-open */ + + annotatemore_open(); + + astate = annotate_state_new(); + r = annotate_state_set_server(astate); + CU_ASSERT_EQUAL(r, 0); + annotate_state_set_auth(astate, isadmin, userid, auth_state); + + r = annotate_state_fetch(astate, + &entries, &attribs, + fetch_cb, &results); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL_FATAL(results.count, 1); +#define EXPECTED \ + "mboxname=\"\" " \ + "uid=0 " \ + "entry=\"" EXENTRY "\" " \ + VALUE_SHARED "=\"" VALUE0 "\" " \ + SIZE_SHARED "=\"" LENGTH0 "\"" + CU_ASSERT_STRING_EQUAL(results.data[0], EXPECTED); +#undef EXPECTED + strarray_truncate(&results, 0); + + r = annotatemore_lookup(/*mboxname*/"", EXENTRY, /*userid*/"", &val2); + CU_ASSERT_EQUAL_FATAL(r, 0); + CU_ASSERT_PTR_NOT_NULL_FATAL(val2.s); + CU_ASSERT_STRING_EQUAL(buf_cstring(&val2), VALUE0); + buf_free(&val2); + + buf_free(&val); + setentryatt(&ealist, EXENTRY, VALUE_SHARED, &val); + annotate_state_set_auth(astate, /*isadmin*/1, userid, auth_state); + r = annotate_state_store(astate, ealist); + annotate_state_set_auth(astate, /*isadmin*/0, userid, auth_state); + CU_ASSERT_EQUAL(r, 0); + freeentryatts(ealist); + ealist = NULL; + + r = annotate_state_commit(&astate); + CU_ASSERT_EQUAL(r, 0); + + /* check that there is no value any more */ + astate = annotate_state_new(); + r = annotate_state_set_server(astate); + CU_ASSERT_EQUAL(r, 0); + annotate_state_set_auth(astate, isadmin, userid, auth_state); + + r = annotate_state_fetch(astate, + &entries, &attribs, + fetch_cb, &results); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL_FATAL(results.count, 1); +#define EXPECTED \ + "mboxname=\"\" " \ + "uid=0 " \ + "entry=\"" EXENTRY "\" " \ + VALUE_SHARED "=NIL " \ + SIZE_SHARED "=\"0\"" + CU_ASSERT_STRING_EQUAL(results.data[0], EXPECTED); +#undef EXPECTED + strarray_truncate(&results, 0); + + r = annotatemore_lookup(/*mboxname*/"", EXENTRY, /*userid*/"", &val); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NULL(val.s); + + + annotate_state_abort(&astate); + annotatemore_close(); + + strarray_fini(&entries); + strarray_fini(&attribs); + strarray_fini(&results); + buf_free(&val); +} static const char *stringifyea(const struct entryattlist *ea) { const struct attvaluelist *av; @@ -1841,11 +2007,11 @@ static int create_messages(struct mailbox *mailbox, int count) struct buf buf = BUF_INITIALIZER; /* Write the message to the filesystem */ - if (!(fp = append_newstage(mailbox->name, internaldate, 0, &stage))) { - fprintf(stderr, "append_newstage(%s) failed", mailbox->name); - return r; + if (!(fp = append_newstage(mailbox_name(mailbox), internaldate, 0, &stage))) { + fprintf(stderr, "append_newstage(%s) failed", mailbox_name(mailbox)); + return IMAP_IOERROR; } - buf_printf(&buf, msgtmpl, i, mailbox->name, i, i, mailbox->name); + buf_printf(&buf, msgtmpl, i, mailbox_name(mailbox), i, i, mailbox_name(mailbox)); fwrite(buf_base(&buf), 1, buf_len(&buf), fp); buf_free(&buf); if (fclose(fp)) { @@ -1858,13 +2024,13 @@ static int create_messages(struct mailbox *mailbox, int count) r = append_setup_mbox(&as, mailbox, userid, auth_state, 0, qdiffs, 0, 0, EVENT_MESSAGE_NEW); if (r) { - fprintf(stderr, "append_setup_mbox(%s) failed: %s", mailbox->name, + fprintf(stderr, "append_setup_mbox(%s) failed: %s", mailbox_name(mailbox), strerror(errno)); return r; } r = append_fromstage(&as, &body, stage, internaldate, 0, NULL, 0, NULL); if (r) { - fprintf(stderr, "append_fromstage(%s) failed: %s", mailbox->name, + fprintf(stderr, "append_fromstage(%s) failed: %s", mailbox_name(mailbox), error_message(r)); append_abort(&as); return r; @@ -1875,7 +2041,7 @@ static int create_messages(struct mailbox *mailbox, int count) append_removestage(stage); r = append_commit(&as); if (r) { - fprintf(stderr, "append_commit(%s) failed: %s", mailbox->name, + fprintf(stderr, "append_commit(%s) failed: %s", mailbox_name(mailbox), error_message(r)); return r; } @@ -1884,6 +2050,114 @@ static int create_messages(struct mailbox *mailbox, int count) return 0; } +static void test_canon_value(void) +{ + struct { + const char *annot; + const char *attrb; + const char *value; + int is_invalid; + } tests[] = {{ + .annot = "/check", + .attrb = "value.shared", + .value = "true", + }, { + .annot = "/check", + .attrb = "value.shared", + .value = "false", + }, { + .annot = "/check", + .attrb = "value.shared", + .value = "yes", + .is_invalid = 1, + }, { + .annot = "/vendor/cmu/cyrus-imapd/sortorder", + .attrb = "value.priv", + .value = "123", + }, { + .annot = "/vendor/cmu/cyrus-imapd/sortorder", + .attrb = "value.priv", + .value = "12298189749871298739829873", + .is_invalid = 1 + }, { + .annot = "/vendor/cmu/cyrus-imapd/sortorder", + .attrb = "value.priv", + .value = "-1", + .is_invalid = 1 + }, { + .annot = "/vendor/cmu/cyrus-imapd/expire", + .attrb = "value.shared", + .value = "1", + }, { + .annot = "/vendor/cmu/cyrus-imapd/expire", + .attrb = "value.shared", + .value = "1s", + }, { + .annot = "/vendor/cmu/cyrus-imapd/expire", + .attrb = "value.shared", + .value = "1x", + .is_invalid = 1, + }, { + .annot = "/vendor/cmu/cyrus-imapd/archive", + .attrb = "value.shared", + .value = "1", + }, { + .annot = "/vendor/cmu/cyrus-imapd/archive", + .attrb = "value.shared", + .value = "1s", + }, { + .annot = "/vendor/cmu/cyrus-imapd/archive", + .attrb = "value.shared", + .value = "1x", + .is_invalid = 1, + }, { + .annot = "/vendor/cmu/cyrus-imapd/delete", + .attrb = "value.shared", + .value = "1", + }, { + .annot = "/vendor/cmu/cyrus-imapd/delete", + .attrb = "value.shared", + .value = "1s", + }, { + .annot = "/vendor/cmu/cyrus-imapd/delete", + .attrb = "value.shared", + .value = "1x", + .is_invalid = 1, + }}; + + struct mailbox *mailbox = NULL; + annotate_state_t *astate = NULL; + struct entryattlist *ealist = NULL; + struct buf val = BUF_INITIALIZER; + + annotate_init(NULL, NULL); + annotatemore_open(); + + int r = mailbox_open_iwl(MBOXNAME1_INT, &mailbox); + CU_ASSERT_EQUAL_FATAL(r, 0); + + astate = annotate_state_new(); + r = annotate_state_set_mailbox(astate, mailbox); + CU_ASSERT_EQUAL(r, 0); + annotate_state_set_auth(astate, isadmin, userid, auth_state); + + /* validate value */ + for (size_t i = 0; i < sizeof(tests)/sizeof(tests[0]); i++) { + buf_setcstr(&val, tests[i].value); + setentryatt(&ealist, tests[i].annot, tests[i].attrb, &val); + r = annotate_state_store(astate, ealist); + CU_ASSERT_EQUAL(r, tests[i].is_invalid ? IMAP_ANNOTATION_BADVALUE : 0); + freeentryatts(ealist); + ealist = NULL; + } + + buf_free(&val); + + annotate_state_abort(&astate); + mailbox_close(&mailbox); + annotatemore_close(); +} + static int set_up(void) { @@ -1896,9 +2170,6 @@ static int set_up(void) DBDIR"/db", DBDIR"/conf", DBDIR"/data", - DBDIR"/data/user", - DBDIR"/data/user/smurf", - DBDIR"/data/user/smurfette", NULL }; @@ -1940,68 +2211,86 @@ static int set_up(void) mboxlist_init(); mboxlist_open(NULL); + struct mboxlock *namespacelock = mboxname_usernamespacelock(MBOXNAME1_INT); + + /* XXX API abuse? perhaps this should just call mboxlist_create, + * XXX rather than the low level APIs. */ + r = mailbox_create(MBOXNAME1_INT, /*mbtype*/0, PARTITION, ACL, + makeuuid(), + /*options*/0, /*uidvalidity*/0, + /*createdmodseq*/0, + /*highestmodseq*/0, &mailbox); + if (r) + return r; + memset(&mbentry, 0, sizeof(mbentry)); mbentry.name = MBOXNAME1_INT; + mbentry.uniqueid = (char *)mailbox_uniqueid(mailbox); mbentry.mbtype = 0; mbentry.partition = PARTITION; mbentry.acl = ACL; - r = mboxlist_update(&mbentry, /*localonly*/1); + r = mboxlist_updatelock(&mbentry, /*localonly*/1); if (r) return r; - r = mailbox_create(MBOXNAME1_INT, /*mbtype*/0, PARTITION, ACL, - /*uniqueid*/NULL, - /*options*/0, /*uidvalidity*/0, - /*createdmodseq*/0, - /*highestmodseq*/0, &mailbox); + r = create_messages(mailbox, MBOX1_MAXUID); if (r) return r; + mailbox_close(&mailbox); - r = create_messages(mailbox, MBOX1_MAXUID); + mboxname_release(&namespacelock); + + namespacelock = mboxname_usernamespacelock(MBOXNAME2_INT); + + r = mailbox_create(MBOXNAME2_INT, /*mbtype*/0, PARTITION, ACL, + makeuuid(), + /*options*/0, /*uidvalidity*/0, + /*createdmodseq*/0, + /*highestmodseq*/0, &mailbox); if (r) return r; - mailbox_close(&mailbox); memset(&mbentry, 0, sizeof(mbentry)); mbentry.name = MBOXNAME2_INT; + mbentry.uniqueid = (char *)mailbox_uniqueid(mailbox); mbentry.mbtype = 0; mbentry.partition = PARTITION; mbentry.acl = ACL; - r = mboxlist_update(&mbentry, /*localonly*/1); + r = mboxlist_updatelock(&mbentry, /*localonly*/1); if (r) return r; - r = mailbox_create(MBOXNAME2_INT, /*mbtype*/0, PARTITION, ACL, - /*uniqueid*/NULL, - /*options*/0, /*uidvalidity*/0, - /*createdmodseq*/0, - /*highestmodseq*/0, &mailbox); + r = create_messages(mailbox, MBOX2_MAXUID); if (r) return r; + mailbox_close(&mailbox); - r = create_messages(mailbox, MBOX2_MAXUID); + mboxname_release(&namespacelock); + + namespacelock = mboxname_usernamespacelock(MBOXNAME3_INT); + + r = mailbox_create(MBOXNAME3_INT, /*mbtype*/0, PARTITION, ACL, + makeuuid(), + /*options*/0, /*uidvalidity*/0, + /*createdmodseq*/0, + /*highestmodseq*/0, &mailbox); if (r) return r; - mailbox_close(&mailbox); memset(&mbentry, 0, sizeof(mbentry)); mbentry.name = MBOXNAME3_INT; + mbentry.uniqueid = (char *)mailbox_uniqueid(mailbox); mbentry.mbtype = 0; mbentry.partition = PARTITION; mbentry.acl = ACL; - r = mboxlist_update(&mbentry, /*localonly*/1); + r = mboxlist_updatelock(&mbentry, /*localonly*/1); if (r) return r; - r = mailbox_create(MBOXNAME3_INT, /*mbtype*/0, PARTITION, ACL, - /*uniqueid*/NULL, - /*options*/0, /*uidvalidity*/0, - /*createdmodseq*/0, - /*highestmodseq*/0, &mailbox); - if (r) - return r; mailbox_close(&mailbox); + mboxname_release(&namespacelock); + old_annotation_definitions = imapopts[IMAPOPT_ANNOTATION_DEFINITIONS].val.s; @@ -2026,6 +2315,7 @@ static int tear_down(void) auth_freestate(auth_state); cyrusdb_done(); + config_reset(); config_mboxlist_db = NULL; config_annotation_db = NULL; diff --git a/cunit/arrayu64.testc b/cunit/arrayu64.testc new file mode 100644 index 0000000000..7c1bb8e003 --- /dev/null +++ b/cunit/arrayu64.testc @@ -0,0 +1,748 @@ +#include "cunit/cyrunit.h" +#include "xmalloc.h" +#include "bsearch.h" +#include "arrayu64.h" + +static void test_fini_null(void) +{ + /* _fini(NULL) is harmless */ + arrayu64_fini(NULL); + /* _free(NULL) is harmless */ + arrayu64_free(NULL); +} + +static void test_auto(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; + uint64_t u1; + uint64_t u2; + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + u1 = 1234567UL; + arrayu64_append(&a, u1); + CU_ASSERT_EQUAL(arrayu64_size(&a), 1); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), u1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), u1); + + u2 = 7654321UL; + arrayu64_append(&a, u2); + CU_ASSERT_EQUAL(arrayu64_size(&a), 2); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), u1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), u2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), u2); + + arrayu64_fini(&a); + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT_EQUAL(a.alloc, 0); + CU_ASSERT_PTR_NULL(a.data); +} + +static void test_heap(void) +{ + arrayu64_t *a = arrayu64_new(); + uint64_t u1; + uint64_t u2; + + CU_ASSERT_EQUAL(a->count, 0); + CU_ASSERT(a->alloc >= a->count); + CU_ASSERT_EQUAL(arrayu64_nth(a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(a, -1), 0UL); + + u1 = 1234567UL; + arrayu64_append(a, u1); + CU_ASSERT_EQUAL(a->count, 1); + CU_ASSERT(a->alloc >= a->count); + CU_ASSERT_PTR_NOT_NULL(a->data); + CU_ASSERT_EQUAL(arrayu64_nth(a, 0), u1); + CU_ASSERT_EQUAL(arrayu64_nth(a, -1), u1); + + u2 = 7654321UL; + arrayu64_append(a, u2); + CU_ASSERT_EQUAL(a->count, 2); + CU_ASSERT(a->alloc >= a->count); + CU_ASSERT_PTR_NOT_NULL(a->data); + CU_ASSERT_EQUAL(arrayu64_nth(a, 0), u1); + CU_ASSERT_EQUAL(arrayu64_nth(a, 1), u2); + CU_ASSERT_EQUAL(arrayu64_nth(a, -1), u2); + + arrayu64_free(a); +} + +static void test_set(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; +#define VAL0 (1234567UL) +#define VAL0REP (2345678UL) +#define VAL0REP2 (3456789UL) +#define VAL1 (1111111UL) +#define VAL2 (2222222UL) +#define VAL2REP (222UL) +#define VAL3 (3333333UL) +#define VAL4 (4444444UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + arrayu64_append(&a, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 1); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + + arrayu64_set(&a, 0, VAL0REP); + CU_ASSERT_EQUAL(arrayu64_size(&a), 1); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0REP); + + arrayu64_set(&a, -1, VAL0REP2); + CU_ASSERT_EQUAL(arrayu64_size(&a), 1); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0REP2); + + arrayu64_append(&a, VAL1); + arrayu64_append(&a, VAL2); + arrayu64_append(&a, VAL3); + arrayu64_append(&a, VAL4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 5); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0REP2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL4); + + arrayu64_set(&a, 2, VAL2REP); + CU_ASSERT_EQUAL(arrayu64_size(&a), 5); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0REP2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2REP); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL4); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL0REP +#undef VAL0REP2 +#undef VAL1 +#undef VAL2 +#undef VAL2REP +#undef VAL3 +#undef VAL4 +} + +static void test_insert(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; +#define VAL0 (111111UL) +#define VAL1 (222222UL) +#define VAL2 (333333UL) +#define VAL3 (444444UL) +#define VAL4 (555555UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + arrayu64_insert(&a, 0, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 1); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + + arrayu64_insert(&a, -1, VAL1); + CU_ASSERT_EQUAL(arrayu64_size(&a), 2); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL0); + + arrayu64_insert(&a, 0, VAL2); + CU_ASSERT_EQUAL(arrayu64_size(&a), 3); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL0); + + arrayu64_insert(&a, -1, VAL3); + CU_ASSERT_EQUAL(arrayu64_size(&a), 4); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL0); + + arrayu64_insert(&a, 2, VAL4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 5); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL4); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL0); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +#undef VAL3 +#undef VAL4 +} + +/* test that _set(), _setm(), _insert() and _insertm() of a bad + * index will fail silently and leave no side effects including + * memory leaks */ +static void test_bad_index(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; +#define VAL0 (111111UL) +#define VAL1 (222222UL) +#define VAL2 (333333UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + /* when the arrayu64 is empty, -1 is a bad index */ + + arrayu64_set(&a, -1, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + arrayu64_insert(&a, -1, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + /* a negative number larger than the (non-zero) count is a bad index */ + arrayu64_append(&a, VAL1); + arrayu64_append(&a, VAL2); + + arrayu64_set(&a, -4, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), VAL2); + + arrayu64_insert(&a, -4, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), VAL2); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +} + +/* test building a sparse array with _set() and _setm() */ +static void test_sparse_set(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; +#define VAL0 (1111111UL) +#define VAL1 (2222222UL) +#define VAL2 (3333333UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + arrayu64_set(&a, 3, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 4); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), VAL0); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +} + +static void test_remove(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; + uint64_t u; +#define VAL0 (1111111UL) +#define VAL1 (2222222UL) +#define VAL2 (3333333UL) +#define VAL3 (4444444UL) +#define VAL4 (5555555UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL1); + arrayu64_append(&a, VAL2); + arrayu64_append(&a, VAL3); + arrayu64_append(&a, VAL4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 5); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL4); + + u = arrayu64_remove(&a, 2); + CU_ASSERT_EQUAL(u, VAL2); + CU_ASSERT_EQUAL(arrayu64_size(&a), 4); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL4); + + u = arrayu64_remove(&a, 0); + CU_ASSERT_EQUAL(u, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 3); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL4); + + u = arrayu64_remove(&a, -1); + CU_ASSERT_EQUAL(u, VAL4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 2); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL3); + + u = arrayu64_remove(&a, 1); + CU_ASSERT_EQUAL(u, VAL3); + CU_ASSERT_EQUAL(arrayu64_size(&a), 1); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL1); + + u = arrayu64_remove(&a, 0); + CU_ASSERT_EQUAL(u, VAL1); + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + + u = arrayu64_remove(&a, 0); + CU_ASSERT_EQUAL(u, 0UL); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +#undef VAL3 +#undef VAL4 +} + +static void test_truncate(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; +#define VAL0 (1111111UL) +#define VAL1 (2222222UL) +#define VAL2 (3333333UL) +#define VAL3 (4444444UL) +#define VAL4 (5555555UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL1); + arrayu64_append(&a, VAL2); + arrayu64_append(&a, VAL3); + arrayu64_append(&a, VAL4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 5); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL4); + + /* expand the array */ + arrayu64_truncate(&a, 7); + CU_ASSERT_EQUAL(arrayu64_size(&a), 7); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL4); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 5), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 6), 0UL); + + /* shrink the array */ + arrayu64_truncate(&a, 4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 4); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + + /* shrink the array harder */ + arrayu64_truncate(&a, 3); + CU_ASSERT_EQUAL(arrayu64_size(&a), 3); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + + /* shrink the array to nothing */ + arrayu64_truncate(&a, 0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + /* whether a.data is NULL is undefined at this time */ + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +#undef VAL3 +#undef VAL4 +} + +static void test_find(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; + off_t i; +#define VAL0 (1111111UL) +#define VAL1 (2222222UL) +#define VAL2 (3333333UL) +#define VAL3 (4444444UL) +#define VAL4 (5555555UL) +#define NOTHERE (1234567UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL1); + arrayu64_append(&a, VAL2); + arrayu64_append(&a, VAL3); + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 6); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 5), VAL4); + + /* search for something which isn't there */ + i = arrayu64_find(&a, NOTHERE, 0); + CU_ASSERT_EQUAL(i, -1); + + /* search for something which isn't there, starting off the end */ + i = arrayu64_find(&a, NOTHERE, 7); + CU_ASSERT_EQUAL(i, -1); + + /* search for something which is there */ + i = arrayu64_find(&a, VAL1, 0); + CU_ASSERT_EQUAL(i, 1); + i = arrayu64_find(&a, VAL1, i+1); + CU_ASSERT_EQUAL(i, -1); + + /* search for something which is there, starting off the end */ + i = arrayu64_find(&a, VAL1, 7); + CU_ASSERT_EQUAL(i, -1); + + /* search for something which is there multiple times */ + i = arrayu64_find(&a, VAL0, 0); + CU_ASSERT_EQUAL(i, 0); + i = arrayu64_find(&a, VAL0, i+1); + CU_ASSERT_EQUAL(i, 4); + i = arrayu64_find(&a, VAL0, i+1); + CU_ASSERT_EQUAL(i, -1); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +#undef VAL3 +#undef VAL4 +#undef NOTHERE +} + +static void test_dup(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; + arrayu64_t *dup; +#define VAL0 (1111111UL) +#define VAL1 (2222222UL) +#define VAL2 (3333333UL) +#define VAL3 (4444444UL) +#define VAL4 (5555555UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + + /* dup an empty array */ + dup = arrayu64_dup(&a); + CU_ASSERT_PTR_NOT_NULL(dup); + CU_ASSERT_PTR_NOT_EQUAL(dup, &a); + CU_ASSERT_EQUAL(dup->count, 0); + CU_ASSERT(dup->alloc >= dup->count); + arrayu64_free(dup); + + /* dup a non-empty array */ + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL1); + arrayu64_append(&a, VAL2); + arrayu64_append(&a, VAL3); + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 6); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 5), VAL4); + + dup = arrayu64_dup(&a); + CU_ASSERT_PTR_NOT_NULL(dup); + CU_ASSERT_PTR_NOT_EQUAL(dup, &a); + CU_ASSERT_EQUAL(dup->count, 6); + CU_ASSERT(dup->alloc >= dup->count); + CU_ASSERT_PTR_NOT_NULL(dup->data); + CU_ASSERT_PTR_NOT_EQUAL(a.data, dup->data); + CU_ASSERT_EQUAL(arrayu64_nth(dup, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(dup, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(dup, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(dup, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(dup, 4), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(dup, 5), VAL4); + arrayu64_free(dup); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +#undef VAL3 +#undef VAL4 +} + +static void test_remove_all(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; +#define VAL0 (1111111UL) +#define VAL1 (2222222UL) +#define VAL2 (3333333UL) +#define VAL3 (4444444UL) +#define VAL4 (5555555UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + + /* removing from an empty array */ + arrayu64_remove_all(&a, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + + /* removing a single item from a non-empty array */ + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL1); + arrayu64_append(&a, VAL2); + arrayu64_append(&a, VAL3); + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL4); + CU_ASSERT_EQUAL(arrayu64_size(&a), 6); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 5), VAL4); + + arrayu64_remove_all(&a, VAL1); + CU_ASSERT_EQUAL(arrayu64_size(&a), 5); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL4); + + /* removing an item that appears more than once */ + arrayu64_remove_all(&a, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 3); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL4); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +#undef VAL3 +#undef VAL4 +} + +static void test_sort(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; +#define VAL0 (1111111UL) +#define VAL1 (2222222UL) +#define VAL2 (5555555UL) +#define VAL3 (4444444UL) +#define VAL4 (3333333UL) + + /* initialise */ + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL1); + arrayu64_append(&a, VAL2); + arrayu64_append(&a, VAL3); + arrayu64_append(&a, VAL4); + /* duplicates */ + arrayu64_append(&a, VAL0); + arrayu64_append(&a, VAL2); + arrayu64_append(&a, VAL1); + + CU_ASSERT_EQUAL(arrayu64_size(&a), 8); + + /* normal sort */ + arrayu64_sort(&a, NULL); + + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL4); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 5), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 6), VAL2); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 7), VAL2); + + /* uniq */ + arrayu64_uniq(&a); + CU_ASSERT_EQUAL(arrayu64_size(&a), 5); + + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL4); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 3), VAL3); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 4), VAL2); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +#undef VAL3 +#undef VAL4 +} + +static void test_add(void) +{ + arrayu64_t a = ARRAYU64_INITIALIZER; +#define VAL0 (1111111UL) +#define VAL1 (2222222UL) +#define VAL2 (3333333UL) + + CU_ASSERT_EQUAL(arrayu64_size(&a), 0); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), 0UL); + CU_ASSERT_EQUAL(arrayu64_nth(&a, -1), 0UL); + + /* _add() on an empty array appends */ + arrayu64_add(&a, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 1); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + + /* _add() of an item already present is a no-op */ + arrayu64_add(&a, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 1); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + + /* _add() of an item not already present appends */ + arrayu64_add(&a, VAL1); + CU_ASSERT_EQUAL(arrayu64_size(&a), 2); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + + /* _add() of an item already present is a no-op */ + arrayu64_add(&a, VAL0); + CU_ASSERT_EQUAL(arrayu64_size(&a), 2); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + + arrayu64_add(&a, VAL2); + CU_ASSERT_EQUAL(arrayu64_size(&a), 3); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + + arrayu64_add(&a, VAL1); + CU_ASSERT_EQUAL(arrayu64_size(&a), 3); + CU_ASSERT(a.alloc >= arrayu64_size(&a)); + CU_ASSERT_PTR_NOT_NULL(a.data); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 0), VAL0); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 1), VAL1); + CU_ASSERT_EQUAL(arrayu64_nth(&a, 2), VAL2); + + arrayu64_fini(&a); +#undef VAL0 +#undef VAL1 +#undef VAL2 +} + +/* vim: set ft=c: */ diff --git a/cunit/backend.testc b/cunit/backend.testc index b419dafe51..3d962eb356 100644 --- a/cunit/backend.testc +++ b/cunit/backend.testc @@ -14,10 +14,16 @@ #include "prot.h" #include "imap/backend.h" +#include "lib/libconfig.h" +#include "lib/libcyr_cfg.h" +#include "lib/util.h" +#include "lib/xstrlcpy.h" + +#define DBDIR "test-dbdir" + struct server_config { int sasl_plain; int sasl_login; - int sasl_digestmd5; int starttls; int deflate; int caps_one_per_line; @@ -39,6 +45,7 @@ struct server_state { /* global state */ int rend_sock; + char banner[64]; /* per-connection state */ int is_connected; @@ -52,7 +59,6 @@ struct server_state { struct protstream *out; }; - #define HOST "localhost" #define SASLSERVICE "vorpal" #define USERID "fbloggs" @@ -64,7 +70,6 @@ static struct server_state *server_state; static const struct server_config default_server_config = { .sasl_plain = 1, .sasl_login = 0, - .sasl_digestmd5 = 0, .starttls = 0, .deflate = 0, .caps_one_per_line = 1 @@ -75,6 +80,7 @@ static const struct capa_t default_capa[] = { { "CCCOMPRESS=DEFLATE", CAPA_COMPRESS }, { NULL, 0 } }; +static const char default_banner[] = "Toy Test server v0.0.1"; static char default_service[32]; static int init_sasl(int isclient); @@ -132,6 +138,10 @@ static struct protocol_t test_prot = } }; +static void server_set_banner(struct server_state *state, const char *str) +{ + strlcpy(state->banner, str ? str : default_banner, sizeof(state->banner)); +} /* * Setup default test conditions, on both the client and server. @@ -139,6 +149,7 @@ static struct protocol_t test_prot = static void default_conditions(void) { server_state->config = default_server_config; + server_set_banner(server_state, NULL); test_prot.service = default_service; memcpy(&test_prot.u.std.capa_cmd.capa, default_capa, sizeof(default_capa)); @@ -180,6 +191,329 @@ static void test_badservice(void) CU_ASSERT_EQUAL(server_state->is_connected, 0); } +/* + * Test that backend_version detects the remote version correctly. + */ +struct backend_version_data { + char banner[64]; + int version; +}; + +static void backend_version_common(void) +{ + size_t i, n_tests; + struct backend_version_data data[] = { + /* 2.3 changed index versions a few times */ + { "Cyrus IMAP v2.3.0", 7 }, + { "Cyrus IMAP v2.3.1", 7 }, + { "Cyrus IMAP v2.3.2", 7 }, + { "Cyrus IMAP v2.3.3", 7 }, + { "Cyrus IMAP v2.3.4", 8 }, + { "Cyrus IMAP v2.3.5", 8 }, + { "Cyrus IMAP v2.3.6", 8 }, + { "Cyrus IMAP v2.3.7", 9 }, + { "Cyrus IMAP v2.3.8", 9 }, + { "Cyrus IMAP v2.3.9", 9 }, + { "Cyrus IMAP v2.3.10", 10 }, + { "Cyrus IMAP v2.3.11", 10 }, + { "Cyrus IMAP v2.3.12", 10 }, + { "Cyrus IMAP v2.3.13", 10 }, + { "Cyrus IMAP v2.3.14", 10 }, + { "Cyrus IMAP v2.3.15", 10 }, + { "Cyrus IMAP v2.3.16", 10 }, + { "Cyrus IMAP v2.3.17", 10 }, + { "Cyrus IMAP v2.3.18", 10 }, + { "Cyrus IMAP v2.3.19", 10 }, + { "Cyrus IMAP v2.3.20", 10 }, + + /* 2.4 only had a single index version */ + { "Cyrus IMAP v2.4.0", 12 }, + { "Cyrus IMAP v2.4.1", 12 }, + { "Cyrus IMAP v2.4.2", 12 }, + { "Cyrus IMAP v2.4.3", 12 }, + { "Cyrus IMAP v2.4.4", 12 }, + { "Cyrus IMAP v2.4.5", 12 }, + { "Cyrus IMAP v2.4.6", 12 }, + { "Cyrus IMAP v2.4.7", 12 }, + { "Cyrus IMAP v2.4.8", 12 }, + { "Cyrus IMAP v2.4.9", 12 }, + { "Cyrus IMAP v2.4.10", 12 }, + { "Cyrus IMAP v2.4.11", 12 }, + { "Cyrus IMAP v2.4.12", 12 }, + { "Cyrus IMAP v2.4.13", 12 }, + { "Cyrus IMAP v2.4.14", 12 }, + { "Cyrus IMAP v2.4.15", 12 }, + { "Cyrus IMAP v2.4.16", 12 }, + { "Cyrus IMAP v2.4.17", 12 }, + { "Cyrus IMAP v2.4.18", 12 }, + { "Cyrus IMAP v2.4.19", 12 }, + { "Cyrus IMAP v2.4.20", 12 }, + + /* and a weirdly-named one, apparently? */ + { "Cyrus IMAP git2.4.0", 12 }, + + /* 2.5 just had one */ + { "Cyrus IMAP 2.5.0", 13 }, + { "Cyrus IMAP 2.5.1", 13 }, + { "Cyrus IMAP 2.5.2", 13 }, + { "Cyrus IMAP 2.5.3", 13 }, + { "Cyrus IMAP 2.5.4", 13 }, + { "Cyrus IMAP 2.5.5", 13 }, + { "Cyrus IMAP 2.5.6", 13 }, + { "Cyrus IMAP 2.5.7", 13 }, + { "Cyrus IMAP 2.5.8", 13 }, + { "Cyrus IMAP 2.5.8", 13 }, + { "Cyrus IMAP 2.5.10", 13 }, + { "Cyrus IMAP 2.5.11", 13 }, + { "Cyrus IMAP 2.5.12", 13 }, + { "Cyrus IMAP 2.5.13", 13 }, + { "Cyrus IMAP 2.5.14", 13 }, + { "Cyrus IMAP 2.5.15", 13 }, + + /* and some weirdly named ones? */ + { "Cyrus IMAP Murder 2.5.0", 13 }, + { "Cyrus IMAP git2.5.0", 13 }, + + /* 3.0 had just one */ + { "Cyrus IMAP 3.0.0", 13 }, + { "Cyrus IMAP 3.0.1", 13 }, + { "Cyrus IMAP 3.0.2", 13 }, + { "Cyrus IMAP 3.0.3", 13 }, + { "Cyrus IMAP 3.0.4", 13 }, + { "Cyrus IMAP 3.0.5", 13 }, + { "Cyrus IMAP 3.0.6", 13 }, + { "Cyrus IMAP 3.0.7", 13 }, + { "Cyrus IMAP 3.0.8", 13 }, + { "Cyrus IMAP 3.0.8", 13 }, + { "Cyrus IMAP 3.0.10", 13 }, + { "Cyrus IMAP 3.0.11", 13 }, + { "Cyrus IMAP 3.0.12", 13 }, + { "Cyrus IMAP 3.0.13", 13 }, + + /* 3.1 supported 13..16 but we don't bother detecting that microscopically */ + /* n.b. there was no 3.1.0 */ + { "Cyrus IMAP 3.1.1", 13 }, + { "Cyrus IMAP 3.1.2", 13 }, + { "Cyrus IMAP 3.1.3", 13 }, + { "Cyrus IMAP 3.1.4", 13 }, + { "Cyrus IMAP 3.1.5", 13 }, + { "Cyrus IMAP 3.1.6", 13 }, + { "Cyrus IMAP 3.1.7", 13 }, + { "Cyrus IMAP 3.1.8", 13 }, + { "Cyrus IMAP 3.1.9", 13 }, + + /* 3.2 had just one */ + { "Cyrus IMAP 3.2.0", 16 }, + { "Cyrus IMAP 3.2.1", 16 }, + { "Cyrus IMAP 3.2.2", 16 }, + { "Cyrus IMAP 3.2.3", 16 }, + { "Cyrus IMAP 3.2.4", 16 }, + { "Cyrus IMAP 3.2.5", 16 }, + { "Cyrus IMAP 3.2.6", 16 }, + { "Cyrus IMAP 3.2.7", 16 }, + { "Cyrus IMAP 3.2.8", 16 }, + { "Cyrus IMAP 3.2.9", 16 }, + + /* 3.3 had just one */ + { "Cyrus IMAP 3.3.0", 17 }, + { "Cyrus IMAP 3.3.1", 17 }, + { "Cyrus IMAP 3.3.2", 17 }, + { "Cyrus IMAP 3.3.3", 17 }, + { "Cyrus IMAP 3.3.4", 17 }, + { "Cyrus IMAP 3.3.5", 17 }, + { "Cyrus IMAP 3.3.6", 17 }, + { "Cyrus IMAP 3.3.7", 17 }, + { "Cyrus IMAP 3.3.8", 17 }, + { "Cyrus IMAP 3.3.9", 17 }, + + /* 3.4 had just one */ + { "Cyrus IMAP 3.4.0", 17 }, + { "Cyrus IMAP 3.4.1", 17 }, + { "Cyrus IMAP 3.4.2", 17 }, + { "Cyrus IMAP 3.4.3", 17 }, + { "Cyrus IMAP 3.4.4", 17 }, + { "Cyrus IMAP 3.4.5", 17 }, + { "Cyrus IMAP 3.4.6", 17 }, + { "Cyrus IMAP 3.4.7", 17 }, + { "Cyrus IMAP 3.4.8", 17 }, + { "Cyrus IMAP 3.4.9", 17 }, + + /* 3.5 had just one */ + { "Cyrus IMAP 3.5.0", 17 }, + { "Cyrus IMAP 3.5.1", 17 }, + { "Cyrus IMAP 3.5.2", 17 }, + { "Cyrus IMAP 3.5.3", 17 }, + { "Cyrus IMAP 3.5.4", 17 }, + { "Cyrus IMAP 3.5.5", 17 }, + { "Cyrus IMAP 3.5.6", 17 }, + { "Cyrus IMAP 3.5.7", 17 }, + { "Cyrus IMAP 3.5.8", 17 }, + { "Cyrus IMAP 3.5.9", 17 }, + + /* 3.6 had just one */ + { "Cyrus IMAP 3.6.0", 17 }, + { "Cyrus IMAP 3.6.1", 17 }, + { "Cyrus IMAP 3.6.2", 17 }, + { "Cyrus IMAP 3.6.3", 17 }, + { "Cyrus IMAP 3.6.4", 17 }, + { "Cyrus IMAP 3.6.5", 17 }, + { "Cyrus IMAP 3.6.6", 17 }, + { "Cyrus IMAP 3.6.7", 17 }, + { "Cyrus IMAP 3.6.8", 17 }, + { "Cyrus IMAP 3.6.9", 17 }, + + /* 3.7 had just one */ + { "Cyrus IMAP 3.7.0", 17 }, + { "Cyrus IMAP 3.7.1", 17 }, + { "Cyrus IMAP 3.7.2", 17 }, + { "Cyrus IMAP 3.7.3", 17 }, + { "Cyrus IMAP 3.7.4", 17 }, + { "Cyrus IMAP 3.7.5", 17 }, + { "Cyrus IMAP 3.7.6", 17 }, + { "Cyrus IMAP 3.7.7", 17 }, + { "Cyrus IMAP 3.7.8", 17 }, + { "Cyrus IMAP 3.7.9", 17 }, + + /* 3.8 had just one */ + { "Cyrus IMAP 3.8.0", 17 }, + { "Cyrus IMAP 3.8.1", 17 }, + { "Cyrus IMAP 3.8.2", 17 }, + { "Cyrus IMAP 3.8.3", 17 }, + { "Cyrus IMAP 3.8.4", 17 }, + { "Cyrus IMAP 3.8.5", 17 }, + { "Cyrus IMAP 3.8.6", 17 }, + { "Cyrus IMAP 3.8.7", 17 }, + { "Cyrus IMAP 3.8.8", 17 }, + { "Cyrus IMAP 3.8.9", 17 }, + + /* 3.9 supported 17..?? but we don't bother detecting that microscopically */ + { "Cyrus IMAP 3.9.0", 17 }, + { "Cyrus IMAP 3.9.1", 17 }, + { "Cyrus IMAP 3.9.2", 17 }, + { "Cyrus IMAP 3.9.3", 17 }, + { "Cyrus IMAP 3.9.4", 17 }, + { "Cyrus IMAP 3.9.5", 17 }, + { "Cyrus IMAP 3.9.6", 17 }, + { "Cyrus IMAP 3.9.7", 17 }, + { "Cyrus IMAP 3.9.8", 17 }, + { "Cyrus IMAP 3.9.9", 17 }, + + /* better test some pre-release version strings too, would be bad if + * the parser chokes on them! + */ + { "Cyrus IMAP 3.0.0-rc1", 13 }, + { "Cyrus IMAP 3.2.0-beta4", 16 }, + { "Cyrus IMAP 3.6.0-beta2", 17 }, + { "Cyrus IMAP 3.7.0-alpha0-1155-gf86d12f44a", 17 }, + }; + + /* XXX there's something wrong with the toy test server that results in + * XXX the banner not being set with auto_capa enabled??? + */ + test_prot.u.std.banner.auto_capa = 0; + + n_tests = sizeof(data) / sizeof(data[0]); + + CU_ASSERT_NOT_EQUAL_FATAL(n_tests, 0); + + for (i = 0; i < n_tests; i++) { + struct backend *be; + const char *auth_status = NULL; + int got_version; + + server_set_banner(server_state, data[i].banner); + + be = backend_connect(NULL, HOST, &test_prot, + USERID, callbacks, &auth_status, /*fd*/-1); + CU_ASSERT_PTR_NOT_NULL_FATAL(be); + CU_ASSERT_EQUAL(server_state->is_connected, 1); + CU_ASSERT_EQUAL(server_state->is_authenticated, 1); + CU_ASSERT_EQUAL(server_state->is_tls, 0); + + got_version = backend_version(be); + CU_ASSERT_EQUAL(got_version, data[i].version); + + backend_disconnect(be); + free(be); + } +} + +static void test_backend_version_multiline_caps(void) +{ + default_conditions(); + server_state->config.caps_one_per_line = 1; + test_prot.u.std.capa_cmd.formatflags = CAPAF_ONE_PER_LINE|CAPAF_SKIP_FIRST_WORD; + backend_version_common(); +} + +static void test_backend_version_oneline_caps(void) +{ + default_conditions(); + server_state->config.caps_one_per_line = 0; + test_prot.u.std.capa_cmd.formatflags = CAPAF_MANY_PER_LINE; + backend_version_common(); +} + +static void unrecognised_backend_version_common(void) +{ + size_t i, n_tests; + struct backend_version_data data[] = { + { "BOGUS", 6 }, /* completely unrecognised */ + { "Cyrus IMAP v2.3.B (OGUS)", 6 }, /* junk 2.3 revision */ + { "Cyrus IMAP 10.0.0", MAILBOX_MINOR_VERSION }, /* unknown future version */ + { "Cyrus IMAP 10.0.0-rc1", MAILBOX_MINOR_VERSION }, + { "Cyrus IMAP 10.0.0-alpha0-57-g1f6140ee54", MAILBOX_MINOR_VERSION }, + }; + + /* XXX there's something wrong with the toy test server that results in + * XXX the banner not being set with auto_capa enabled??? + */ + test_prot.u.std.banner.auto_capa = 0; + + n_tests = sizeof(data) / sizeof(data[0]); + + CU_ASSERT_NOT_EQUAL_FATAL(n_tests, 0); + + for (i = 0; i < n_tests; i++) { + struct backend *be; + const char *auth_status = NULL; + int got_version; + + server_set_banner(server_state, data[i].banner); + + be = backend_connect(NULL, HOST, &test_prot, + USERID, callbacks, &auth_status, /*fd*/-1); + CU_ASSERT_PTR_NOT_NULL_FATAL(be); + CU_ASSERT_EQUAL(server_state->is_connected, 1); + CU_ASSERT_EQUAL(server_state->is_authenticated, 1); + CU_ASSERT_EQUAL(server_state->is_tls, 0); + + CU_SYSLOG_MATCH("Assuming index version [0-9]+!"); + got_version = backend_version(be); + CU_ASSERT_EQUAL(got_version, data[i].version); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + backend_disconnect(be); + free(be); + } +} + +static void test_unrecognised_backend_version_multiline_caps(void) +{ + default_conditions(); + server_state->config.caps_one_per_line = 1; + test_prot.u.std.capa_cmd.formatflags = CAPAF_ONE_PER_LINE|CAPAF_SKIP_FIRST_WORD; + unrecognised_backend_version_common(); +} + +static void test_unrecognised_backend_version_oneline_caps(void) +{ + default_conditions(); + server_state->config.caps_one_per_line = 0; + test_prot.u.std.capa_cmd.formatflags = CAPAF_MANY_PER_LINE; + unrecognised_backend_version_common(); +} + /* * Test authenticating with the PLAIN mechanism. */ @@ -246,38 +580,6 @@ static void not_test_sasl_login(void) } #endif -/* - * Test authenticating with the DIGEST-MD5 mechanism. - */ -static void test_sasl_digestmd5(void) -{ - struct backend *be; - const char *auth_status = NULL; - char *mechs; - int r; - - default_conditions(); - server_state->config.sasl_plain = 0; - server_state->config.sasl_digestmd5 = 1; - - be = backend_connect(NULL, HOST, &test_prot, - USERID, callbacks, &auth_status, /*fd*/-1); - CU_ASSERT_PTR_NOT_NULL_FATAL(be); - CU_ASSERT_EQUAL(server_state->is_connected, 1); - CU_ASSERT_EQUAL(server_state->is_authenticated, 1); - CU_ASSERT_EQUAL(server_state->is_tls, 0); - - mechs = backend_get_cap_params(be, CAPA_AUTH); - CU_ASSERT_STRING_EQUAL(mechs, "DIGEST-MD5"); - free(mechs); - - r = backend_ping(be, NULL); - CU_ASSERT_EQUAL(r, 0); - - backend_disconnect(be); - free(be); -} - /* Common routine to test the semantics of capabilities */ static void caps_common(void) { @@ -621,10 +923,9 @@ static void server_unaccept(struct server_state *state) /* * Main routine for pushing text back to the client. */ -static void server_printf(struct server_state *, const char *fmt, ...) - __attribute__((format(printf,2,3))); - -static void server_printf(struct server_state *state, const char *fmt, ...) +static void +__attribute__((format(printf, 2, 3))) +server_printf(struct server_state *state, const char *fmt, ...) { va_list args; int r; @@ -693,7 +994,6 @@ static void server_emit_caps(struct server_state *state) * NAME, VALUE, NULL, * NULL */ char line[1024]; - static const char banner[] = "Toy Test server v0.0.1"; /* * The ccSASL cap reports a list of SASL mechanism names. @@ -702,7 +1002,6 @@ static void server_emit_caps(struct server_state *state) if (!state->config.starttls || state->is_tls) { int got_login = 0; int got_plain = 0; - int got_digestmd5 = 0; /* First see what mechanisms SASL has; no point reporting * mechanisms which aren't actually available. */ @@ -719,14 +1018,10 @@ static void server_emit_caps(struct server_state *state) words[n++] = "LOGIN"; got_login = 1; } - if (!strcasecmp(p, "LOGIN") && state->config.sasl_plain) { + if (!strcasecmp(p, "PLAIN") && state->config.sasl_plain) { words[n++] = "PLAIN"; got_plain = 1; } - if (!strcasecmp(p, "DIGEST-MD5") && state->config.sasl_digestmd5) { - words[n++] = "DIGEST-MD5"; - got_digestmd5 = 1; - } } words[n++] = NULL; free(b); @@ -737,9 +1032,6 @@ static void server_emit_caps(struct server_state *state) if (state->config.sasl_plain && !got_plain) fprintf(stderr, "Server failed to find requested " "SASL mechanism \"PLAIN\"\n"); - if (state->config.sasl_digestmd5 && !got_digestmd5) - fprintf(stderr, "Server failed to find requested " - "SASL mechanism \"DIGEST-MD5\"\n"); } /* @@ -804,7 +1096,7 @@ static void server_emit_caps(struct server_state *state) n++; server_printf(state, "* %s%s\r\n", name, line); } - server_printf(state, "OK %s\r\n", banner); + server_printf(state, "OK %s server ready\r\n", state->banner); } else { /* One-line, NAME=VALUE pairs inside IMAP-style response code */ line[0] = '\0'; @@ -822,7 +1114,8 @@ static void server_emit_caps(struct server_state *state) } n++; } - server_printf(state, "OK [CAPABILITY%s] %s\r\n", line, banner); + server_printf(state, "OK [CAPABILITY%s] %s server ready\r\n", + line, state->banner); } server_flush(state); } @@ -1127,8 +1420,7 @@ static AUXPROP_RTYPE server_auxprop_lookup(void *glob_context __attribute__((unu if (!prop) return AUXPROP_RET; for ( ; prop->name ; prop++) { - if (!strcmp(prop->name, "*userPassword") || - !strcmp(prop->name, "*cmusaslsecretDIGEST-MD5")) { + if (!strcmp(prop->name, "*userPassword")) { if (prop->values) sparams->utils->prop_erase(sparams->propctx, prop->name); sparams->utils->prop_set(sparams->propctx, prop->name, @@ -1141,7 +1433,7 @@ static AUXPROP_RTYPE server_auxprop_lookup(void *glob_context __attribute__((unu /* * Helps create a fake "auxiliary property plugin" for the SASL library, - * which is how we hook into the DIGEST-MD5 mechanism when it wants to + * which is how we hook into the DIGEST-MD5? mechanism when it wants to * get a plaintext password to check against the hash received from the * client. */ @@ -1193,8 +1485,10 @@ static int init_sasl(int isclient) } callbacks = mysasl_callbacks(USERID, USERID, NULL, PASSWORD); - if (!callbacks) + if (!callbacks) { + fprintf(stderr, "could not init sasl client callbacks\n"); return -1; + } } else { static const struct sasl_callback server_cb[] = { { SASL_CB_SERVER_USERDB_CHECKPASS, (void *)&server_checkpass, NULL }, @@ -1219,11 +1513,6 @@ static int init_sasl(int isclient) static struct server_state *server_state; static pid_t server_pid; static int sasl_initialised; -static int old_session_timeout; -static char *old_config_dir; -static char *old_tls_ca_file; -static char *old_tls_cert_file; -static char *old_tls_key_file; /* * Test suite setup function. Sets up the global @@ -1235,7 +1524,8 @@ static int set_up(void) { int rend_sock, port = 0; char *sr; - static char cwd[PATH_MAX]; + char cwd[PATH_MAX]; + struct buf myconfig = BUF_INITIALIZER; if (verbose > 1) fprintf(stderr, "Starting server!\n"); @@ -1246,24 +1536,20 @@ static int set_up(void) return -1; } - old_config_dir = (char *)config_dir; - config_dir = xstrdup(cwd); - - old_tls_ca_file = (char *)imapopts[IMAPOPT_TLS_SERVER_CA_FILE].val.s; - imapopts[IMAPOPT_TLS_SERVER_CA_FILE].val.s = - strconcat(cwd, "/cacert.pem", (char *)NULL); - - old_tls_cert_file = (char *)imapopts[IMAPOPT_TLS_SERVER_CERT].val.s; - imapopts[IMAPOPT_TLS_SERVER_CERT].val.s = - strconcat(cwd, "/cert.pem", (char *)NULL); + /* we need config for these tests */ + libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); + buf_printf(&myconfig, "configdirectory: %s/conf\n", DBDIR); - old_tls_key_file = (char *)imapopts[IMAPOPT_TLS_SERVER_KEY].val.s; - imapopts[IMAPOPT_TLS_SERVER_KEY].val.s = - strconcat(cwd, "/key.pem", (char *)NULL); + /* certs are in current directory */ + buf_printf(&myconfig, "tls_server_ca_file: %s/cacert.pm\n", cwd); + buf_printf(&myconfig, "tls_server_cert: %s/cert.pem\n", cwd); + buf_printf(&myconfig, "tls_server_key: %s/key.pem\n", cwd); /* disable SSL session caching */ - old_session_timeout = imapopts[IMAPOPT_TLS_SESSION_TIMEOUT].val.i; - imapopts[IMAPOPT_TLS_SESSION_TIMEOUT].val.i = 0; + buf_printf(&myconfig, "tls_session_timeout: 0\n"); + + config_read_string(buf_cstring(&myconfig)); + buf_free(&myconfig); rend_sock = create_server_socket(&port); if (rend_sock < 0) @@ -1305,6 +1591,10 @@ static int set_up(void) */ static int tear_down(void) { + int r; + + config_reset(); + if (verbose > 1) fprintf(stderr, "Cleaning up server! NOT.\n"); if (server_pid > 1) @@ -1320,21 +1610,9 @@ static int tear_down(void) sasl_initialised = 0; } - free((char *)config_dir); - config_dir = old_config_dir; - - imapopts[IMAPOPT_TLS_SESSION_TIMEOUT].val.i = old_session_timeout; - - free((char *)imapopts[IMAPOPT_TLS_SERVER_CA_FILE].val.s); - imapopts[IMAPOPT_TLS_SERVER_CA_FILE].val.s = old_tls_ca_file; + r = system("rm -rf " DBDIR); - free((char *)imapopts[IMAPOPT_TLS_SERVER_CERT].val.s); - imapopts[IMAPOPT_TLS_SERVER_CERT].val.s = old_tls_cert_file; - - free((char *)imapopts[IMAPOPT_TLS_SERVER_KEY].val.s); - imapopts[IMAPOPT_TLS_SERVER_KEY].val.s = old_tls_key_file; - - return 0; + return r; } /* ====================================================================== */ diff --git a/cunit/binhex.testc b/cunit/binhex.testc index 4dcf660d73..aee68c89a1 100644 --- a/cunit/binhex.testc +++ b/cunit/binhex.testc @@ -1,17 +1,58 @@ #include "cunit/cyrunit.h" #include "util.h" +#define ASSERT_TO_HEX(BIN, HEX, hex, flags) \ +{ \ + memset(hex, 0x45, sizeof(hex)); \ + int r = bin_to_hex(BIN, sizeof(BIN), hex, flags); \ + CU_ASSERT_EQUAL(r, sizeof(hex)-1); \ + CU_ASSERT_STRING_EQUAL(hex, HEX); \ + \ + if (flags == BH_LOWER) { \ + r = bin_to_lchex(BIN, sizeof(BIN), hex); \ + CU_ASSERT_EQUAL(r, sizeof(hex)-1); \ + CU_ASSERT_STRING_EQUAL(hex, HEX); \ + } \ + \ + struct buf buf = BUF_INITIALIZER; \ + r = buf_bin_to_hex(&buf, BIN, sizeof(BIN), flags); \ + CU_ASSERT_EQUAL(r, sizeof(hex)-1); \ + CU_ASSERT_EQUAL(r, buf_len(&buf)); \ + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), HEX); \ + buf_reset(&buf); \ + \ + if (flags == BH_LOWER) { \ + r = buf_bin_to_lchex(&buf, BIN, sizeof(BIN)); \ + CU_ASSERT_EQUAL(r, sizeof(hex)-1); \ + CU_ASSERT_EQUAL(r, buf_len(&buf)); \ + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), HEX); \ + buf_reset(&buf); \ + } \ + buf_free(&buf); \ +} + + +#define ASSERT_TO_BIN(HEX, BIN, bin) \ +{ \ + memset(bin, 0xff, sizeof(bin)); \ + int r = hex_to_bin(HEX, sizeof(HEX)-1, bin); \ + CU_ASSERT_EQUAL(r, sizeof(bin)); \ + CU_ASSERT_EQUAL(memcmp(bin, BIN, sizeof(bin)), 0); \ + \ + struct buf buf = BUF_INITIALIZER; \ + r = buf_hex_to_bin(&buf, HEX, sizeof(HEX)-1); \ + CU_ASSERT_EQUAL(r, sizeof(bin)); \ + CU_ASSERT_EQUAL(r, buf_len(&buf)); \ + CU_ASSERT_EQUAL(memcmp(buf_base(&buf), BIN, buf_len(&buf)), 0); \ + buf_free(&buf); \ +} + static void test_bin_to_hex(void) { static const unsigned char BIN[4] = { 0xca, 0xfe, 0xba, 0xbe }; static const char HEX[9] = "cafebabe"; - int r; char hex[9]; - - memset(hex, 0x45, sizeof(hex)); - r = bin_to_hex(BIN, sizeof(BIN), hex, BH_LOWER); - CU_ASSERT_EQUAL(r, sizeof(hex)-1); - CU_ASSERT_STRING_EQUAL(hex, HEX); + ASSERT_TO_HEX(BIN, HEX, hex, BH_LOWER); } static void test_bin_to_hex_long(void) @@ -21,71 +62,59 @@ static void test_bin_to_hex_long(void) 0x6f,0x9f,0xfa,0x77,0xe4,0x04,0x84,0x04,0xa0,0x02 }; static const char HEX[41] = "33ac18b6dc746e9ad7bd6f9ffa77e4048404a002"; - int r; char hex[41]; - - memset(hex, 0x45, sizeof(hex)); - r = bin_to_hex(BIN, sizeof(BIN), hex, BH_LOWER); - CU_ASSERT_EQUAL(r, sizeof(hex)-1); - CU_ASSERT_STRING_EQUAL(hex, HEX); - r = bin_to_lchex(BIN, sizeof(BIN), hex); - CU_ASSERT_EQUAL(r, sizeof(hex)-1); - CU_ASSERT_STRING_EQUAL(hex, HEX); + ASSERT_TO_HEX(BIN, HEX, hex, BH_LOWER); } static void test_bin_to_hex_short(void) { static const unsigned char BIN[1] = { 0x42 }; static const char HEX[3] = "42"; - int r; char hex[3]; - - memset(hex, 0x45, sizeof(hex)); - r = bin_to_hex(BIN, sizeof(BIN), hex, BH_LOWER); - CU_ASSERT_EQUAL(r, sizeof(hex)-1); - CU_ASSERT_STRING_EQUAL(hex, HEX); - r = bin_to_lchex(BIN, sizeof(BIN), hex); - CU_ASSERT_EQUAL(r, sizeof(hex)-1); - CU_ASSERT_STRING_EQUAL(hex, HEX); + ASSERT_TO_HEX(BIN, HEX, hex, BH_LOWER); } static void test_bin_to_hex_sep(void) { static const unsigned char BIN[4] = { 0xca, 0xfe, 0xba, 0xbe }; static const char HEX[12] = "ca:fe:ba:be"; - int r; char hex[12]; + ASSERT_TO_HEX(BIN, HEX, hex, (BH_LOWER|BH_SEPARATOR(':'))); +} - memset(hex, 0x45, sizeof(hex)); - r = bin_to_hex(BIN, sizeof(BIN), hex, BH_LOWER|BH_SEPARATOR(':')); - CU_ASSERT_EQUAL(r, sizeof(hex)-1); - CU_ASSERT_STRING_EQUAL(hex, HEX); +static void test_bin_to_hex_realloc(void) +{ + struct buf buf = BUF_INITIALIZER; + for (int i = 0; i < 30; i++) { + buf_putc(&buf, 'x'); + } + CU_ASSERT_EQUAL(30, buf.len); + CU_ASSERT_EQUAL(32, buf.alloc); + CU_ASSERT_PTR_NOT_NULL(buf.s); + + char c = 0xac; + buf_bin_to_hex(&buf, &c, 1, BH_UPPER); + CU_ASSERT_EQUAL(32, buf.len); + CU_ASSERT_EQUAL(64, buf.alloc); + CU_ASSERT_STRING_EQUAL("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAC", buf_cstring(&buf)); + + buf_free(&buf); } static void test_hex_to_bin(void) { static const char HEX[9] = "cafebabe"; static const unsigned char BIN[4] = { 0xca, 0xfe, 0xba, 0xbe }; - int r; char bin[4]; - - memset(bin, 0xff, sizeof(bin)); - r = hex_to_bin(HEX, sizeof(HEX)-1, bin); - CU_ASSERT_EQUAL(r, sizeof(bin)); - CU_ASSERT_EQUAL(memcmp(bin, BIN, sizeof(bin)), 0); + ASSERT_TO_BIN(HEX, BIN, bin); } static void test_hex_to_bin_short(void) { static const char HEX[3] = "42"; static const unsigned char BIN[1] = { 0x42 }; - int r; char bin[1]; - - memset(bin, 0xff, sizeof(bin)); - r = hex_to_bin(HEX, sizeof(HEX)-1, bin); - CU_ASSERT_EQUAL(r, sizeof(bin)); - CU_ASSERT_EQUAL(memcmp(bin, BIN, sizeof(bin)), 0); + ASSERT_TO_BIN(HEX, BIN, bin); } static void test_hex_to_bin_long(void) @@ -95,26 +124,16 @@ static void test_hex_to_bin_long(void) 0x33,0xac,0x18,0xb6,0xdc,0x74,0x6e,0x9a,0xd7,0xbd, 0x6f,0x9f,0xfa,0x77,0xe4,0x04,0x84,0x04,0xa0,0x02 }; - int r; char bin[20]; - - memset(bin, 0xff, sizeof(bin)); - r = hex_to_bin(HEX, sizeof(HEX)-1, bin); - CU_ASSERT_EQUAL(r, sizeof(bin)); - CU_ASSERT_EQUAL(memcmp(bin, BIN, sizeof(bin)), 0); + ASSERT_TO_BIN(HEX, BIN, bin); } static void test_hex_to_bin_capitals(void) { static const char HEX[9] = "CAFEBABE"; static const unsigned char BIN[4] = { 0xca, 0xfe, 0xba, 0xbe }; - int r; char bin[4]; - - memset(bin, 0xff, sizeof(bin)); - r = hex_to_bin(HEX, sizeof(HEX)-1, bin); - CU_ASSERT_EQUAL(r, sizeof(bin)); - CU_ASSERT_EQUAL(memcmp(bin, BIN, sizeof(bin)), 0); + ASSERT_TO_BIN(HEX, BIN, bin); } static void test_hex_to_bin_odd(void) @@ -130,6 +149,12 @@ static void test_hex_to_bin_odd(void) CU_ASSERT_EQUAL(bin[1], 0xff); CU_ASSERT_EQUAL(bin[2], 0xff); CU_ASSERT_EQUAL(bin[3], 0xff); + + struct buf buf = BUF_INITIALIZER; + r = buf_hex_to_bin(&buf, HEX, sizeof(HEX)-1); + CU_ASSERT_EQUAL(r, -1); + CU_ASSERT_EQUAL(buf_len(&buf), 0); + buf_free(&buf); } static void test_hex_to_bin_nonxdigit(void) @@ -141,6 +166,12 @@ static void test_hex_to_bin_nonxdigit(void) memset(bin, 0xff, sizeof(bin)); r = hex_to_bin(HEX, sizeof(HEX)-1, bin); CU_ASSERT_EQUAL(r, -1); + + struct buf buf = BUF_INITIALIZER; + r = buf_hex_to_bin(&buf, HEX, sizeof(HEX)-1); + CU_ASSERT_EQUAL(r, -1); + CU_ASSERT_EQUAL(buf_len(&buf), 0); + buf_free(&buf); } static void test_hex_to_bin_whitespace(void) @@ -152,19 +183,20 @@ static void test_hex_to_bin_whitespace(void) memset(bin, 0xff, sizeof(bin)); r = hex_to_bin(HEX, sizeof(HEX)-1, bin); CU_ASSERT_EQUAL(r, -1); + + struct buf buf = BUF_INITIALIZER; + r = buf_hex_to_bin(&buf, HEX, sizeof(HEX)-1); + CU_ASSERT_EQUAL(r, -1); + CU_ASSERT_EQUAL(buf_len(&buf), 0); + buf_free(&buf); } static void test_hex_to_bin_nolength(void) { static const char HEX[9] = "cafebabe"; static const unsigned char BIN[4] = { 0xca, 0xfe, 0xba, 0xbe }; - int r; char bin[4]; - - memset(bin, 0xff, sizeof(bin)); - r = hex_to_bin(HEX, 0, bin); - CU_ASSERT_EQUAL(r, sizeof(bin)); - CU_ASSERT_EQUAL(memcmp(bin, BIN, sizeof(bin)), 0); + ASSERT_TO_BIN(HEX, BIN, bin); } static void test_hex_to_bin_null(void) @@ -176,6 +208,14 @@ static void test_hex_to_bin_null(void) r = hex_to_bin(NULL, 0, bin); CU_ASSERT_EQUAL(r, -1); CU_ASSERT_EQUAL(bin[0], 0xff); + + struct buf buf = BUF_INITIALIZER; + r = buf_hex_to_bin(&buf, NULL, 0); + CU_ASSERT_EQUAL(r, -1); + CU_ASSERT_EQUAL(buf_len(&buf), 0); + buf_free(&buf); } +#undef ASSERT_TO_HEX + /* vim: set ft=c: */ diff --git a/cunit/bitvector.testc b/cunit/bitvector.testc index 4331c8b3db..4b7a22d68d 100644 --- a/cunit/bitvector.testc +++ b/cunit/bitvector.testc @@ -9,7 +9,7 @@ static void test_free(void) bitvector_t bv = BV_INITIALIZER; /* it's ok to call free() even if we never * set or cleared any bits */ - bv_free(&bv); + bv_fini(&bv); } static void test_basic(void) @@ -29,46 +29,46 @@ static void test_basic(void) /* can set bit0 and get it back */ bv_set(&bv, 0); CU_ASSERT_EQUAL(1, bv.length); - CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv.alloc); CU_ASSERT_EQUAL(1, bv_isset(&bv, 0)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 7)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 23)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 104)); CU_ASSERT_EQUAL(1, bv.length); - CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv.alloc); /* can set bit23 and get it back */ bv_set(&bv, 23); CU_ASSERT_EQUAL(24, bv.length); - CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv.alloc); CU_ASSERT_EQUAL(1, bv_isset(&bv, 0)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 7)); CU_ASSERT_EQUAL(1, bv_isset(&bv, 23)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 104)); CU_ASSERT_EQUAL(24, bv.length); - CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv.alloc); /* can set all bits, does not change length */ bv_setall(&bv); CU_ASSERT_EQUAL(24, bv.length); - CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv.alloc); CU_ASSERT_EQUAL(1, bv_isset(&bv, 0)); CU_ASSERT_EQUAL(1, bv_isset(&bv, 7)); CU_ASSERT_EQUAL(1, bv_isset(&bv, 23)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 104)); CU_ASSERT_EQUAL(24, bv.length); - CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv.alloc); /* can clear all bits, does not change length */ bv_clearall(&bv); CU_ASSERT_EQUAL(24, bv.length); - CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv.alloc); CU_ASSERT_EQUAL(0, bv_isset(&bv, 0)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 7)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 23)); CU_ASSERT_EQUAL(0, bv_isset(&bv, 104)); CU_ASSERT_EQUAL(24, bv.length); - CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv.alloc); /* can set the size, does not change existing bits */ bv_set(&bv, 0); @@ -83,6 +83,20 @@ static void test_basic(void) CU_ASSERT_EQUAL(105, bv.length); CU_ASSERT_NOT_EQUAL(0, bv.alloc); + /* bits are now heap-allocated */ + + /* can set bit63 and get it back */ + bv_set(&bv, 63); + CU_ASSERT_EQUAL(105, bv.length); + CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(1, bv_isset(&bv, 0)); + CU_ASSERT_EQUAL(0, bv_isset(&bv, 7)); + CU_ASSERT_EQUAL(1, bv_isset(&bv, 23)); + CU_ASSERT_EQUAL(1, bv_isset(&bv, 63)); + CU_ASSERT_EQUAL(0, bv_isset(&bv, 104)); + CU_ASSERT_EQUAL(105, bv.length); + CU_ASSERT_NOT_EQUAL(0, bv.alloc); + /* setall now works on the new size */ bv_setall(&bv); CU_ASSERT_EQUAL(105, bv.length); @@ -94,7 +108,29 @@ static void test_basic(void) CU_ASSERT_EQUAL(105, bv.length); CU_ASSERT_NOT_EQUAL(0, bv.alloc); - bv_free(&bv); + /* can set all bits, does not change length */ + bv_setall(&bv); + CU_ASSERT_EQUAL(105, bv.length); + CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(1, bv_isset(&bv, 0)); + CU_ASSERT_EQUAL(1, bv_isset(&bv, 7)); + CU_ASSERT_EQUAL(1, bv_isset(&bv, 23)); + CU_ASSERT_EQUAL(1, bv_isset(&bv, 104)); + CU_ASSERT_EQUAL(105, bv.length); + CU_ASSERT_NOT_EQUAL(0, bv.alloc); + + /* can clear all bits, does not change length */ + bv_clearall(&bv); + CU_ASSERT_EQUAL(105, bv.length); + CU_ASSERT_NOT_EQUAL(0, bv.alloc); + CU_ASSERT_EQUAL(0, bv_isset(&bv, 0)); + CU_ASSERT_EQUAL(0, bv_isset(&bv, 7)); + CU_ASSERT_EQUAL(0, bv_isset(&bv, 23)); + CU_ASSERT_EQUAL(0, bv_isset(&bv, 104)); + CU_ASSERT_EQUAL(105, bv.length); + CU_ASSERT_NOT_EQUAL(0, bv.alloc); + + bv_fini(&bv); } static void test_andeq(void) @@ -121,8 +157,8 @@ static void test_andeq(void) CU_ASSERT_EQUAL(0, bv_isset(&a, 3)); CU_ASSERT_EQUAL(0, bv_isset(&a, 23)); - bv_free(&a); - bv_free(&b); + bv_fini(&a); + bv_fini(&b); } static void test_andeq_noexpand(void) @@ -152,8 +188,8 @@ static void test_andeq_noexpand(void) CU_ASSERT_EQUAL(0, bv_isset(&a, 7)); CU_ASSERT_EQUAL(0, bv_isset(&a, 23)); - bv_free(&a); - bv_free(&b); + bv_fini(&a); + bv_fini(&b); } static void test_oreq(void) @@ -180,8 +216,8 @@ static void test_oreq(void) CU_ASSERT_EQUAL(1, bv_isset(&a, 3)); CU_ASSERT_EQUAL(1, bv_isset(&a, 23)); - bv_free(&a); - bv_free(&b); + bv_fini(&a); + bv_fini(&b); } static void test_oreq_empty(void) @@ -201,8 +237,8 @@ static void test_oreq_empty(void) CU_ASSERT_EQUAL(1, bv_isset(&a, 0)); CU_ASSERT_EQUAL(1, bv_isset(&a, 23)); - bv_free(&a); - bv_free(&b); + bv_fini(&a); + bv_fini(&b); } static void test_oreq_noexpand(void) @@ -229,8 +265,8 @@ static void test_oreq_noexpand(void) CU_ASSERT_EQUAL(1, bv_isset(&a, 3)); CU_ASSERT_EQUAL(1, bv_isset(&a, 23)); - bv_free(&a); - bv_free(&b); + bv_fini(&a); + bv_fini(&b); } static void test_shrink_expand(void) @@ -273,7 +309,7 @@ static void test_shrink_expand(void) bv_setall(&bv); } - bv_free(&bv); + bv_fini(&bv); } static void test_copy(void) @@ -322,8 +358,8 @@ static void test_copy(void) CU_ASSERT_EQUAL(1, bv_isset(&src, 23)); CU_ASSERT_EQUAL(0, bv_isset(&src, 24)); - bv_free(&dst); - bv_free(&src); + bv_fini(&dst); + bv_fini(&src); } static void test_cstring(void) @@ -387,7 +423,7 @@ static void test_cstring(void) CU_ASSERT_STRING_EQUAL(s, "aa0a[1,3,5,7,9,11]"); free(s); - bv_free(&bv); + bv_fini(&bv); } static void test_next_set(void) @@ -406,7 +442,7 @@ static void test_next_set(void) } \ bit = bv_next_set(&bv, bit+1); \ CU_ASSERT_EQUAL(bit, -1); \ - bv_free(&bv); \ + bv_fini(&bv); \ } /* empty vector never reports any set bits */ @@ -449,7 +485,7 @@ static void test_prev_set(void) } \ bit = bv_prev_set(&bv, bit-1); \ CU_ASSERT_EQUAL(bit, -1); \ - bv_free(&bv); \ + bv_fini(&bv); \ } /* empty vector never reports any set bits */ @@ -487,7 +523,7 @@ static void test_count(void) bv_set(&bv, _in[i]); \ count = bv_count(&bv); \ CU_ASSERT_EQUAL(count, VECTOR_SIZE(_in)); \ - bv_free(&bv); \ + bv_fini(&bv); \ } /* empty vector */ @@ -513,4 +549,44 @@ static void test_count(void) #undef TESTCASE } +static void test_andeq_valgrind(void) +{ + bitvector_t a = BV_INITIALIZER; + bitvector_t b = BV_INITIALIZER; + + bv_set(&a, 5); + bv_set(&b, 65535); + + bv_set(&a, 257); + bv_set(&b, 257); + + bv_andeq(&a, &b); + CU_ASSERT_EQUAL(257, bv_next_set(&a, 0)); + CU_ASSERT_EQUAL(-1, bv_next_set(&a, 257+1)); + + bv_fini(&a); + bv_fini(&b); +} + +static void test_oreq_valgrind(void) +{ + bitvector_t a = BV_INITIALIZER; + bitvector_t b = BV_INITIALIZER; + + bv_set(&a, 5); + bv_set(&b, 65535); + + bv_set(&a, 257); + bv_set(&b, 257); + + bv_oreq(&a, &b); + CU_ASSERT_EQUAL(5, bv_next_set(&a, 0)); + CU_ASSERT_EQUAL(257, bv_next_set(&a, 5+1)); + CU_ASSERT_EQUAL(65535, bv_next_set(&a, 257+1)); + CU_ASSERT_EQUAL(-1, bv_next_set(&a, 65535+1)); + + bv_fini(&a); + bv_fini(&b); +} + /* vim: set ft=c: */ diff --git a/cunit/buf.testc b/cunit/buf.testc index 14b4965245..c88a54e8b0 100644 --- a/cunit/buf.testc +++ b/cunit/buf.testc @@ -98,10 +98,27 @@ static void test_initm(void) buf_free(&b); } +static void test_initmcstr(void) +{ + struct buf b = BUF_INITIALIZER; + /* data thanks to hipsteripsum.me */ + char *s = xstrdup("terry richardson fanny pack"); + + buf_initmcstr(&b, s); + CU_ASSERT_EQUAL(b.len, strlen(s)); + CU_ASSERT_EQUAL(buf_len(&b), b.len); + CU_ASSERT(b.alloc >= b.len); + CU_ASSERT_PTR_NOT_NULL(b.s); + CU_ASSERT(!memcmp(b.s, s, strlen(s))); + + /* we don't free(s) - the buf_free() should do that */ + buf_free(&b); +} + static void test_long(void) { struct buf b = BUF_INITIALIZER; - int i; + uint16_t i; char *exp; #define SZ 6 #define N 10000 @@ -113,7 +130,7 @@ static void test_long(void) CU_ASSERT_PTR_NULL(b.s); for (i = 0 ; i < N ; i++) { - snprintf(tt, sizeof(tt), "%c%05d", 'A'+(i%26), i); + snprintf(tt, sizeof(tt), "%c%05d", 'A'+(char)(i%26), i); buf_appendcstr(&b, tt); } buf_cstring(&b); @@ -125,7 +142,7 @@ static void test_long(void) exp = xmalloc(SZ*N+1); for (i = 0 ; i < N ; i++) - snprintf(exp+SZ*i, SZ+1, "%c%05d", 'A'+(i%26), i); + snprintf(exp+SZ*i, SZ+1, "%c%05d", 'A'+(char)(i%26), i); CU_ASSERT(!strcmp(b.s, exp)); free(exp); @@ -397,7 +414,7 @@ static void test_printf(void) static void test_long_printf(void) { struct buf b = BUF_INITIALIZER; - int i; + uint16_t i; const char *s; char *exp; #define SZ 6 @@ -410,7 +427,7 @@ static void test_long_printf(void) exp = xmalloc(SZ*N+1); for (i = 0 ; i < N ; i++) - snprintf(exp+SZ*i, SZ+1, "%c%05d", 'A'+(i%26), i); + snprintf(exp+SZ*i, SZ+1, "%c%05d", 'A'+(char)(i%26), i); buf_printf(&b, "x%sy", exp); s = buf_cstring(&b); @@ -629,7 +646,7 @@ static void test_replace_all(void) CU_ASSERT_STRING_EQUAL(b.s, _in); \ \ r = regcomp(&re, _reg, REG_EXTENDED); \ - CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL_FATAL(r, 0); #define TESTCASE_MIDDLE \ n = buf_replace_one_re(&b, &re, _rep); #define TESTCASE_END \ @@ -1553,7 +1570,89 @@ static void test_trim(void) _trimsto("\t ", ""); } +static void test_appendoverlap(void) +{ + struct testcase { + const char *buf; + const char *str; + const char *want; + }; + + struct testcase testcases[] = {{ + "abcdef", "efg", "abcdefg" + }, { + "abcd", "efg", "abcdefg" + }, { + "ab", "cdefg", "abcdefg" + }, { + "ab", "abcdefg", "abcdefg" + }, { + "a", "abcdefg", "abcdefg" + }, { + "", "abcdefg", "abcdefg" + }, { + "abcdefg", "", "abcdefg" + }, { + "abcdefg", "efg", "abcdefg" + }}; + + struct buf buf = BUF_INITIALIZER; + + size_t i; + for (i = 0; i < sizeof(testcases) / sizeof(testcases[0]); i++) { + struct testcase tc = testcases[i]; + buf_setcstr(&buf, tc.buf); + buf_appendoverlap(&buf, tc.str); + CU_ASSERT_STRING_EQUAL(tc.want, buf_cstring(&buf)); + buf_reset(&buf); + } + buf_free(&buf); +} + +static void test_tocrlf(void) +{ + struct testcase { + char *input; + char *expect; + }; + + struct testcase testcases[] = { + { "\rleading bare cr", "\r\nleading bare cr" }, + { "\nleading bare lf", "\r\nleading bare lf" }, + { "\r\nleading crlf", "\r\nleading crlf" }, + { "bare\rcr", "bare\r\ncr" }, + { "bare\nlf", "bare\r\nlf" }, + { "correct\r\ncrlf", "correct\r\ncrlf" }, + { "trailing bare cr\r", "trailing bare cr\r\n" }, + { "trailing bare lf\n", "trailing bare lf\r\n" }, + { "trailing crlf\r\n", "trailing crlf\r\n" }, + { "multiple\rbare\rcr", "multiple\r\nbare\r\ncr" }, + { "multiple\nbare\nlf", "multiple\r\nbare\r\nlf" }, + { "multiple\r\ncrlf\r\n", "multiple\r\ncrlf\r\n" }, + { "mixed cr\rand\nlf", "mixed cr\r\nand\r\nlf" }, + { "mixed bare\rcr, bare\nlf and\r\ncrlf", + "mixed bare\r\ncr, bare\r\nlf and\r\ncrlf" }, + { "adjacent\r\rbare cr", "adjacent\r\n\r\nbare cr" }, + { "adjacent\n\nbare lf", "adjacent\r\n\r\nbare lf" }, + { "adjacent\r\n\r\ncrlf", "adjacent\r\n\r\ncrlf" }, + { "more adjacent\r\r\rbare cr", "more adjacent\r\n\r\n\r\nbare cr" }, + { "more adjacent\n\n\nbare lf", "more adjacent\r\n\r\n\r\nbare lf" }, + { "more adjacent\r\n\r\n\r\ncrlf", "more adjacent\r\n\r\n\r\ncrlf" }, + { "adjacent\n\nbare\r\rmixed", "adjacent\r\n\r\nbare\r\n\r\nmixed" }, + { "tricksy\r\r\n\nsplit", "tricksy\r\n\r\n\r\nsplit" }, + }; + struct buf buf = BUF_INITIALIZER; + size_t i; + for (i = 0; i < sizeof(testcases) / sizeof(testcases[0]); i++) { + struct testcase tc = testcases[i]; + buf_setcstr(&buf, tc.input); + buf_tocrlf(&buf); + CU_ASSERT_STRING_EQUAL(tc.expect, buf_cstring(&buf)); + buf_reset(&buf); + } + buf_free(&buf); +} /* TODO: test the Copy-On-Write feature of buf_ensure()...if anyone * actually uses it */ diff --git a/cunit/bufarray.testc b/cunit/bufarray.testc new file mode 100644 index 0000000000..c7ec843d4b --- /dev/null +++ b/cunit/bufarray.testc @@ -0,0 +1,243 @@ +#include "cunit/cyrunit.h" +#include "xmalloc.h" +#include "bsearch.h" +#include "bufarray.h" + +/* XXX bufarray_nth does NOT follow the same semantics as the other + * XXX fooarray_nth's, so our tests need to be a little different too + */ + +static void test_fini_null(void) +{ + /* _fini(NULL) is harmless */ + bufarray_fini(NULL); + /* _free(NULL) is harmless */ + bufarray_free(NULL); +} + +static void test_auto(void) +{ + bufarray_t ba = BUFARRAY_INITIALIZER; + struct buf b1 = BUF_INITIALIZER; + struct buf b2 = BUF_INITIALIZER; + + CU_ASSERT_EQUAL(bufarray_size(&ba), 0); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); +// CU_ASSERT_PTR_NULL(bufarray_nth(&ba, 0)); +// CU_ASSERT_PTR_NULL(bufarray_nth(&ba, -1)); + + buf_setcstr(&b1, "lorem ipsum"); + bufarray_append(&ba, &b1); + CU_ASSERT_EQUAL(bufarray_size(&ba), 1); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + CU_ASSERT_PTR_NOT_NULL(ba.items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 0), &b1), 0); +// CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, -1), &b1), 0); + + buf_setcstr(&b2, "dolor sit"); + bufarray_append(&ba, &b2); + CU_ASSERT_EQUAL(bufarray_size(&ba), 2); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + CU_ASSERT_PTR_NOT_NULL(ba.items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 0), &b1), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 1), &b2), 0); +// CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, -1), &b2), 0); + + buf_free(&b1); + buf_free(&b2); + bufarray_fini(&ba); + CU_ASSERT_EQUAL(bufarray_size(&ba), 0); + CU_ASSERT_EQUAL(ba.alloc, 0); + CU_ASSERT_PTR_NULL(ba.items); +} + +static void test_heap(void) +{ + bufarray_t *ba = bufarray_new(); + struct buf b1 = BUF_INITIALIZER; + struct buf b2 = BUF_INITIALIZER; + + CU_ASSERT_EQUAL(ba->count, 0); + CU_ASSERT(ba->alloc >= ba->count); +// CU_ASSERT_PTR_NULL(bufarray_nth(ba, 0)); +// CU_ASSERT_PTR_NULL(bufarray_nth(ba, -1)); + + buf_setcstr(&b1, "lorem ipsum"); + bufarray_append(ba, &b1); + CU_ASSERT_EQUAL(ba->count, 1); + CU_ASSERT(ba->alloc >= ba->count); + CU_ASSERT_PTR_NOT_NULL(ba->items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(ba, 0), &b1), 0); +// CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, -1), &b1), 0); + + buf_setcstr(&b2, "dolor sit"); + bufarray_append(ba, &b2); + CU_ASSERT_EQUAL(ba->count, 2); + CU_ASSERT(ba->alloc >= ba->count); + CU_ASSERT_PTR_NOT_NULL(ba->items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(ba, 0), &b1), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(ba, 1), &b2), 0); +// CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(ba, -1), &b2), 0); + + buf_free(&b1); + buf_free(&b2); + bufarray_free(&ba); +} + +static void test_truncate(void) +{ + bufarray_t ba = BUFARRAY_INITIALIZER; + struct buf WORD0 = BUF_INITIALIZER; + struct buf WORD1 = BUF_INITIALIZER; + struct buf WORD2 = BUF_INITIALIZER; + struct buf WORD3 = BUF_INITIALIZER; + struct buf WORD4 = BUF_INITIALIZER; + + CU_ASSERT_EQUAL(bufarray_size(&ba), 0); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); +// CU_ASSERT_PTR_NULL(bufarray_nth(&ba, 0)); +// CU_ASSERT_PTR_NULL(bufarray_nth(&ba, -1)); + + buf_setcstr(&WORD0, "lorem"); + buf_setcstr(&WORD1, "ipsum"); + buf_setcstr(&WORD2, "dolor"); + buf_setcstr(&WORD3, "sit"); + buf_setcstr(&WORD4, "amet"); + + bufarray_append(&ba, &WORD0); + bufarray_append(&ba, &WORD1); + bufarray_append(&ba, &WORD2); + bufarray_append(&ba, &WORD3); + bufarray_append(&ba, &WORD4); + CU_ASSERT_EQUAL(bufarray_size(&ba), 5); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + CU_ASSERT_PTR_NOT_NULL(ba.items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 0), &WORD0), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 1), &WORD1), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 2), &WORD2), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 3), &WORD3), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 4), &WORD4), 0); + + /* expand the array */ + bufarray_truncate(&ba, 7); + CU_ASSERT_EQUAL(bufarray_size(&ba), 7); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + CU_ASSERT_PTR_NOT_NULL(ba.items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 0), &WORD0), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 1), &WORD1), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 2), &WORD2), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 3), &WORD3), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 4), &WORD4), 0); + CU_ASSERT_PTR_NULL(bufarray_nth(&ba, 5)); + CU_ASSERT_PTR_NULL(bufarray_nth(&ba, 6)); + + /* shrink the array */ + bufarray_truncate(&ba, 4); + CU_ASSERT_EQUAL(bufarray_size(&ba), 4); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + CU_ASSERT_PTR_NOT_NULL(ba.items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 0), &WORD0), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 1), &WORD1), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 2), &WORD2), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 3), &WORD3), 0); + + /* shrink the array harder */ + bufarray_truncate(&ba, 3); + CU_ASSERT_EQUAL(bufarray_size(&ba), 3); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + CU_ASSERT_PTR_NOT_NULL(ba.items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 0), &WORD0), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 1), &WORD1), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 2), &WORD2), 0); + + /* shrink the array to nothing */ + bufarray_truncate(&ba, 0); + CU_ASSERT_EQUAL(bufarray_size(&ba), 0); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + /* whether ba.items is NULL is undefined at this time */ +// CU_ASSERT_PTR_NULL(bufarray_nth(&ba, 0)); +// CU_ASSERT_PTR_NULL(bufarray_nth(&ba, -1)); + + bufarray_fini(&ba); + + buf_free(&WORD0); + buf_free(&WORD1); + buf_free(&WORD2); + buf_free(&WORD3); + buf_free(&WORD4); +} + +static void test_dup(void) +{ + bufarray_t ba = BUFARRAY_INITIALIZER; + bufarray_t *dup; + struct buf WORD0 = BUF_INITIALIZER; + struct buf WORD1 = BUF_INITIALIZER; + struct buf WORD2 = BUF_INITIALIZER; + struct buf WORD3 = BUF_INITIALIZER; + struct buf WORD4 = BUF_INITIALIZER; + + CU_ASSERT_EQUAL(bufarray_size(&ba), 0); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + + /* dup an empty array */ + dup = bufarray_dup(&ba); + CU_ASSERT_PTR_NOT_NULL(dup); + CU_ASSERT_PTR_NOT_EQUAL(dup, &ba); + CU_ASSERT_EQUAL(dup->count, 0); + CU_ASSERT(dup->alloc >= dup->count); + bufarray_free(&dup); + + buf_setcstr(&WORD0, "lorem"); + buf_setcstr(&WORD1, "ipsum"); + buf_setcstr(&WORD2, "dolor"); + buf_setcstr(&WORD3, "sit"); + buf_setcstr(&WORD4, "amet"); + + /* dup a non-empty array */ + bufarray_append(&ba, &WORD0); + bufarray_append(&ba, &WORD1); + bufarray_append(&ba, &WORD2); + bufarray_append(&ba, &WORD3); + bufarray_append(&ba, &WORD0); + bufarray_append(&ba, &WORD4); + CU_ASSERT_EQUAL(bufarray_size(&ba), 6); + CU_ASSERT(ba.alloc >= bufarray_size(&ba)); + CU_ASSERT_PTR_NOT_NULL(ba.items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 0), &WORD0), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 1), &WORD1), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 2), &WORD2), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 3), &WORD3), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 4), &WORD0), 0); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(&ba, 5), &WORD4), 0); + + dup = bufarray_dup(&ba); + CU_ASSERT_PTR_NOT_NULL(dup); + CU_ASSERT_PTR_NOT_EQUAL(dup, &ba); + CU_ASSERT_EQUAL(dup->count, 6); + CU_ASSERT(dup->alloc >= dup->count); + CU_ASSERT_PTR_NOT_NULL(dup->items); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(dup, 0), &WORD0), 0); + CU_ASSERT_PTR_NOT_EQUAL((void *)bufarray_nth(dup, 0), (void *)bufarray_nth(&ba, 0)); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(dup, 1), &WORD1), 0); + CU_ASSERT_PTR_NOT_EQUAL((void *)bufarray_nth(dup, 1), (void *)bufarray_nth(&ba, 1)); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(dup, 2), &WORD2), 0); + CU_ASSERT_PTR_NOT_EQUAL((void *)bufarray_nth(dup, 2), (void *)bufarray_nth(&ba, 2)); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(dup, 3), &WORD3), 0); + CU_ASSERT_PTR_NOT_EQUAL((void *)bufarray_nth(dup, 3), (void *)bufarray_nth(&ba, 3)); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(dup, 4), &WORD0), 0); + CU_ASSERT_PTR_NOT_EQUAL((void *)bufarray_nth(dup, 4), (void *)bufarray_nth(&ba, 4)); + CU_ASSERT_EQUAL(buf_cmp(bufarray_nth(dup, 5), &WORD4), 0); + CU_ASSERT_PTR_NOT_EQUAL((void *)bufarray_nth(dup, 5), (void *)bufarray_nth(&ba, 5)); + bufarray_free(&dup); + + bufarray_fini(&ba); + + buf_free(&WORD0); + buf_free(&WORD1); + buf_free(&WORD2); + buf_free(&WORD3); + buf_free(&WORD4); +} + +/* vim: set ft=c: */ diff --git a/cunit/byteorder64.testc b/cunit/byteorder.testc similarity index 96% rename from cunit/byteorder64.testc rename to cunit/byteorder.testc index d8548cfb42..2a26ad352c 100644 --- a/cunit/byteorder64.testc +++ b/cunit/byteorder.testc @@ -1,5 +1,5 @@ #include "cunit/cyrunit.h" -#include "byteorder64.h" +#include "byteorder.h" static void test_byteorder(void) { diff --git a/cunit/cacert.pem b/cunit/cacert.pem index ea5c565ad9..3583045b0b 100644 --- a/cunit/cacert.pem +++ b/cunit/cacert.pem @@ -2,65 +2,119 @@ Certificate: Data: Version: 3 (0x2) Serial Number: - dc:ff:1e:12:b0:0b:b6:a0 - Signature Algorithm: sha1WithRSAEncryption - Issuer: C=AU, ST=Victoria, O=Cyrus IMAP Testers, Inc., OU=SSL Wrangling, CN=Greg Banks/emailAddress=gnb@fastmail.fm + d8:8f:9f:11:01:4d:34:da + Signature Algorithm: sha256WithRSAEncryption + Issuer: O = Cyrus, CN = Cunit Test CA, emailAddress = ellie@fastmail.com Validity - Not Before: Feb 1 06:35:58 2011 GMT - Not After : Jan 31 06:35:58 2014 GMT - Subject: C=AU, ST=Victoria, O=Cyrus IMAP Testers, Inc., OU=SSL Wrangling, CN=Greg Banks/emailAddress=gnb@fastmail.fm + Not Before: May 6 00:39:56 2020 GMT + Not After : May 4 00:39:56 2030 GMT + Subject: O = Cyrus, CN = Cunit Test CA, emailAddress = ellie@fastmail.com Subject Public Key Info: Public Key Algorithm: rsaEncryption - RSA Public Key: (1024 bit) - Modulus (1024 bit): - 00:da:e4:ec:8f:ef:07:6d:58:b3:13:3e:d0:25:30: - 07:11:35:88:df:70:5c:f5:0d:10:5c:a6:96:4b:e4: - 9c:f3:df:3f:8f:54:fa:00:1b:a2:b9:e8:5f:17:19: - ee:a8:9a:5d:40:59:6b:d3:90:1b:6f:6c:3b:27:0c: - be:e0:32:1a:a6:31:bc:57:e5:20:86:c8:c2:2f:b1: - 7c:a0:fe:a9:d7:57:7e:6c:3e:ee:92:b7:f7:7a:fe: - 20:e1:4e:46:91:0a:4a:c2:5b:23:1c:f2:03:0a:8d: - cc:c9:e4:9b:d8:02:fb:97:6e:38:ee:ce:8d:1f:6b: - d6:45:70:f8:b9:3b:6e:d3:a7 + Public-Key: (4096 bit) + Modulus: + 00:ad:93:cc:8d:90:4b:d7:7d:2e:e4:8e:2a:d4:6e: + 0c:31:cc:f3:0a:f0:01:be:6d:24:c8:c4:c7:9a:8a: + c5:0e:05:6a:86:62:14:b9:94:43:28:2d:43:ba:e2: + 9e:ab:e5:81:be:b5:93:fc:0b:c8:eb:f0:43:0a:74: + 9a:4d:67:69:86:0a:71:50:ac:fa:d4:6c:0a:fb:76: + 0a:28:bd:51:50:0b:8b:a6:38:6e:b5:a6:c3:78:33: + 89:32:cb:9a:0a:6b:03:82:5e:a3:1f:ad:0a:18:77: + 3e:8b:2a:88:32:d6:03:fc:96:d8:82:cc:f4:65:89: + ea:d8:ea:a6:65:21:e8:26:7b:46:05:2a:a3:d9:1d: + 68:e9:18:ee:e5:77:92:20:74:da:e7:42:24:35:e5: + 6b:63:b6:80:fa:dc:9e:42:80:ae:2d:3f:71:03:64: + 6a:b8:2a:1d:bf:f9:0e:33:f1:88:8a:a1:51:fe:62: + 0a:9b:5c:0c:9d:2a:c4:75:98:fe:40:32:d2:19:bf: + 3f:27:ec:15:06:87:62:e0:de:dd:85:5b:46:1d:b0: + b1:1f:90:4e:e7:38:5d:b9:00:7d:95:bb:da:fb:2a: + 03:ef:4e:2f:b0:44:8a:92:eb:09:82:38:52:8c:8a: + b7:70:14:f8:61:36:2c:da:81:08:ba:37:ea:bc:ba: + 99:4f:51:3e:6d:d3:01:a4:c4:7e:6c:47:8f:f3:47: + 9c:eb:16:a1:c3:f7:23:b8:35:98:a4:69:a2:02:c4: + 35:ad:8a:3a:8c:55:01:74:a4:45:20:99:db:de:dc: + d2:6a:42:bb:16:5e:c4:47:e7:4f:95:ab:49:4a:64: + 91:3b:97:d2:6e:92:92:ad:14:00:78:4c:e5:3e:bc: + 3d:36:c3:0c:2a:e9:dc:bd:83:27:d3:83:47:33:95: + 85:dc:34:2f:b9:de:e9:b0:46:c0:b5:26:5c:52:87: + 7d:cd:57:7c:04:dd:ce:01:20:a5:3d:9b:77:65:31: + 44:bb:c4:81:78:1e:63:59:14:9f:1c:3f:70:18:18: + 87:94:79:b2:a3:e7:da:96:ee:38:88:55:0c:ae:ef: + a0:75:c9:e7:4f:89:c8:09:a9:8f:eb:9a:00:c9:ae: + ba:dd:2e:c3:e6:3a:bc:13:f0:d7:8a:2f:43:e4:d5: + ed:70:6a:b3:2c:70:13:e4:1b:02:e8:e5:cf:a3:3d: + 96:a7:f3:3b:86:5e:c4:dc:dc:e3:f5:90:ca:c9:0e: + ee:08:cf:ac:4f:81:f1:5e:46:94:d7:b2:3c:de:3e: + 0b:e5:e4:c5:28:d5:1e:04:e1:8d:c5:4b:d0:62:c4: + 3d:46:1d:6d:27:5a:4f:f4:8f:9b:1c:bc:cd:e3:2b: + 8d:bb:21 Exponent: 65537 (0x10001) X509v3 extensions: - X509v3 Subject Key Identifier: - D3:8D:00:56:9B:71:99:CE:00:44:F4:86:98:AE:A9:78:AB:20:17:8A - X509v3 Authority Key Identifier: - keyid:D3:8D:00:56:9B:71:99:CE:00:44:F4:86:98:AE:A9:78:AB:20:17:8A - DirName:/C=AU/ST=Victoria/O=Cyrus IMAP Testers, Inc./OU=SSL Wrangling/CN=Greg Banks/emailAddress=gnb@fastmail.fm - serial:DC:FF:1E:12:B0:0B:B6:A0 + X509v3 Subject Key Identifier: + 39:6F:8D:DF:ED:88:34:6D:F3:C3:9A:AC:4A:B5:49:43:AB:74:AB:0A + X509v3 Authority Key Identifier: + keyid:39:6F:8D:DF:ED:88:34:6D:F3:C3:9A:AC:4A:B5:49:43:AB:74:AB:0A - X509v3 Basic Constraints: + X509v3 Basic Constraints: critical CA:TRUE - Signature Algorithm: sha1WithRSAEncryption - 6f:ec:eb:37:40:53:b0:af:c7:db:28:64:6f:5d:49:80:7d:2f: - 98:59:3b:18:c4:f9:19:57:5d:04:80:97:0e:9e:dc:d5:fe:da: - 93:d9:55:38:ec:33:f7:e2:e2:c0:ba:9c:13:4d:15:1b:52:40: - af:93:1b:6c:97:74:7a:cc:1d:8d:31:ec:cd:b0:ba:31:5e:18: - 44:45:7a:80:3d:e4:6d:18:dc:87:95:f9:2b:8e:3c:1f:64:04: - b1:8e:10:fb:6b:db:60:ed:62:75:d5:08:ab:55:03:ff:a5:7c: - 33:0a:66:07:35:37:b2:49:93:e7:8a:80:c7:0d:e9:c0:fe:9b: - 80:2a + Signature Algorithm: sha256WithRSAEncryption + 0f:25:56:f2:34:9a:3c:bc:37:6c:79:36:70:f5:6b:9b:d9:b6: + 58:eb:1e:ba:f9:08:d7:15:59:db:3c:aa:85:c4:54:6b:81:2a: + 15:fe:24:91:48:66:b4:23:bf:b9:ee:12:ac:19:f0:84:35:d4: + f4:99:b6:90:0a:67:54:22:40:ea:91:e7:97:75:96:b9:40:4f: + d0:b1:6a:07:24:b0:23:66:07:0c:4b:70:24:38:6c:bd:64:3c: + e2:a7:2a:5c:00:e6:cc:51:95:2c:54:c3:d1:8a:82:96:8e:82: + 75:80:52:cb:2b:e0:b5:bc:a3:d2:55:3c:9b:f8:c6:17:0c:a2: + d5:e7:a9:32:ba:e7:5e:ab:00:a2:4b:85:52:3e:15:95:3c:84: + a2:d9:8e:02:96:7e:c9:45:00:da:e0:b0:d9:c2:9a:9a:1c:18: + aa:4f:b6:29:02:d9:39:44:19:a6:f5:51:c9:15:88:c2:6d:87: + 42:7d:3c:1e:0d:05:a3:96:96:e9:7c:1e:47:84:90:f6:fe:89: + 47:59:ae:c7:84:86:ae:85:e7:d2:12:61:ed:72:18:27:68:c8: + f4:86:90:cb:63:f7:4b:5c:d9:98:0e:9b:c7:bc:be:82:aa:d7: + d8:a2:a8:48:36:8e:c2:7e:a2:19:2b:3b:2b:4b:08:3b:cf:b7: + 34:6e:4a:10:8e:4a:54:f5:bb:93:2d:a5:00:0f:b3:92:df:74: + 14:d0:8c:5f:3f:5b:78:94:33:bd:bd:69:8d:06:71:54:d8:1b: + 64:fc:11:44:08:95:c1:f0:24:55:7d:93:a7:0e:e0:cc:0a:7a: + d9:70:9f:48:f6:b1:38:e4:2d:9d:b7:3d:c1:52:7b:6a:89:cd: + 7d:1e:9d:3d:62:73:72:b0:39:11:04:3a:4a:95:37:97:71:5e: + 24:c5:4d:83:ba:9b:08:e0:99:ae:d0:76:dd:8f:c4:ee:66:1b: + c0:4c:57:da:1b:14:83:d8:78:74:27:00:b5:4d:58:19:1e:73: + ce:75:1f:a7:44:ce:98:31:89:10:5a:92:cb:78:93:9e:bc:28: + 2e:25:a7:d1:76:cf:11:8b:4d:be:54:11:92:4f:a2:19:59:a3: + f1:c1:65:16:d2:dc:ef:41:00:ed:f8:6e:3b:f1:37:b7:b8:4b: + 6f:53:e5:6e:d9:88:1b:c9:0b:ca:58:32:bc:6c:30:ea:42:12: + e7:16:03:7a:2c:24:d8:f9:d0:ff:35:f2:87:92:2c:6d:d3:38: + 58:77:ec:61:a5:42:e7:aa:c3:7c:3d:c3:d2:fb:f3:7f:03:35: + 45:08:76:18:8b:16:1f:6c:e6:86:97:39:56:f5:09:a2:58:82: + bb:79:05:67:1d:5b:4d:c8 -----BEGIN CERTIFICATE----- -MIIDmTCCAwKgAwIBAgIJANz/HhKwC7agMA0GCSqGSIb3DQEBBQUAMIGQMQswCQYD -VQQGEwJBVTERMA8GA1UECBMIVmljdG9yaWExITAfBgNVBAoTGEN5cnVzIElNQVAg -VGVzdGVycywgSW5jLjEWMBQGA1UECxMNU1NMIFdyYW5nbGluZzETMBEGA1UEAxMK -R3JlZyBCYW5rczEeMBwGCSqGSIb3DQEJARYPZ25iQGZhc3RtYWlsLmZtMB4XDTEx -MDIwMTA2MzU1OFoXDTE0MDEzMTA2MzU1OFowgZAxCzAJBgNVBAYTAkFVMREwDwYD -VQQIEwhWaWN0b3JpYTEhMB8GA1UEChMYQ3lydXMgSU1BUCBUZXN0ZXJzLCBJbmMu -MRYwFAYDVQQLEw1TU0wgV3JhbmdsaW5nMRMwEQYDVQQDEwpHcmVnIEJhbmtzMR4w -HAYJKoZIhvcNAQkBFg9nbmJAZmFzdG1haWwuZm0wgZ8wDQYJKoZIhvcNAQEBBQAD -gY0AMIGJAoGBANrk7I/vB21YsxM+0CUwBxE1iN9wXPUNEFymlkvknPPfP49U+gAb -ornoXxcZ7qiaXUBZa9OQG29sOycMvuAyGqYxvFflIIbIwi+xfKD+qddXfmw+7pK3 -93r+IOFORpEKSsJbIxzyAwqNzMnkm9gC+5duOO7OjR9r1kVw+Lk7btOnAgMBAAGj -gfgwgfUwHQYDVR0OBBYEFNONAFabcZnOAET0hpiuqXirIBeKMIHFBgNVHSMEgb0w -gbqAFNONAFabcZnOAET0hpiuqXirIBeKoYGWpIGTMIGQMQswCQYDVQQGEwJBVTER -MA8GA1UECBMIVmljdG9yaWExITAfBgNVBAoTGEN5cnVzIElNQVAgVGVzdGVycywg -SW5jLjEWMBQGA1UECxMNU1NMIFdyYW5nbGluZzETMBEGA1UEAxMKR3JlZyBCYW5r -czEeMBwGCSqGSIb3DQEJARYPZ25iQGZhc3RtYWlsLmZtggkA3P8eErALtqAwDAYD -VR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQBv7Os3QFOwr8fbKGRvXUmAfS+Y -WTsYxPkZV10EgJcOntzV/tqT2VU47DP34uLAupwTTRUbUkCvkxtsl3R6zB2NMezN -sLoxXhhERXqAPeRtGNyHlfkrjjwfZASxjhD7a9tg7WJ11QirVQP/pXwzCmYHNTey -SZPnioDHDenA/puAKg== +MIIFbDCCA1SgAwIBAgIJANiPnxEBTTTaMA0GCSqGSIb3DQEBCwUAMEsxDjAMBgNV +BAoMBUN5cnVzMRYwFAYDVQQDDA1DdW5pdCBUZXN0IENBMSEwHwYJKoZIhvcNAQkB +FhJlbGxpZUBmYXN0bWFpbC5jb20wHhcNMjAwNTA2MDAzOTU2WhcNMzAwNTA0MDAz +OTU2WjBLMQ4wDAYDVQQKDAVDeXJ1czEWMBQGA1UEAwwNQ3VuaXQgVGVzdCBDQTEh +MB8GCSqGSIb3DQEJARYSZWxsaWVAZmFzdG1haWwuY29tMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEArZPMjZBL130u5I4q1G4MMczzCvABvm0kyMTHmorF +DgVqhmIUuZRDKC1DuuKeq+WBvrWT/AvI6/BDCnSaTWdphgpxUKz61GwK+3YKKL1R +UAuLpjhutabDeDOJMsuaCmsDgl6jH60KGHc+iyqIMtYD/JbYgsz0ZYnq2OqmZSHo +JntGBSqj2R1o6Rju5XeSIHTa50IkNeVrY7aA+tyeQoCuLT9xA2RquCodv/kOM/GI +iqFR/mIKm1wMnSrEdZj+QDLSGb8/J+wVBodi4N7dhVtGHbCxH5BO5zhduQB9lbva ++yoD704vsESKkusJgjhSjIq3cBT4YTYs2oEIujfqvLqZT1E+bdMBpMR+bEeP80ec +6xahw/cjuDWYpGmiAsQ1rYo6jFUBdKRFIJnb3tzSakK7Fl7ER+dPlatJSmSRO5fS +bpKSrRQAeEzlPrw9NsMMKuncvYMn04NHM5WF3DQvud7psEbAtSZcUod9zVd8BN3O +ASClPZt3ZTFEu8SBeB5jWRSfHD9wGBiHlHmyo+falu44iFUMru+gdcnnT4nICamP +65oAya663S7D5jq8E/DXii9D5NXtcGqzLHAT5BsC6OXPoz2Wp/M7hl7E3Nzj9ZDK +yQ7uCM+sT4HxXkaU17I83j4L5eTFKNUeBOGNxUvQYsQ9Rh1tJ1pP9I+bHLzN4yuN +uyECAwEAAaNTMFEwHQYDVR0OBBYEFDlvjd/tiDRt88OarEq1SUOrdKsKMB8GA1Ud +IwQYMBaAFDlvjd/tiDRt88OarEq1SUOrdKsKMA8GA1UdEwEB/wQFMAMBAf8wDQYJ +KoZIhvcNAQELBQADggIBAA8lVvI0mjy8N2x5NnD1a5vZtljrHrr5CNcVWds8qoXE +VGuBKhX+JJFIZrQjv7nuEqwZ8IQ11PSZtpAKZ1QiQOqR55d1lrlAT9CxagcksCNm +BwxLcCQ4bL1kPOKnKlwA5sxRlSxUw9GKgpaOgnWAUssr4LW8o9JVPJv4xhcMotXn +qTK6516rAKJLhVI+FZU8hKLZjgKWfslFANrgsNnCmpocGKpPtikC2TlEGab1UckV +iMJth0J9PB4NBaOWlul8HkeEkPb+iUdZrseEhq6F59ISYe1yGCdoyPSGkMtj90tc +2ZgOm8e8voKq19iiqEg2jsJ+ohkrOytLCDvPtzRuShCOSlT1u5MtpQAPs5LfdBTQ +jF8/W3iUM729aY0GcVTYG2T8EUQIlcHwJFV9k6cO4MwKetlwn0j2sTjkLZ23PcFS +e2qJzX0enT1ic3KwOREEOkqVN5dxXiTFTYO6mwjgma7Qdt2PxO5mG8BMV9obFIPY +eHQnALVNWBkec851H6dEzpgxiRBakst4k568KC4lp9F2zxGLTb5UEZJPohlZo/HB +ZRbS3O9BAO34bjvxN7e4S29T5W7ZiBvJC8pYMrxsMOpCEucWA3osJNj50P818oeS +LG3TOFh37GGlQueqw3w9w9L7838DNUUIdhiLFh9s5oaXOVb1CaJYgrt5BWcdW03I -----END CERTIFICATE----- diff --git a/cunit/cert.pem b/cunit/cert.pem index cb7959d314..95856758d7 100644 --- a/cunit/cert.pem +++ b/cunit/cert.pem @@ -1,63 +1,111 @@ Certificate: Data: - Version: 3 (0x2) + Version: 1 (0x0) Serial Number: - dc:ff:1e:12:b0:0b:b6:a1 - Signature Algorithm: sha1WithRSAEncryption - Issuer: C=AU, ST=Victoria, O=Cyrus IMAP Testers, Inc., OU=SSL Wrangling, CN=Greg Banks/emailAddress=gnb@fastmail.fm + b1:9a:bb:97:c3:6c:2f:03 + Signature Algorithm: sha256WithRSAEncryption + Issuer: O = Cyrus, CN = Cunit Test CA, emailAddress = ellie@fastmail.com Validity - Not Before: Feb 1 07:40:55 2011 GMT - Not After : Feb 1 07:40:55 2012 GMT - Subject: C=AU, ST=Victoria, O=Cyrus IMAP Testers, Inc., OU=SSL Wranglers, CN=Greg Banks/emailAddress=gnb@fastmail.fm + Not Before: May 6 00:51:14 2020 GMT + Not After : May 4 00:51:14 2030 GMT + Subject: O = Cyrus, CN = Cunit Test Certificate, emailAddress = ellie@fastmail.com Subject Public Key Info: Public Key Algorithm: rsaEncryption - RSA Public Key: (1024 bit) - Modulus (1024 bit): - 00:f2:cc:96:27:92:93:ad:56:9c:78:85:68:d0:bd: - 00:00:62:7a:d5:22:b3:54:f0:4a:1b:bc:18:8d:7e: - 37:1f:cb:b0:04:ab:d8:91:55:37:c8:89:79:2f:94: - cd:02:d3:34:0f:49:ca:68:80:1c:8b:9b:be:43:c4: - 8f:a1:53:04:a8:35:b7:8d:d8:67:ec:92:30:89:87: - 55:0b:a9:9d:45:37:88:af:ea:99:64:11:9c:5e:c2: - d5:95:17:df:37:23:f1:0c:75:e9:0b:b6:1e:b0:80: - 7d:a0:da:87:6c:80:3f:73:72:f4:d4:b4:5d:54:78: - 07:6b:ef:7f:7f:0a:08:84:bb + Public-Key: (4096 bit) + Modulus: + 00:c5:7b:27:ab:e9:ec:a0:cd:3a:9a:ee:bf:d6:e8: + 40:da:5d:ff:23:75:7e:b7:c7:94:77:5f:65:a2:a6: + 58:18:4c:d8:b6:57:b5:ed:46:e6:2c:45:cd:09:ff: + bb:24:1b:75:14:54:d1:95:a3:d2:9b:13:5d:dc:4c: + e5:20:eb:07:d0:86:2b:1e:53:1a:fa:5e:c6:02:9c: + 74:82:8c:71:66:4d:7b:e5:e6:92:9b:68:6c:52:a1: + d1:cd:a6:12:b8:44:04:9e:55:d2:91:05:fc:83:86: + 47:6a:e3:d6:b9:b2:a6:01:3c:1e:a6:c7:95:90:81: + 36:f1:79:72:e1:07:97:1c:aa:41:3d:3a:60:dd:3b: + 2c:77:6e:ba:6d:cb:27:89:09:a9:db:c9:fe:ff:95: + a8:a5:ef:c1:7f:30:bb:a4:d9:d3:af:44:16:d6:45: + 1c:fa:49:e3:26:10:55:fa:b5:a1:91:99:bc:79:fe: + 8e:4b:92:a4:30:ca:f4:20:21:ac:0d:fe:c6:4a:69: + 8c:a5:80:3e:67:e9:fd:d2:02:91:8c:4a:cf:2c:ce: + 54:7c:cb:76:fa:e7:c0:a0:de:d0:fc:dc:e9:28:21: + cd:e4:26:3a:53:fd:bd:3e:ac:51:ae:a9:31:a4:3d: + 6d:c3:a6:b5:05:af:3e:c2:02:34:08:40:96:ee:d3: + 11:97:d3:0a:af:51:0e:a9:0f:dd:01:28:1b:51:56: + 44:91:7b:75:13:71:c3:71:3f:86:a6:c5:f4:18:69: + 2d:53:9f:c0:84:42:8f:9e:55:5f:5d:6f:c9:e8:a9: + 40:db:0c:30:f4:20:94:e6:d8:3c:b6:7f:ea:5f:b3: + a7:fe:4b:03:21:8f:f5:31:ce:cf:c1:77:b5:3d:6e: + 46:60:dc:c4:71:4c:18:69:6e:62:b5:ad:ef:da:f8: + 1d:49:fc:3f:00:6e:d1:ae:1e:01:97:0c:73:81:89: + 45:61:47:37:7f:22:88:59:bf:87:59:39:e1:c6:42: + b6:04:a6:ad:55:6a:53:41:91:a0:60:d0:c7:90:77: + 57:3d:97:7a:26:92:a6:ec:1a:39:b2:5e:97:a4:08: + 5a:f3:b3:a6:9a:b7:84:f7:33:98:aa:15:14:d6:f9: + b9:be:0a:98:85:f8:e2:ee:e5:c9:dc:b5:0f:30:1b: + 8b:fa:ef:94:3a:59:8d:03:cb:47:05:07:77:47:7c: + 57:2f:b3:19:0f:82:59:b9:05:92:ca:6f:a1:0e:29: + 66:52:99:77:8d:3f:07:61:14:af:63:e4:ae:93:6d: + 1b:2f:03:ad:a3:f6:e4:89:34:25:c1:c7:bc:ef:37: + e3:88:ff:92:67:91:9c:a2:91:6a:f7:9b:b7:e0:67: + c4:d1:db Exponent: 65537 (0x10001) - X509v3 extensions: - X509v3 Basic Constraints: - CA:FALSE - Netscape Comment: - OpenSSL Generated Certificate - X509v3 Subject Key Identifier: - 96:48:A2:12:B6:AD:DC:B8:94:D4:4E:46:94:C4:1E:AF:32:4C:B9:01 - X509v3 Authority Key Identifier: - keyid:D3:8D:00:56:9B:71:99:CE:00:44:F4:86:98:AE:A9:78:AB:20:17:8A - - Signature Algorithm: sha1WithRSAEncryption - 7a:4c:82:b7:55:31:76:89:44:08:57:0d:c9:df:82:e7:1f:94: - b1:5f:a6:bc:93:63:d7:90:74:25:b0:0b:5b:eb:b6:d0:46:e3: - 59:34:8c:e8:46:7a:33:af:b6:4d:3f:b8:0a:dd:89:51:22:f2: - f8:26:97:9e:4b:62:8a:10:f4:87:0c:cb:53:ff:c8:2b:a2:95: - 0e:02:65:e5:97:b5:ad:c6:87:25:c9:dc:35:b5:c7:e2:4d:d5: - cb:dd:a7:2a:e4:0f:7e:e1:a3:b8:fa:11:02:61:0e:04:2b:3a: - a7:73:80:e1:26:24:24:6c:fb:35:50:31:6e:ce:15:53:c7:43: - 1a:60 + Signature Algorithm: sha256WithRSAEncryption + 01:e9:6b:c7:a2:f7:20:0b:a1:ae:ef:7e:0f:73:8d:9f:4d:c0: + 9e:ce:0c:be:88:a0:d9:07:2e:3b:af:73:79:90:79:6d:67:e5: + 45:8d:cb:96:4f:db:f2:49:f6:5c:22:94:60:a9:0c:05:22:9f: + e3:4b:7c:b7:5c:e9:25:bf:25:63:f4:b9:f6:bc:dc:8d:ae:2e: + 34:b2:de:68:50:99:00:dd:b4:3f:ee:cf:f5:94:25:51:57:95: + 83:5b:d0:2f:98:80:d8:75:8f:b7:73:e1:18:37:85:70:6c:20: + 96:f7:3a:d7:79:e6:e1:cb:30:40:42:5c:74:34:8a:47:2c:d2: + 8f:a4:ba:54:4c:8c:00:9e:52:d7:af:88:63:a6:d0:35:c8:9a: + f1:04:87:65:7c:44:f6:9d:7e:83:ee:3e:62:23:21:05:b2:4b: + da:fa:dc:55:9b:bd:d7:58:08:6a:a1:85:6c:f6:2a:28:09:bc: + 07:ed:32:1a:95:e1:a2:3c:23:26:5b:b4:01:49:0f:87:e3:c3: + 16:75:f5:28:64:b8:b8:a4:68:b8:9e:8c:4b:80:7a:20:60:74: + bc:72:aa:96:7e:28:77:ed:00:7a:ac:51:13:34:c4:6e:6b:f7: + ae:9e:83:cb:0e:41:fc:51:f3:61:ff:fd:14:a2:15:da:2f:6a: + 18:2f:5f:01:0a:e9:ae:be:d6:44:37:70:d8:4c:e1:6b:b0:4f: + 34:3d:7b:f8:1f:f4:97:ea:c4:1c:af:c2:7f:50:8a:d1:55:b5: + 1c:b2:c0:9f:e4:1e:45:42:49:ef:05:8d:c2:fe:27:d8:e5:ec: + e9:d3:65:73:2d:7e:ad:34:05:93:e2:9c:bc:6a:f8:9c:75:09: + 1d:5b:60:e8:b6:15:a4:35:6a:55:38:3e:4e:dc:07:13:82:6f: + 0a:95:7d:fc:44:29:8f:d5:4b:f8:64:dd:54:5c:02:e7:be:84: + de:46:ad:65:5b:31:b4:7f:f0:de:03:a3:7c:e6:53:12:21:ed: + df:18:98:ef:7f:aa:59:ee:78:cc:1f:3b:b1:9b:67:75:1e:a5: + 8e:ad:ac:21:c9:b5:55:08:76:7a:24:d5:7a:87:ba:64:11:c3: + a7:89:35:8f:55:90:aa:e5:ed:7e:ee:c5:94:33:59:ad:ef:62: + 98:88:ae:d1:38:7d:25:56:ee:d0:9b:9d:cc:9a:fa:27:9f:83: + 59:7f:39:a7:06:b1:1e:f6:6e:5d:42:4d:48:02:ce:a8:6e:0f: + 78:f4:f0:b3:c7:0d:c3:26:a2:ff:ac:ea:6a:0d:6b:75:c2:72: + 49:c5:a7:36:47:90:23:da:f9:84:9c:c7:a6:6b:49:02:4d:a6: + dd:8e:e9:27:d2:4c:51:1b -----BEGIN CERTIFICATE----- -MIIDGzCCAoSgAwIBAgIJANz/HhKwC7ahMA0GCSqGSIb3DQEBBQUAMIGQMQswCQYD -VQQGEwJBVTERMA8GA1UECBMIVmljdG9yaWExITAfBgNVBAoTGEN5cnVzIElNQVAg -VGVzdGVycywgSW5jLjEWMBQGA1UECxMNU1NMIFdyYW5nbGluZzETMBEGA1UEAxMK -R3JlZyBCYW5rczEeMBwGCSqGSIb3DQEJARYPZ25iQGZhc3RtYWlsLmZtMB4XDTEx -MDIwMTA3NDA1NVoXDTEyMDIwMTA3NDA1NVowgZAxCzAJBgNVBAYTAkFVMREwDwYD -VQQIEwhWaWN0b3JpYTEhMB8GA1UEChMYQ3lydXMgSU1BUCBUZXN0ZXJzLCBJbmMu -MRYwFAYDVQQLEw1TU0wgV3JhbmdsZXJzMRMwEQYDVQQDEwpHcmVnIEJhbmtzMR4w -HAYJKoZIhvcNAQkBFg9nbmJAZmFzdG1haWwuZm0wgZ8wDQYJKoZIhvcNAQEBBQAD -gY0AMIGJAoGBAPLMlieSk61WnHiFaNC9AABietUis1TwShu8GI1+Nx/LsASr2JFV -N8iJeS+UzQLTNA9JymiAHIubvkPEj6FTBKg1t43YZ+ySMImHVQupnUU3iK/qmWQR -nF7C1ZUX3zcj8Qx16Qu2HrCAfaDah2yAP3Ny9NS0XVR4B2vvf38KCIS7AgMBAAGj -ezB5MAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVk -IENlcnRpZmljYXRlMB0GA1UdDgQWBBSWSKIStq3cuJTUTkaUxB6vMky5ATAfBgNV -HSMEGDAWgBTTjQBWm3GZzgBE9IaYrql4qyAXijANBgkqhkiG9w0BAQUFAAOBgQB6 -TIK3VTF2iUQIVw3J34LnH5SxX6a8k2PXkHQlsAtb67bQRuNZNIzoRnozr7ZNP7gK -3YlRIvL4JpeeS2KKEPSHDMtT/8gropUOAmXll7Wtxoclydw1tcfiTdXL3acq5A9+ -4aO4+hECYQ4EKzqnc4DhJiQkbPs1UDFuzhVTx0MaYA== +MIIFGzCCAwMCCQCxmruXw2wvAzANBgkqhkiG9w0BAQsFADBLMQ4wDAYDVQQKDAVD +eXJ1czEWMBQGA1UEAwwNQ3VuaXQgVGVzdCBDQTEhMB8GCSqGSIb3DQEJARYSZWxs +aWVAZmFzdG1haWwuY29tMB4XDTIwMDUwNjAwNTExNFoXDTMwMDUwNDAwNTExNFow +VDEOMAwGA1UECgwFQ3lydXMxHzAdBgNVBAMMFkN1bml0IFRlc3QgQ2VydGlmaWNh +dGUxITAfBgkqhkiG9w0BCQEWEmVsbGllQGZhc3RtYWlsLmNvbTCCAiIwDQYJKoZI +hvcNAQEBBQADggIPADCCAgoCggIBAMV7J6vp7KDNOpruv9boQNpd/yN1frfHlHdf +ZaKmWBhM2LZXte1G5ixFzQn/uyQbdRRU0ZWj0psTXdxM5SDrB9CGKx5TGvpexgKc +dIKMcWZNe+XmkptobFKh0c2mErhEBJ5V0pEF/IOGR2rj1rmypgE8HqbHlZCBNvF5 +cuEHlxyqQT06YN07LHduum3LJ4kJqdvJ/v+VqKXvwX8wu6TZ069EFtZFHPpJ4yYQ +Vfq1oZGZvHn+jkuSpDDK9CAhrA3+xkppjKWAPmfp/dICkYxKzyzOVHzLdvrnwKDe +0Pzc6SghzeQmOlP9vT6sUa6pMaQ9bcOmtQWvPsICNAhAlu7TEZfTCq9RDqkP3QEo +G1FWRJF7dRNxw3E/hqbF9BhpLVOfwIRCj55VX11vyeipQNsMMPQglObYPLZ/6l+z +p/5LAyGP9THOz8F3tT1uRmDcxHFMGGluYrWt79r4HUn8PwBu0a4eAZcMc4GJRWFH +N38iiFm/h1k54cZCtgSmrVVqU0GRoGDQx5B3Vz2XeiaSpuwaObJel6QIWvOzppq3 +hPczmKoVFNb5ub4KmIX44u7lydy1DzAbi/rvlDpZjQPLRwUHd0d8Vy+zGQ+CWbkF +kspvoQ4pZlKZd40/B2EUr2PkrpNtGy8DraP25Ik0JcHHvO8344j/kmeRnKKRaveb +t+BnxNHbAgMBAAEwDQYJKoZIhvcNAQELBQADggIBAAHpa8ei9yALoa7vfg9zjZ9N +wJ7ODL6IoNkHLjuvc3mQeW1n5UWNy5ZP2/JJ9lwilGCpDAUin+NLfLdc6SW/JWP0 +ufa83I2uLjSy3mhQmQDdtD/uz/WUJVFXlYNb0C+YgNh1j7dz4Rg3hXBsIJb3Otd5 +5uHLMEBCXHQ0ikcs0o+kulRMjACeUteviGOm0DXImvEEh2V8RPadfoPuPmIjIQWy +S9r63FWbvddYCGqhhWz2KigJvAftMhqV4aI8IyZbtAFJD4fjwxZ19ShkuLikaLie +jEuAeiBgdLxyqpZ+KHftAHqsURM0xG5r966eg8sOQfxR82H//RSiFdovahgvXwEK +6a6+1kQ3cNhM4WuwTzQ9e/gf9JfqxByvwn9QitFVtRyywJ/kHkVCSe8FjcL+J9jl +7OnTZXMtfq00BZPinLxq+Jx1CR1bYOi2FaQ1alU4Pk7cBxOCbwqVffxEKY/VS/hk +3VRcAue+hN5GrWVbMbR/8N4Do3zmUxIh7d8YmO9/qlnueMwfO7GbZ3UepY6trCHJ +tVUIdnok1XqHumQRw6eJNY9VkKrl7X7uxZQzWa3vYpiIrtE4fSVW7tCbncya+ief +g1l/OacGsR72bl1CTUgCzqhuD3j08LPHDcMmov+s6moNa3XCcknFpzZHkCPa+YSc +x6ZrSQJNpt2O6SfSTFEb -----END CERTIFICATE----- diff --git a/cunit/charset.testc b/cunit/charset.testc index de0c77d2c5..39a7d3e662 100644 --- a/cunit/charset.testc +++ b/cunit/charset.testc @@ -5,6 +5,9 @@ extern int charset_debug; +#define CU_ASSERT_MEMEQUAL(actual, expected, sz) \ + CU_ASSERT_EQUAL(memcmp(actual, expected, sz), 0) + /* The Unicode Replacement character 0xfffd in UTF-8 encoding */ #define UTF8_REPLACEMENT "\357\277\275" /* The Replacement char after search normalisation */ @@ -12,22 +15,18 @@ extern int charset_debug; static void test_lookupname(void) { - charset_t cs, cs2; + charset_t cs; /* us-ascii must exist */ cs = charset_lookupname("us-ascii"); CU_ASSERT_PTR_NOT_NULL(cs); + charset_free(&cs); /* names are case-insensitive */ - cs2 = charset_lookupname("US-ASCII"); - CU_ASSERT_PTR_NOT_NULL(cs2); - CU_ASSERT_STRING_EQUAL(charset_name(cs), charset_name(cs2)); - charset_free(&cs2); - - cs2 = charset_lookupname("Us-AsCiI"); - CU_ASSERT_PTR_NOT_NULL(cs2); - CU_ASSERT_STRING_EQUAL(charset_name(cs), charset_name(cs2)); - charset_free(&cs2); + cs = charset_lookupname("US-ASCII"); + CU_ASSERT_PTR_NOT_NULL(cs); + CU_ASSERT_STRING_EQUAL("us-ascii", charset_canon_name(cs)); + CU_ASSERT_STRING_EQUAL("US-ASCII", charset_alias_name(cs)); charset_free(&cs); /* some others must also exist */ @@ -41,6 +40,8 @@ static void test_lookupname(void) cs = charset_lookupname("iso-8859-1"); CU_ASSERT_PTR_NOT_NULL(cs); + CU_ASSERT_STRING_EQUAL("windows-1252", charset_canon_name(cs)); + CU_ASSERT_STRING_EQUAL("iso-8859-1", charset_alias_name(cs)); charset_free(&cs); /* @@ -49,26 +50,31 @@ static void test_lookupname(void) */ cs = charset_lookupname("windows-1252"); CU_ASSERT_PTR_NOT_NULL(cs); - CU_ASSERT_STRING_EQUAL(charset_name(cs), "windows-1252"); + CU_ASSERT_STRING_EQUAL("windows-1252", charset_canon_name(cs)); + CU_ASSERT_STRING_EQUAL("windows-1252", charset_alias_name(cs)); charset_free(&cs); cs = charset_lookupname("1252"); CU_ASSERT_PTR_NOT_NULL(cs); - CU_ASSERT_STRING_EQUAL(charset_name(cs), "windows-1252"); + CU_ASSERT_STRING_EQUAL("windows-1252", charset_canon_name(cs)); + CU_ASSERT_STRING_EQUAL("1252", charset_alias_name(cs)); charset_free(&cs); - /* But still use the ICU name if there's no good alias for it */ + /* But still use the ICU name if there's no Cyrus canon name */ cs = charset_lookupname("ebcdic-ar"); CU_ASSERT_PTR_NOT_NULL(cs); - CU_ASSERT_STRING_EQUAL(charset_name(cs), "ibm-16804_X110-1999"); + CU_ASSERT_STRING_EQUAL("ibm-16804_X110-1999", charset_canon_name(cs)); + CU_ASSERT_STRING_EQUAL("ebcdic-ar", charset_alias_name(cs)); charset_free(&cs); - } static void test_to_utf8(void) { charset_t cs; char *s; + struct buf buf = BUF_INITIALIZER; + int r; + static const char ASCII_1[] = "Hello World"; static const char ASCII_2[] = "Hello W\370rld"; static const char UTF8_2[] = "Hello W" UTF8_REPLACEMENT "rld"; @@ -84,44 +90,70 @@ static void test_to_utf8(void) CU_ASSERT_PTR_NOT_NULL(cs); /* zero length input */ - s = charset_to_utf8("", 0, cs, ENCODING_NONE); + s = charset_to_utf8cstr("", 0, cs, ENCODING_NONE); CU_ASSERT_PTR_NOT_NULL(s); CU_ASSERT_STRING_EQUAL(s, ""); free(s); + r = charset_to_utf8(&buf, "", 0, cs, ENCODING_NONE); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(buf_len(&buf), 0); /* invalid encoding */ - s = charset_to_utf8(ASCII_1, sizeof(ASCII_1), cs, 0xdeadbeef); + s = charset_to_utf8cstr(ASCII_1, sizeof(ASCII_1), cs, 0xdeadbeef); CU_ASSERT_PTR_NULL(s); + r = charset_to_utf8(&buf, ASCII_1, sizeof(ASCII_1), cs, 0xdeadbeef); + CU_ASSERT_EQUAL(r, -1); + CU_ASSERT_EQUAL(buf_len(&buf), 0); /* invalid charset */ - s = charset_to_utf8(ASCII_1, sizeof(ASCII_1), NULL, ENCODING_NONE); + s = charset_to_utf8cstr(ASCII_1, sizeof(ASCII_1), NULL, ENCODING_NONE); CU_ASSERT_PTR_NULL(s); + r = charset_to_utf8(&buf, ASCII_1, sizeof(ASCII_1), NULL, ENCODING_NONE); + CU_ASSERT_EQUAL(r, -1); + CU_ASSERT_EQUAL(buf_len(&buf), 0); /* simple ASCII string */ - s = charset_to_utf8(ASCII_1, sizeof(ASCII_1)-1, cs, ENCODING_NONE); + s = charset_to_utf8cstr(ASCII_1, sizeof(ASCII_1)-1, cs, ENCODING_NONE); CU_ASSERT_PTR_NOT_NULL(s); CU_ASSERT_STRING_EQUAL(s, ASCII_1); free(s); + r = charset_to_utf8(&buf, ASCII_1, sizeof(ASCII_1)-1, cs, ENCODING_NONE); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(buf_len(&buf), strlen(ASCII_1)); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), ASCII_1); /* ASCII string with an invalid character */ - s = charset_to_utf8(ASCII_2, sizeof(ASCII_2)-1, cs, ENCODING_NONE); + s = charset_to_utf8cstr(ASCII_2, sizeof(ASCII_2)-1, cs, ENCODING_NONE); CU_ASSERT_PTR_NOT_NULL(s); CU_ASSERT_STRING_EQUAL(s, UTF8_2); free(s); + r = charset_to_utf8(&buf, ASCII_2, sizeof(ASCII_2)-1, cs, ENCODING_NONE); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(buf_len(&buf), strlen(UTF8_2)); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), UTF8_2); /* base64 encoding */ - s = charset_to_utf8(BASE64_3, sizeof(BASE64_3)-1, cs, ENCODING_BASE64); + s = charset_to_utf8cstr(BASE64_3, sizeof(BASE64_3)-1, cs, ENCODING_BASE64); CU_ASSERT_PTR_NOT_NULL(s); CU_ASSERT_STRING_EQUAL(s, ASCII_1); free(s); + r = charset_to_utf8(&buf, BASE64_3, sizeof(BASE64_3)-1, cs, ENCODING_BASE64); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(buf_len(&buf), strlen(ASCII_1)); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), ASCII_1); /* Quoted-printable encoding */ - s = charset_to_utf8(QP_4, sizeof(QP_4)-1, cs, ENCODING_QP); + s = charset_to_utf8cstr(QP_4, sizeof(QP_4)-1, cs, ENCODING_QP); CU_ASSERT_PTR_NOT_NULL(s); CU_ASSERT_STRING_EQUAL(s, ASCII_4); free(s); + r = charset_to_utf8(&buf, QP_4, sizeof(QP_4)-1, cs, ENCODING_QP); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(buf_len(&buf), strlen(ASCII_4)); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), ASCII_4); charset_free(&cs); + buf_free(&buf); } static void test_to_imaputf7(void) @@ -140,7 +172,7 @@ static void test_to_imaputf7(void) s = charset_to_imaputf7(_in, strlen(_in), csu8, ENCODING_NONE); \ CU_ASSERT_PTR_NOT_NULL(s); \ CU_ASSERT_STRING_EQUAL(s, _want); \ - q = charset_to_utf8(s, strlen(s), csu7, ENCODING_NONE); \ + q = charset_to_utf8cstr(s, strlen(s), csu7, ENCODING_NONE); \ CU_ASSERT_PTR_NOT_NULL(q); \ CU_ASSERT_STRING_EQUAL(q, _in); \ free(q); \ @@ -186,7 +218,7 @@ static void test_misc_charsets(void) static const char _want[] = (want); \ charset_t cs = charset_lookupname(alias); \ CU_ASSERT_PTR_NOT_NULL(cs); \ - s = charset_to_utf8(_in, strlen(_in), cs, ENCODING_NONE); \ + s = charset_to_utf8cstr(_in, strlen(_in), cs, ENCODING_NONE); \ CU_ASSERT_PTR_NOT_NULL(s); \ CU_ASSERT_STRING_EQUAL(s, _want); \ free(s); \ @@ -218,6 +250,43 @@ static void test_misc_charsets(void) "\xD1\x94\xD1\x96\xD1\x97\xD2\x91" "\xD0\x84\xD0\x86\xD0\x87\xD2\x90"); + /* iso-8859-1 aliased to windows-1252. */ + /* They differ in the range 0x80 to 0x9f, where iso-8859-1 + has control chars that are mostly useless for emails. + windows-1252 encoding has visible characters in that range. */ + TESTCASE("iso-8859-1", "\x80", "\xe2\x82\xac"); // Euro sign + TESTCASE("iso-8859-1", "\x81", "\xc2\x81"); // Unassigned + TESTCASE("iso-8859-1", "\x82", "\xe2\x80\x9a"); // Single Low-9 Quotation Mark + TESTCASE("iso-8859-1", "\x83", "\xc6\x92"); // Latin Small Letter F With Hook + TESTCASE("iso-8859-1", "\x84", "\xe2\x80\x9e"); // Double Low-9 Quotation Mark + TESTCASE("iso-8859-1", "\x85", "\xe2\x80\xa6"); // Horizontal Ellipsis + TESTCASE("iso-8859-1", "\x86", "\xe2\x80\xa0"); // Dagger + TESTCASE("iso-8859-1", "\x87", "\xe2\x80\xa1"); // Double Dagger + TESTCASE("iso-8859-1", "\x88", "\xcb\x86"); // Modifier Letter Circumflex Accent + TESTCASE("iso-8859-1", "\x89", "\xe2\x80\xb0"); // Per Mille Sign + TESTCASE("iso-8859-1", "\x8a", "\xc5\xa0"); // Latin Capital Letter S With Caron + TESTCASE("iso-8859-1", "\x8b", "\xe2\x80\xb9"); // Single Left-Pointing Angle Quotation Mark + TESTCASE("iso-8859-1", "\x8c", "\xc5\x92"); // Latin Capital Ligature OE + TESTCASE("iso-8859-1", "\x8d", "\xc2\x8d"); // Unassigned + TESTCASE("iso-8859-1", "\x8e", "\xc5\xbd"); // Latin Capital Letter Z With Caron + TESTCASE("iso-8859-1", "\x8f", "\xc2\x8f"); // Unassigned + TESTCASE("iso-8859-1", "\x90", "\xc2\x90"); // Unassigned + TESTCASE("iso-8859-1", "\x91", "\xe2\x80\x98"); // Left Single Quotation Mark + TESTCASE("iso-8859-1", "\x92", "\xe2\x80\x99"); // Right Single Quotation Mark + TESTCASE("iso-8859-1", "\x93", "\xe2\x80\x9c"); // Left Double Quotation Mark + TESTCASE("iso-8859-1", "\x94", "\xe2\x80\x9d"); // Right Double Quotation Mark + TESTCASE("iso-8859-1", "\x95", "\xe2\x80\xa2"); // Bullet + TESTCASE("iso-8859-1", "\x96", "\xe2\x80\x93"); // En Dash + TESTCASE("iso-8859-1", "\x97", "\xe2\x80\x94"); // Em Dash + TESTCASE("iso-8859-1", "\x98", "\xcb\x9c"); // Small Tilde + TESTCASE("iso-8859-1", "\x99", "\xe2\x84\xa2"); // Trade Mark Sign + TESTCASE("iso-8859-1", "\x9a", "\xc5\xa1"); // Latin Small Letter S With Caron + TESTCASE("iso-8859-1", "\x9b", "\xe2\x80\xba"); // Single Right-Pointing Angle Quotation Mark + TESTCASE("iso-8859-1", "\x9c", "\xc5\x93"); // Latin Small Ligature OE + TESTCASE("iso-8859-1", "\x9d", "\xc2\x9d"); // Unassigned + TESTCASE("iso-8859-1", "\x9e", "\xc5\xbe"); // Latin Small Letter Z With Caron + TESTCASE("iso-8859-1", "\x9f", "\xc5\xb8"); // Latin Capital Letter Y With Diaeresis + #undef TESTCASE } @@ -231,7 +300,7 @@ static void test_qp(void) charset_t _cs = charset_lookupname(cs); \ CU_ASSERT_PTR_NOT_NULL(_cs); \ int _enc = (enc); \ - char *s = charset_to_utf8(_in, sizeof(_in)-1, _cs, _enc); \ + char *s = charset_to_utf8cstr(_in, sizeof(_in)-1, _cs, _enc); \ CU_ASSERT_PTR_NOT_NULL(s); \ CU_ASSERT_STRING_EQUAL(s, _exp); \ free(s); \ @@ -267,11 +336,12 @@ static void test_qp(void) static void test_encode_mimeheader(void) { /* corner cases in Quoted-Printable */ -#define TESTCASE(in, exp) \ +#define TESTCASE(in, exp, len) \ { \ static const char _in[] = (in); \ static const char _exp[] = (exp); \ - char *s = charset_encode_mimeheader(_in, 0, 0); \ + int _len = (len); \ + char *s = charset_encode_mimeheader(_in, _len, 0); \ CU_ASSERT_PTR_NOT_NULL(s); \ CU_ASSERT_STRING_EQUAL(s, _exp); \ const char *p, *lf; \ @@ -285,24 +355,60 @@ static void test_encode_mimeheader(void) free(s); \ } - TESTCASE("abc", "abc"); + TESTCASE("abc", "abc", 0); - TESTCASE("abc\r\n", "=?UTF-8?Q?abc?="); + TESTCASE("abc\r\n", "=?UTF-8?Q?abc?=", 0); /* bogus indent */ - TESTCASE("abc\r\nxyz", "=?UTF-8?Q?abc?=\r\n =?UTF-8?Q?xyz?="); + TESTCASE("abc\r\nxyz", "=?UTF-8?Q?abc?=\r\n =?UTF-8?Q?xyz?=", 0); /* wrap */ - TESTCASE("abc\r\n xyz", "=?UTF-8?Q?abc?=\r\n =?UTF-8?Q?xyz?="); + TESTCASE("abc\r\n xyz", "=?UTF-8?Q?abc?=\r\n =?UTF-8?Q?xyz?=", 0); /* three-byte UTF-8 word barely fits line length limit */ TESTCASE("0123456789012345678901234567890123456789012345678901234\xe2\x82\xac", - "=?UTF-8?Q?0123456789012345678901234567890123456789012345678901234=E2=82=AC?="); + "=?UTF-8?Q?0123456789012345678901234567890123456789012345678901234=E2=82=AC?=", 0); /* three-byte UTF-8 word must not be split */ TESTCASE("01234567890123456789012345678901234567890123456789012345\xe2\x82\xac", "=?UTF-8?Q?01234567890123456789012345678901234567890123456789012345?=" - "\r\n ""=?UTF-8?Q?=E2=82=AC?="); + "\r\n ""=?UTF-8?Q?=E2=82=AC?=", 0); + + /* only encode display-name of address */ + TESTCASE("\"abc\xe2\x88\x97xyz\" ", "=?UTF-8?Q?=22abc=E2=88=97xyz=22?= ", 0); + + /* fold long lines with whitespace */ + TESTCASE("012345678 " "012345678 " "012345678 " "012345678 " + "012345678 " "012345678 " "012345678 " "0123456789", + "012345678 " "012345678 " "012345678 " "012345678 " + "012345678 " "012345678 " "012345678" "\r\n " + "0123456789", 0) + + /* fold long lines with whitespace (truncated) */ + TESTCASE("012345678 " "012345678 " "012345678 " "012345678 " + "012345678 " "012345678 " "012345678 " "0123456789", + "012345678 " "012345678 " "012345678 " "012345678 " + "012345678 " "012345678 " "012345678" "\r\n " + "012345678", 79); + + /* truncate long lines */ + TESTCASE("012345678 " "012345678 " "012345678 " "012345678 " + "012345678 " "012345678 " "012345678 " "0123456789", + "012345678 " "012345678 " "01234", 25); + + /* encode long lines with no whitespace */ + TESTCASE("0123456789" "0123456789" "0123456789" "0123456789" + "0123456789" "0123456789" "0123456789" "0123456789", + "=?UTF-8?Q?" "0123456789" "0123456789" "0123456789" "0123456789" + "0123456789" "0123456789" "01?=\r\n " + "=?UTF-8?Q?" "2345678901" "23456789?=", 0); + + /* encode long lines with no whitespace (truncated) */ + TESTCASE("0123456789" "0123456789" "0123456789" "0123456789" + "0123456789" "0123456789" "0123456789" "0123456789", + "=?UTF-8?Q?" "0123456789" "0123456789" "0123456789" "0123456789" + "0123456789" "0123456789" "01?=\r\n " + "=?UTF-8?Q?" "2345678901" "2345678?=", 79); #undef TESTCASE } @@ -396,13 +502,13 @@ static void test_decode_mimeheader(void) CU_ASSERT_STRING_EQUAL(s, HTML_4b); free(s); - flags = CHARSET_SNIPPET; + flags = CHARSET_KEEPCASE; s = charset_decode_mimeheader(HTML_4, flags); CU_ASSERT_PTR_NOT_NULL(s); CU_ASSERT_STRING_EQUAL(s, HTML_4c); free(s); - flags = CHARSET_SNIPPET|CHARSET_ESCAPEHTML; + flags = CHARSET_KEEPCASE|CHARSET_ESCAPEHTML; s = charset_decode_mimeheader(HTML_4, flags); CU_ASSERT_PTR_NOT_NULL(s); CU_ASSERT_STRING_EQUAL(s, HTML_4d); @@ -489,7 +595,7 @@ static void test_mimeheader_badcharset(void) static void test_mimeheader_mergeqwords(void) { - int flags = CHARSET_SKIPDIACRIT | CHARSET_MERGESPACE | CHARSET_SNIPPET; + int flags = CHARSET_SKIPDIACRIT | CHARSET_MERGESPACE | CHARSET_KEEPCASE; static const char SPLIT_Q[] = "=?utf-8?q?=E2=82?= =?utf-8?q?=AC?="; static const char SPLIT_B[] = "=?utf-8?b?wg==?= =?utf-8?b?oUhvbGEsc2XD?= =?utf-8?b?sW9yIQ==?="; @@ -562,15 +668,19 @@ static void test_encode_mimexvalue(void) s = charset_encode_mimexvalue(NULL, NULL); CU_ASSERT_PTR_NULL(s); - /* naively encode everything in octets, including ASCII */ s = charset_encode_mimexvalue("foo", NULL); CU_ASSERT_PTR_NOT_NULL(s); - CU_ASSERT_STRING_EQUAL(s, "utf-8''%66%6f%6f"); + CU_ASSERT_STRING_EQUAL(s, "utf-8''foo"); free(s); s = charset_encode_mimexvalue("foo", "en"); CU_ASSERT_PTR_NOT_NULL(s); - CU_ASSERT_STRING_EQUAL(s, "utf-8'en'%66%6f%6f"); + CU_ASSERT_STRING_EQUAL(s, "utf-8'en'foo"); + free(s); + + s = charset_encode_mimexvalue("R\xc3\xe4tsel", "de"); + CU_ASSERT_PTR_NOT_NULL(s); + CU_ASSERT_STRING_EQUAL(s, "utf-8'de'R%C3%E4tsel"); free(s); } @@ -730,12 +840,14 @@ struct text_rock { struct buf out; }; -static void append_text(const struct buf *text, void *rock) +static int append_text(const struct buf *text, void *rock) { struct text_rock *tr = (struct text_rock *)rock; tr->ncalls++; buf_append(&tr->out, text); + + return 0; } #define TESTCASE(in, cs, enc, st, exp) \ @@ -754,7 +866,7 @@ static void append_text(const struct buf *text, void *rock) buf_init_ro(&bin, _in, sizeof(_in)-1); \ \ r = charset_extract(append_text, &tr, &bin, _cs, _enc, _st, flags); \ - CU_ASSERT_EQUAL(r, 1); \ + CU_ASSERT_EQUAL(r, 0); \ CU_ASSERT_EQUAL(tr.ncalls, 1); \ CU_ASSERT_STRING_EQUAL(buf_cstring(&tr.out), _exp); \ \ @@ -840,7 +952,7 @@ static void test_extract(void) /* HTML: OMITTAG tags with and without end tags */ TESTCASE("
Terry

Richardson", "us-ascii", ENCODING_NONE, "HTML", - " TERRY RICHARDSON "); + "TERRY RICHARDSON"); /* HTML: non-phrasing tags are replaced with whitespace */ TESTCASE("hella
mlkshk", @@ -851,12 +963,12 @@ static void test_extract(void) "GODARD SYNTH"); TESTCASE("
vinyl
narwhal
", "us-ascii", ENCODING_NONE, "HTML", - " VINYL NARWHAL "); + "VINYL NARWHAL"); /* HTML: quoted tag parameters */ TESTCASE("leggings gastropub", "us-ascii", ENCODING_NONE, "HTML", - "LEGGINGS GASTROPUB"); + "LEGGINGS GASTROPUB"); /* HTML: unquoted tag parameters */ TESTCASE("biodiesel seitan", @@ -929,7 +1041,7 @@ static void test_extract(void) /* HTML: naked & is emitted */ TESTCASE("gentrify&sartorial", "us-ascii", ENCODING_NONE, "HTML", - "GENTRIFY&SARTORIAL"); + "GENTRIFY& SARTORIAL"); /* HTML: non-zero length unterminated entities are emitted */ TESTCASE("tattooed& locavore", @@ -1073,20 +1185,72 @@ static void test_extract(void) TESTCASE("A&nonesuch;B", "us-ascii", ENCODING_NONE, "HTML", "A"UTF8_REPLACEMENT"B"); /* HTML: Strip HTML from snippet and don't canonify */ - flags = CHARSET_SNIPPET; + flags = CHARSET_KEEPCASE; TESTCASE("Photo booth", "us-ascii", ENCODING_NONE, "HTML", "Photo booth"); /* HTML: Strip HTML from snippet, there is nothing to escape */ - flags = CHARSET_SNIPPET|CHARSET_ESCAPEHTML; + flags = CHARSET_KEEPCASE|CHARSET_ESCAPEHTML; TESTCASE("Photo", "us-ascii", ENCODING_NONE, "HTML", "Photo"); /* PLAIN: Generate snippet, don't escape HTML by default */ - flags = CHARSET_SNIPPET; + flags = CHARSET_KEEPCASE; TESTCASE("Photo", "us-ascii", ENCODING_NONE, "PLAIN", "Photo"); /* PLAIN: Generate snippet with escaped HTML */ - flags = CHARSET_SNIPPET|CHARSET_ESCAPEHTML; + flags = CHARSET_KEEPCASE|CHARSET_ESCAPEHTML; TESTCASE("Photo", "us-ascii", ENCODING_NONE, "PLAIN", "<b>Photo</b>"); + + /* HTML: strip HTML, including angle-bracketed URIs */ + flags = CHARSET_KEEPCASE; + TESTCASE("xy", "us-ascii", ENCODING_NONE, "HTML", "x y"); + + /* HTML: strip HTML, keep angle-bracketed URIs */ + flags |= CHARSET_KEEP_ANGLEURI; + TESTCASE("xy", "us-ascii", ENCODING_NONE, "HTML", "xy"); + + /* HTML: regression tests for '/' and '>' characters '*/ + flags = CHARSET_SKIPDIACRIT | CHARSET_MERGESPACE; /* default */ + // end of tag after attribute name + TESTCASE("X
Y
Z", "us-ascii", ENCODING_NONE, "HTML", "X Y Z"); + TESTCASE("X
Y
Z", "us-ascii", ENCODING_NONE, "HTML", "X Y Z"); + // quoted attribute value ends without quote + TESTCASE("x", "us-ascii", ENCODING_NONE, "HTML", "X"); + // ">" in quoted attribute value is legit, but broken in our parser + TESTCASE("
\"x\" src=\"y\"/>
", "us-ascii", ENCODING_NONE, "HTML", "\" SRC=\"Y\"/>"); + + /* HTML: extract URLs and alt text from href and img */ + flags = CHARSET_SKIPDIACRIT | CHARSET_MERGESPACE; /* default */ + TESTCASE("", "us-ascii", ENCODING_NONE, "HTML", + ""); + TESTCASE("\"A", "us-ascii", ENCODING_NONE, "HTML", + " (A CAT)"); + TESTCASE("", "us-ascii", ENCODING_NONE, "HTML", + ""); + // "/" in quoted attribute value is legit + TESTCASE("
\"letterpress/\"stumptown
", "us-ascii", ENCODING_NONE, "HTML", "STUMPTOWN (LETTERPRESS/)"); + // multiple hrefs in same chunk of text + TESTCASE("
", + "us-ascii", ENCODING_NONE, "HTML", + " "); + TESTCASE("", + "us-ascii", ENCODING_NONE, "HTML", + " "); + // multiple alt tags in same chunk of text + TESTCASE("\"A\"A", + "us-ascii", ENCODING_NONE, "HTML", + " (A CAT) (A DOG)"); + // multiple hrefs in same A tag (invalid!) + TESTCASE("", + "us-ascii", ENCODING_NONE, "HTML", + ""); /* last value seen is kept */ + // multiple alt in same IMG tag (invalid!) + TESTCASE("\"A", + "us-ascii", ENCODING_NONE, "HTML", + " (A TABBY CAT)"); /* last value seen is kept */ + // multiple src in same IMG tag (invalid!) + TESTCASE("A tabby cat", + "us-ascii", ENCODING_NONE, "HTML", + " (A TABBY CAT)"); /* last value seen is kept */ } #undef TESTCASE @@ -1136,25 +1300,19 @@ static void test_utf8_to_searchform(void) static void test_charset_decode(void) { -#define TESTCASE(in, inlen, want, wantlen, enc) \ +#define TESTCASE(encoded, encodedlen, decoded, decodedlen, encoding) \ { \ - struct buf _dst = BUF_INITIALIZER; \ - static const char _in[] = (in); \ - static const char _want[] = (want); \ - int _enc = (enc); \ - size_t _inlen = (inlen); \ - size_t _wantlen = (wantlen); \ - int _r; \ - _r = charset_decode(&_dst, _in, _inlen, _enc); \ - CU_ASSERT(_r == 0); \ - CU_ASSERT(_dst.len == _wantlen); \ - { \ - const char *p; \ - for(p = _dst.s; p < _dst.s + _dst.len; p++) { \ - if (0) fprintf(stderr, "%x\n", *p); \ - } \ - } \ - CU_ASSERT(memcmp(_dst.s, _want, _wantlen) == 0); \ + struct buf buf = BUF_INITIALIZER; \ + int r = charset_decode(&buf, encoded, encodedlen, encoding); \ + CU_ASSERT(r == 0); \ + CU_ASSERT(buf.len == decodedlen); \ + CU_ASSERT(memcmp(buf.s, decoded, decodedlen) == 0); \ + buf_reset(&buf); \ + r = charset_encode(&buf, decoded, decodedlen, encoding); \ + CU_ASSERT(r == 0); \ + CU_ASSERT(buf.len == encodedlen); \ + CU_ASSERT(memcmp(buf.s, encoded, encodedlen) == 0); \ + buf_free(&buf); \ } TESTCASE("", 0, "", 0, ENCODING_NONE); @@ -1170,7 +1328,33 @@ static void test_charset_decode(void) TESTCASE("Zm9vYmE=", 8, "fooba", 5, ENCODING_BASE64); TESTCASE("Zm9vYmFy", 8, "foobar", 6, ENCODING_BASE64); + /* Base64 URL encoding */ + TESTCASE("vu_A3g", 6, "\xbe\xef\xc0\xde", 4, ENCODING_BASE64URL); + TESTCASE("vu_A3g==", 6, "\xbe\xef\xc0\xde", 4, ENCODING_BASE64URL); + #undef TESTCASE + + struct buf buf = BUF_INITIALIZER; + int r; + + /* Base64 with ignored whitespace */ + r = charset_decode(&buf, "Zm 9v\rYm\nFy", 11, ENCODING_BASE64); + CU_ASSERT_EQUAL(0, r); + CU_ASSERT_STRING_EQUAL("foobar", buf_cstring(&buf)); + buf_reset(&buf); + + /* Base64 with invalid characters */ + r = charset_decode(&buf, "Zm9v@@@YmFy", 11, ENCODING_BASE64); + CU_ASSERT_EQUAL(0, r); + CU_ASSERT_STRING_EQUAL("foobar", buf_cstring(&buf)); + buf_reset(&buf); + + /* Base64url with invalid characters */ + r = charset_decode(&buf, "Zm9v@@@YmFy", 11, ENCODING_BASE64URL); + CU_ASSERT_EQUAL(-1, r); + buf_reset(&buf); + + buf_free(&buf); } static void test_broken_length_hint(void) @@ -1182,9 +1366,10 @@ static void test_broken_length_hint(void) cs = charset_lookupname("iso-8859-1"); CU_ASSERT_PTR_NOT_NULL(cs); - s = charset_to_utf8(body, strlen(body), cs, ENCODING_QP); + s = charset_to_utf8cstr(body, strlen(body), cs, ENCODING_QP); CU_ASSERT_STRING_EQUAL(body, s); charset_free(&cs); + free(s); } static void test_utf8_normalize(void) @@ -1200,6 +1385,16 @@ static void test_utf8_normalize(void) CU_ASSERT_PTR_NULL(s); \ } \ free(s); \ + charset_t utf8 = charset_lookupname("utf8"); \ + s = charset_convert(in, utf8, CHARSET_UNORM_NFC|CHARSET_KEEPCASE); \ + if (want) { \ + CU_ASSERT_PTR_NOT_NULL(s); \ + CU_ASSERT_STRING_EQUAL(s, want); \ + } else { \ + CU_ASSERT_PTR_NULL(s); \ + } \ + free(s); \ + charset_free(&utf8); \ } /* Empty is empty */ @@ -1230,6 +1425,33 @@ static void test_utf8_normalize(void) */ TESTCASE("\x71\xcc\x87\xcc\xa3", "\x71\xcc\xa3\xcc\x87"); + /* + * Concatination of multiple examples: + * ANGSTROM SIGN (U+212B) + * OHM SIGN (U+2126) + * LATIN SMALL LETTER S WITH DOT BELOW AND DOT ABOVE (U+1E69) + * LATIN SMALL LETTER D WITH DOT ABOVE (U+1E0B) + * COMBINING DOT BELOW (U+0323) + * to + * LATIN CAPITAL LETTER A WITH RING ABOVE (U+00C5) + * GREEK CAPITAL LETTER OMEGA (U+03A9) + * LATIN SMALL LETTER S WITH DOT BELOW AND DOT ABOVE (U+1E69) + * LATIN SMALL LETTER D WITH DOT BELOW (U+1E0D) + * COMBINING DOT ABOVE (U+0307) + */ + TESTCASE( + "\xE2\x84\xAB" + "\xE2\x84\xA6" + "\xE1\xB9\xA9" + "\xE1\xB8\x8B" + "\xCC\xA3", + "\xC3\x85" + "\xCE\xA9" + "\xE1\xB9\xA9" + "\xE1\xB8\x8D" + "\xCC\x87" + ); + #undef TESTCASE } @@ -1253,6 +1475,11 @@ static void test_count_validutf8(void) CU_ASSERT_EQUAL(5, counts.valid); CU_ASSERT_EQUAL(0, counts.replacement); CU_ASSERT_EQUAL(0, counts.invalid); + CU_ASSERT_EQUAL(0, counts.bytelen[0]); + CU_ASSERT_EQUAL(0, counts.bytelen[1]); + CU_ASSERT_EQUAL(4, counts.bytelen[2]); + CU_ASSERT_EQUAL(1, counts.bytelen[3]); + CU_ASSERT_EQUAL(0, counts.bytelen[4]); /* Two continuation bytes */ data = "a \x80\xbf\x80 text"; @@ -1260,6 +1487,11 @@ static void test_count_validutf8(void) CU_ASSERT_EQUAL(7, counts.valid); CU_ASSERT_EQUAL(0, counts.replacement); CU_ASSERT_EQUAL(3, counts.invalid); + CU_ASSERT_EQUAL(3, counts.bytelen[0]); + CU_ASSERT_EQUAL(7, counts.bytelen[1]); + CU_ASSERT_EQUAL(0, counts.bytelen[2]); + CU_ASSERT_EQUAL(0, counts.bytelen[3]); + CU_ASSERT_EQUAL(0, counts.bytelen[4]); /* Lonely start character, followed by 3-byte char, plus replacement char */ data = "\xfc\xe2\x82\xac \xef\xbf\xbd"; @@ -1267,6 +1499,11 @@ static void test_count_validutf8(void) CU_ASSERT_EQUAL(2, counts.valid); CU_ASSERT_EQUAL(1, counts.replacement); CU_ASSERT_EQUAL(1, counts.invalid); + CU_ASSERT_EQUAL(1, counts.bytelen[0]); + CU_ASSERT_EQUAL(1, counts.bytelen[1]); + CU_ASSERT_EQUAL(0, counts.bytelen[2]); + CU_ASSERT_EQUAL(2, counts.bytelen[3]); + CU_ASSERT_EQUAL(0, counts.bytelen[4]); } static void test_qpencode_mimebody(void) @@ -1303,4 +1540,109 @@ static void test_qpencode_mimebody(void) #undef TESTCASE } +static void test_encode_mimebody(void) +{ +#define TESTCASE(in, want, wrap, wantlines) \ + { \ + size_t inlen = strlen(in); \ + size_t wantlen = strlen(want); \ + size_t preflight_outlen = 0xffee; \ + int preflight_outlines = 0x1234; \ + char *preflight_retval = charset_b64encode_mimebody(NULL, inlen, NULL, \ + &preflight_outlen, &preflight_outlines, wrap); \ + CU_ASSERT_PTR_NULL(preflight_retval); \ + CU_ASSERT_EQUAL(wantlen, preflight_outlen); \ + CU_ASSERT_EQUAL(wantlines, preflight_outlines); \ + char *retval = malloc(preflight_outlen); \ + size_t encode_outlen = 0x1234; \ + int encode_outlines = 0xffee; \ + char *encode_retval = charset_b64encode_mimebody(in, inlen, \ + retval, &encode_outlen, &encode_outlines, wrap); \ + CU_ASSERT_PTR_EQUAL(retval, encode_retval); \ + CU_ASSERT_EQUAL(wantlen, encode_outlen); \ + CU_ASSERT_MEMEQUAL(retval, want, wantlen); \ + CU_ASSERT_EQUAL(wantlines, encode_outlines); \ + free(retval); \ + } + + TESTCASE("a", "YQ==", 0, 1); + TESTCASE("ab", "YWI=", 0, 1); + TESTCASE("abc", "YWJj", 0, 1); + TESTCASE("abcd", "YWJjZA==", 0, 1); + TESTCASE("abcde", "YWJjZGU=", 0, 1); + TESTCASE("abcdef", "YWJjZGVm", 0, 1); + + + TESTCASE("012345678901234567890123456789012345678901234567890123", + "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIz\r\n", + 1, 1); + + TESTCASE("0123456789012345678901234567890123456789012345678901234", + "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIz\r\n" + "NA==\r\n", + 1, 2); + +#undef TESTCASE +} + +static void test_decode_percent(void) +{ + struct buf buf = BUF_INITIALIZER; + + int r = charset_decode_percent(&buf, "%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D"); + CU_ASSERT_EQUAL(0, r); + CU_ASSERT_STRING_EQUAL("!#$%&'()*+,/:;=?@[]", buf_cstring(&buf)); + + // leave bogus percent encodings as-is + r = charset_decode_percent(&buf, "a%21b%2xc%24d"); + CU_ASSERT_EQUAL(-1, r); + CU_ASSERT_STRING_EQUAL("a!b%2xc$d", buf_cstring(&buf)); + + buf_free(&buf); +} + +static void test_unicode_casemap(void) +{ +#define TESTCASE(in, want) \ + { \ + char *s; \ + static const char _in[] = (in); \ + static const char _want[] = (want); \ + s = unicode_casemap(_in, -1); \ + CU_ASSERT_PTR_NOT_NULL(s); \ + CU_ASSERT_STRING_EQUAL(s, _want); \ + free(s); \ + } + + /* Plain US-ASCII: Out = ucase(In) */ + TESTCASE("Hello, World!", "HELLO, WORLD!"); + + /* In: "DŽ" + LATIN CAPITAL LETTER DZ WITH CARON (U+01C4) + * Out: LATIN CAPITAL LETTER D (U+0044) + LATIN SMALL LETTER Z (U+007A) + COMBINING CARON (U+030C) + */ + TESTCASE("\xC7\x84", "\x44\x7A\xCC\x8C"); + + /* In: "pâté" + LATIN CAPITAL SMALL LETTER P (U+0070) + LATIN SMALL LETTER A WITH CIRCUMFLEX (U+00E2) + LATIN SMALL LETTER T (U+0074) + LATIN SMALL LETTER E WITH ACUTE (U+00E9) + * Out: LATIN CAPITAL LETTER P (U+0050) + LATIN CAPITAL LETTER A (U+0041) + COMBINING CIRCUMFLEX ACCENT (U+0302) + LATIN CAPITAL LETTER T (U+0054) + LATIN CAPITAL LETTER E (U+0045) + COMBINING ACUTE ACCENT (U+0301) + */ + TESTCASE("p\xC3\xA2t\xC3\xA9", "PA\xCC\x82TE\xCC\x81"); + + /* Invalid UTF-8: Out = In */ + TESTCASE("junk\xFF", "junk\xFF"); + +#undef TESTCASE +} + /* vim: set ft=c: */ diff --git a/cunit/command.testc b/cunit/command.testc index 83f757119f..02ededf9f1 100644 --- a/cunit/command.testc +++ b/cunit/command.testc @@ -3,6 +3,7 @@ #include "cunit/cyrunit.h" #include #include "command.h" +#include "xunlink.h" const char canary[] = "canary.txt"; @@ -12,7 +13,7 @@ static void test_run(void) struct stat sb; /* make sure the file isnt there */ - r = unlink(canary); + r = xunlink(canary); if (r < 0) r = errno; if (r == ENOENT) r = 0; CU_ASSERT_EQUAL_FATAL(r, 0); @@ -23,7 +24,7 @@ static void test_run(void) r = stat(canary, &sb); if (r < 0) r = errno; CU_ASSERT_EQUAL_FATAL(r, 0); - unlink(canary); + xunlink(canary); } static void test_popen_r(void) @@ -59,7 +60,7 @@ static void test_popen_w(void) char buf[32]; /* make sure the file isnt there */ - r = unlink(canary); + r = xunlink(canary); if (r < 0) r = errno; if (r == ENOENT) r = 0; CU_ASSERT_EQUAL_FATAL(r, 0); @@ -86,7 +87,7 @@ static void test_popen_w(void) CU_ASSERT_STRING_EQUAL(buf, WORD0); fclose(fp); - unlink(canary); + xunlink(canary); #undef WORD0 } diff --git a/cunit/conversations.testc b/cunit/conversations.testc index 981b278fa4..ee9779e4b3 100644 --- a/cunit/conversations.testc +++ b/cunit/conversations.testc @@ -4,12 +4,14 @@ #include "cunit/cyrunit.h" #include "imap/conversations.h" #include "imap/global.h" +#include "retry.h" #include "strarray.h" #include "cyrusdb.h" #include "libcyr_cfg.h" #include "lib/util.h" /* for VECTOR_SIZE */ //#include "message.h" /* for VECTOR_SIZE */ #include "xmalloc.h" +#include "xunlink.h" #define DBDIR "test-dbdir" #define DBNAME DBDIR "/conversations.db" @@ -388,6 +390,9 @@ static void test_folder_rename(void) CU_ASSERT_EQUAL_FATAL(r, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(state); + /* XXX Hack so we don't have to rewrite this test to use mailboxes.db */ + state->folders_byname = 1; + /* setup the records we expect */ r = conversations_add_msgid(state, C_MSGID1, C_CID); CU_ASSERT_EQUAL(r, 0); @@ -399,22 +404,31 @@ static void test_folder_rename(void) conv = conversation_new(); CU_ASSERT_PTR_NOT_NULL(conv); - conversation_update(state, conv, FOLDER1, - /*is_trash*/0, /*num_records*/3, - /*exists*/3, /*unseen*/0, - /*size*/0, /*counts*/NULL, + int f1 = conversation_folder_number(state, FOLDER1, 1); + struct emailcounts ecounts1 = { f1, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 1, 0, 1, 1, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + + conversation_update(state, conv, + &ecounts1, + /*size*/10, /*counts*/NULL, /*modseq*/1, - /*createdmodseq*/1); - conversation_update(state, conv, FOLDER2, - /*is_trash*/0, /*num_records*/3, - /*exists*/2, /*unseen*/0, - /*size*/0, /*counts*/NULL, + /*createdmodseq*/1, + /*silent*/0); + + int f2 = conversation_folder_number(state, FOLDER2, 1); + struct emailcounts ecounts2 = { f2, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 1, 0, 1, 1, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + + conversation_update(state, conv, + &ecounts2, + /*size*/20, /*counts*/NULL, /*modseq*/8, - /*createdmodseq*/1); + /*createdmodseq*/1, + /*silent*/0); conv->subject = xstrdup("☃"); - r = conversation_save(state, C_CID, conv, NULL); + r = conversation_save(state, C_CID, conv); CU_ASSERT_EQUAL(r, 0); conversation_free(conv); @@ -445,17 +459,17 @@ static void test_folder_rename(void) CU_ASSERT_PTR_NOT_NULL_FATAL(conv); CU_ASSERT_EQUAL(conv->modseq, 8); CU_ASSERT_EQUAL(num_folders(conv), 2); - CU_ASSERT_EQUAL(conv->exists, 5); + CU_ASSERT_EQUAL(conv->exists, 2); folder = conversation_find_folder(state, conv, FOLDER1); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->exists, 3); + CU_ASSERT_EQUAL(folder->exists, 1); /* no record for folder2 */ folder = conversation_find_folder(state, conv, FOLDER2); CU_ASSERT_PTR_NULL_FATAL(folder); /* have a record for folder3 */ folder = conversation_find_folder(state, conv, FOLDER3); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->exists, 2); + CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_STRING_EQUAL(conv->subject, "☃"); conversation_free(conv); conv = NULL; @@ -484,6 +498,9 @@ static void test_folders(void) r = conversations_open_path(DBNAME3, NULL, 0/*shared*/, &state); CU_ASSERT_EQUAL_FATAL(r, 0); + /* XXX Hack so we don't have to rewrite this test to use mailboxes.db */ + state->folders_byname = 1; + imapopts[IMAPOPT_CONVERSATIONS_COUNTED_FLAGS].val.s = NULL; CU_ASSERT_EQUAL(state->counted_flags->count, 2); @@ -504,26 +521,29 @@ static void test_folders(void) counts[0] = 1; counts[1] = 0; - conversation_update(state, conv, FOLDER1, - /*is_trash*/0, /*num_records*/13, - /*exists*/7, /*unseen*/5, + int f1 = conversation_folder_number(state, FOLDER1, 1); + struct emailcounts ecounts1 = { f1, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 1, 0, 1, 1, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + conversation_update(state, conv, + &ecounts1, /*size*/0, counts, /*modseq*/4, - /*createdmodseq*/1); + /*createdmodseq*/1, + /*silent*/0); /* make sure the data we just passed to conversation_update() * is present in the structure */ - CU_ASSERT_EQUAL(conv->num_records, 13); - CU_ASSERT_EQUAL(conv->exists, 7); - CU_ASSERT_EQUAL(conv->unseen, 5); + CU_ASSERT_EQUAL(conv->num_records, 1); + CU_ASSERT_EQUAL(conv->exists, 1); + CU_ASSERT_EQUAL(conv->unseen, 0); CU_ASSERT_EQUAL(conv->counts[0], 1); CU_ASSERT_EQUAL(conv->counts[1], 0); CU_ASSERT_EQUAL(conv->modseq, 4); CU_ASSERT_EQUAL(num_folders(conv), 1); folder = conversation_find_folder(state, conv, FOLDER1); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 13); - CU_ASSERT_EQUAL(folder->exists, 7); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 4); CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), CONV_ISDIRTY); folder = conversation_find_folder(state, conv, FOLDER2); @@ -535,7 +555,7 @@ static void test_folders(void) folder = conversation_find_folder(state, conv, FOLDER_N2); CU_ASSERT_PTR_NULL_FATAL(folder); - r = conversation_save(state, C_CID, conv, NULL); + r = conversation_save(state, C_CID, conv); CU_ASSERT_EQUAL(r, 0); conversation_free(conv); conv = NULL; @@ -546,17 +566,17 @@ static void test_folders(void) CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), 0); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NOT_NULL(conv); - CU_ASSERT_EQUAL(conv->num_records, 13); - CU_ASSERT_EQUAL(conv->exists, 7); - CU_ASSERT_EQUAL(conv->unseen, 5); + CU_ASSERT_EQUAL(conv->num_records, 1); + CU_ASSERT_EQUAL(conv->exists, 1); + CU_ASSERT_EQUAL(conv->unseen, 0); CU_ASSERT_EQUAL(conv->counts[0], 1); CU_ASSERT_EQUAL(conv->counts[1], 0); CU_ASSERT_EQUAL(conv->modseq, 4); CU_ASSERT_EQUAL(num_folders(conv), 1); folder = conversation_find_folder(state, conv, FOLDER1); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 13); - CU_ASSERT_EQUAL(folder->exists, 7); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 4); CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), 0); folder = conversation_find_folder(state, conv, FOLDER2); @@ -570,29 +590,32 @@ static void test_folders(void) counts[1] = 2; /* some more updates should succeed */ - conversation_update(state, conv, FOLDER2, - /*is_trash*/0, /*num_records*/2, - /*exists*/1, /*unseen*/0, + int f2 = conversation_folder_number(state, FOLDER2, 1); + struct emailcounts ecounts2 = { f2, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 1, 1, 1, 1, 1 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + conversation_update(state, conv, + &ecounts2, /*size*/0, counts, /*modseq*/7, - /*createdmodseq*/1); + /*createdmodseq*/1, + /*silent*/0); CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), CONV_ISDIRTY); - CU_ASSERT_EQUAL(conv->num_records, 15); - CU_ASSERT_EQUAL(conv->exists, 8); - CU_ASSERT_EQUAL(conv->unseen, 5); + CU_ASSERT_EQUAL(conv->num_records, 2); + CU_ASSERT_EQUAL(conv->exists, 2); + CU_ASSERT_EQUAL(conv->unseen, 1); CU_ASSERT_EQUAL(conv->counts[0], 2); CU_ASSERT_EQUAL(conv->counts[1], 2); CU_ASSERT_EQUAL(conv->modseq, 7); CU_ASSERT_EQUAL(num_folders(conv), 2); folder = conversation_find_folder(state, conv, FOLDER1); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 13); - CU_ASSERT_EQUAL(folder->exists, 7); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 4); folder = conversation_find_folder(state, conv, FOLDER2); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 2); + CU_ASSERT_EQUAL(folder->num_records, 1); CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 7); folder = conversation_find_folder(state, conv, FOLDER3); @@ -604,42 +627,45 @@ static void test_folders(void) counts[1] = 5; - conversation_update(state, conv, FOLDER3, - /*is_trash*/0, /*num_records*/10, - /*exists*/10, /*unseen*/0, + int f3 = conversation_folder_number(state, FOLDER3, 1); + struct emailcounts ecounts3 = { f3, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 0, 0, 1, 0, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + conversation_update(state, conv, + &ecounts3, /*size*/0, counts, /*modseq*/55, - /*createdmodseq*/1); + /*createdmodseq*/1, + /*silent*/0); CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), CONV_ISDIRTY); - CU_ASSERT_EQUAL(conv->num_records, 25); - CU_ASSERT_EQUAL(conv->exists, 18); - CU_ASSERT_EQUAL(conv->unseen, 5); + CU_ASSERT_EQUAL(conv->num_records, 3); + CU_ASSERT_EQUAL(conv->exists, 2); + CU_ASSERT_EQUAL(conv->unseen, 1); CU_ASSERT_EQUAL(conv->counts[0], 3); CU_ASSERT_EQUAL(conv->counts[1], 7); CU_ASSERT_EQUAL(conv->modseq, 55); CU_ASSERT_EQUAL(num_folders(conv), 3); folder = conversation_find_folder(state, conv, FOLDER1); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 13); - CU_ASSERT_EQUAL(folder->exists, 7); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 4); folder = conversation_find_folder(state, conv, FOLDER2); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 2); + CU_ASSERT_EQUAL(folder->num_records, 1); CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 7); folder = conversation_find_folder(state, conv, FOLDER3); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 10); - CU_ASSERT_EQUAL(folder->exists, 10); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 0); CU_ASSERT_EQUAL(folder->modseq, 55); folder = conversation_find_folder(state, conv, FOLDER_N1); CU_ASSERT_PTR_NULL_FATAL(folder); folder = conversation_find_folder(state, conv, FOLDER_N2); CU_ASSERT_PTR_NULL_FATAL(folder); - r = conversation_save(state, C_CID, conv, NULL); + r = conversation_save(state, C_CID, conv); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), 0); conversation_free(conv); @@ -650,27 +676,27 @@ static void test_folders(void) r = conversation_load(state, C_CID, &conv); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NOT_NULL(conv); - CU_ASSERT_EQUAL(conv->num_records, 25); - CU_ASSERT_EQUAL(conv->exists, 18); - CU_ASSERT_EQUAL(conv->unseen, 5); + CU_ASSERT_EQUAL(conv->num_records, 3); + CU_ASSERT_EQUAL(conv->exists, 2); + CU_ASSERT_EQUAL(conv->unseen, 1); CU_ASSERT_EQUAL(conv->counts[0], 3); CU_ASSERT_EQUAL(conv->counts[1], 7); CU_ASSERT_EQUAL(conv->modseq, 55); CU_ASSERT_EQUAL(num_folders(conv), 3); folder = conversation_find_folder(state, conv, FOLDER1); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 13); - CU_ASSERT_EQUAL(folder->exists, 7); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 4); folder = conversation_find_folder(state, conv, FOLDER2); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 2); + CU_ASSERT_EQUAL(folder->num_records, 1); CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 7); folder = conversation_find_folder(state, conv, FOLDER3); CU_ASSERT_PTR_NOT_NULL_FATAL(folder); - CU_ASSERT_EQUAL(folder->num_records, 10); - CU_ASSERT_EQUAL(folder->exists, 10); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 0); CU_ASSERT_EQUAL(folder->modseq, 55); folder = conversation_find_folder(state, conv, FOLDER_N1); CU_ASSERT_PTR_NULL_FATAL(folder); @@ -692,27 +718,27 @@ static void test_folders(void) r = conversation_load(state, C_CID, &conv); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NOT_NULL(conv); - CU_ASSERT_EQUAL(conv->num_records, 25); - CU_ASSERT_EQUAL(conv->exists, 18); - CU_ASSERT_EQUAL(conv->unseen, 5); + CU_ASSERT_EQUAL(conv->num_records, 3); + CU_ASSERT_EQUAL(conv->exists, 2); + CU_ASSERT_EQUAL(conv->unseen, 1); CU_ASSERT_EQUAL(conv->counts[0], 3); CU_ASSERT_EQUAL(conv->counts[1], 7); CU_ASSERT_EQUAL(conv->modseq, 55); CU_ASSERT_EQUAL(num_folders(conv), 3); folder = conversation_find_folder(state, conv, FOLDER1); CU_ASSERT_PTR_NOT_NULL(folder); - CU_ASSERT_EQUAL(folder->num_records, 13); - CU_ASSERT_EQUAL(folder->exists, 7); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 4); folder = conversation_find_folder(state, conv, FOLDER2); CU_ASSERT_PTR_NOT_NULL(folder); - CU_ASSERT_EQUAL(folder->num_records, 2); + CU_ASSERT_EQUAL(folder->num_records, 1); CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 7); folder = conversation_find_folder(state, conv, FOLDER3); CU_ASSERT_PTR_NOT_NULL(folder); - CU_ASSERT_EQUAL(folder->num_records, 10); - CU_ASSERT_EQUAL(folder->exists, 10); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 0); CU_ASSERT_EQUAL(folder->modseq, 55); folder = conversation_find_folder(state, conv, FOLDER_N1); CU_ASSERT_PTR_NULL_FATAL(folder); @@ -723,16 +749,18 @@ static void test_folders(void) /* decrementing a folder down to zero */ counts[0] = -1; counts[1] = 0; - conversation_update(state, conv, FOLDER1, - /*is_trash*/0, /*num_records*/-13, - /*exists*/-7, /*unseen*/0, + struct emailcounts ecountsneg1 = { f1, 0, -1, { 1, 1, 0, 1, 1, 0 }, { 0, 0, 0, 0, 0, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + conversation_update(state, conv, + &ecountsneg1, /*size*/0, counts, /*modseq*/56, - /*createdmodseq*/1); + /*createdmodseq*/1, + /*silent*/0); - CU_ASSERT_EQUAL(conv->num_records, 12); - CU_ASSERT_EQUAL(conv->exists, 11); - CU_ASSERT_EQUAL(conv->unseen, 5); + CU_ASSERT_EQUAL(conv->num_records, 2); + CU_ASSERT_EQUAL(conv->exists, 1); + CU_ASSERT_EQUAL(conv->unseen, 1); CU_ASSERT_EQUAL(conv->counts[0], 2); CU_ASSERT_EQUAL(conv->counts[1], 7); CU_ASSERT_EQUAL(conv->modseq, 56); @@ -745,13 +773,13 @@ static void test_folders(void) CU_ASSERT_EQUAL(folder->modseq, 56); folder = conversation_find_folder(state, conv, FOLDER2); CU_ASSERT_PTR_NOT_NULL(folder); - CU_ASSERT_EQUAL(folder->num_records, 2); + CU_ASSERT_EQUAL(folder->num_records, 1); CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 7); folder = conversation_find_folder(state, conv, FOLDER3); CU_ASSERT_PTR_NOT_NULL(folder); - CU_ASSERT_EQUAL(folder->num_records, 10); - CU_ASSERT_EQUAL(folder->exists, 10); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 0); CU_ASSERT_EQUAL(folder->modseq, 55); folder = conversation_find_folder(state, conv, FOLDER_N1); CU_ASSERT_PTR_NULL_FATAL(folder); @@ -760,7 +788,7 @@ static void test_folders(void) CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), CONV_ISDIRTY); /* folder goes away properly when saved & re-loaded */ - r = conversation_save(state, C_CID, conv, NULL); + r = conversation_save(state, C_CID, conv); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), 0); conversation_free(conv); @@ -771,9 +799,9 @@ static void test_folders(void) CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NOT_NULL(conv); - CU_ASSERT_EQUAL(conv->num_records, 12); - CU_ASSERT_EQUAL(conv->exists, 11); - CU_ASSERT_EQUAL(conv->unseen, 5); + CU_ASSERT_EQUAL(conv->num_records, 2); + CU_ASSERT_EQUAL(conv->exists, 1); + CU_ASSERT_EQUAL(conv->unseen, 1); CU_ASSERT_EQUAL(conv->counts[0], 2); CU_ASSERT_EQUAL(conv->counts[1], 7); CU_ASSERT_EQUAL(conv->modseq, 56); @@ -782,13 +810,13 @@ static void test_folders(void) CU_ASSERT_PTR_NULL_FATAL(folder); folder = conversation_find_folder(state, conv, FOLDER2); CU_ASSERT_PTR_NOT_NULL(folder); - CU_ASSERT_EQUAL(folder->num_records, 2); + CU_ASSERT_EQUAL(folder->num_records, 1); CU_ASSERT_EQUAL(folder->exists, 1); CU_ASSERT_EQUAL(folder->modseq, 7); folder = conversation_find_folder(state, conv, FOLDER3); CU_ASSERT_PTR_NOT_NULL(folder); - CU_ASSERT_EQUAL(folder->num_records, 10); - CU_ASSERT_EQUAL(folder->exists, 10); + CU_ASSERT_EQUAL(folder->num_records, 1); + CU_ASSERT_EQUAL(folder->exists, 0); CU_ASSERT_EQUAL(folder->modseq, 55); folder = conversation_find_folder(state, conv, FOLDER_N1); CU_ASSERT_PTR_NULL_FATAL(folder); @@ -822,44 +850,43 @@ static void test_folder_ordering(void) r = conversations_open_path(DBNAME, NULL, 0/*shared*/, &state); CU_ASSERT_EQUAL_FATAL(r, 0); + /* XXX Hack so we don't have to rewrite this test to use mailboxes.db */ + state->folders_byname = 1; + /* Database is empty, so get should succeed and report no results */ conv = NULL; r = conversation_load(state, C_CID, &conv); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NULL(conv); - /* update should succeed */ - conv = conversation_new(); - CU_ASSERT_PTR_NOT_NULL(conv); - CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), CONV_ISDIRTY); - - /* set up the folder names in order - we are going to discard - * this conversation, but the folder_number call will persist */ - conversation_update(state, conv, FOLDER1, 0, 0, 0, 0, 0, 0, 0, 0); - conversation_update(state, conv, FOLDER2, 0, 0, 0, 0, 0, 0, 0, 0); - conversation_update(state, conv, FOLDER3, 0, 0, 0, 0, 0, 0, 0, 0); + /* set up the folder names in order */ + int f1 = conversation_folder_number(state, FOLDER1, 1); + int f2 = conversation_folder_number(state, FOLDER2, 1); + int f3 = conversation_folder_number(state, FOLDER3, 1); - /* discard and recreate */ - conversation_free(conv); conv = conversation_new(); - conversation_update(state, conv, FOLDER1, - /*is_trash*/0, /*num_records*/1, - /*exists*/1, /*unseen*/0, + struct emailcounts ecounts1 = { f1, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 1, 0, 1, 1, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + conversation_update(state, conv, + &ecounts1, /*size*/0, counts, /*modseq*/1, - /*createdmodseq*/0); + /*createdmodseq*/0, + /*silent*/0); /* add folders out of order */ - conversation_update(state, conv, FOLDER3, - /*is_trash*/0, /*num_records*/10, - /*exists*/10, /*unseen*/0, + struct emailcounts ecounts3 = { f3, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 1, 0, 1, 1, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + conversation_update(state, conv, + &ecounts3, /*size*/0, counts, /*modseq*/55, - /*createdmodseq*/0); + /*createdmodseq*/0, + /*silent*/0); /* save and reload here just to be sure */ - r = conversation_save(state, C_CID, conv, NULL); + r = conversation_save(state, C_CID, conv); CU_ASSERT_EQUAL(r, 0); conversation_free(conv); conv = NULL; @@ -867,12 +894,14 @@ static void test_folder_ordering(void) CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NOT_NULL(conv); - conversation_update(state, conv, FOLDER2, - /*is_trash*/0, /*num_records*/2, - /*exists*/1, /*unseen*/0, + struct emailcounts ecounts2 = { f2, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 1, 0, 1, 1, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + conversation_update(state, conv, + &ecounts2, /*size*/0, counts, /*modseq*/7, - /*createdmodseq*/0); + /*createdmodseq*/0, + /*silent*/0); CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), CONV_ISDIRTY); @@ -890,7 +919,7 @@ static void test_folder_ordering(void) CU_ASSERT_PTR_EQUAL(folder2->next, folder3); CU_ASSERT_PTR_NULL(folder3->next); - r = conversation_save(state, C_CID, conv, NULL); + r = conversation_save(state, C_CID, conv); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_EQUAL((conv->flags & CONV_ISDIRTY), 0); conversation_free(conv); @@ -999,7 +1028,8 @@ static void __test_senders(void) /*exists*/1, /*unseen*/0, /*size*/0, counts, /*modseq*/1, - /*createdmodseq*/0); + /*createdmodseq*/0, + /*silent*/0); /* there's no function for getting sender data, oh well */ sender1 = conv->senders; @@ -1027,7 +1057,7 @@ static void __test_senders(void) CU_ASSERT_STRING_EQUAL(sender3->domain, DOMAIN2); /* save and reload here just to be sure */ - r = conversation_save(state, C_CID, conv, NULL); + r = conversation_save(state, C_CID, conv); CU_ASSERT_EQUAL(r, 0); conversation_free(conv); conv = NULL; @@ -1175,6 +1205,9 @@ static void test_dump(void) r = conversations_open_path(DBNAME, NULL, 0/*shared*/, &state); CU_ASSERT_EQUAL_FATAL(r, 0); + /* XXX Hack so we don't have to rewrite this test to use mailboxes.db */ + state->folders_byname = 1; + for (i = 0 ; i < N_MSGID_TO_CID ; i++) { gen_msgid_cid(i, msgid, sizeof(msgid), &cid); r = conversations_add_msgid(state, msgid, cid); @@ -1185,14 +1218,17 @@ static void test_dump(void) conv = conversation_new(); CU_ASSERT_PTR_NOT_NULL(conv); for (j = 0 ; j < mboxnames.count ; j++) { - conversation_update(state, conv, mboxnames.data[j], - /*is_trash*/0, /*num_records*/1, - /*exists*/1, /*unseen*/0, + int f = conversation_folder_number(state, mboxnames.data[j], 1); + struct emailcounts ecounts = { f, 0, -1, { 0, 0, 0, 0, 0, 0 }, { 1, 1, 0, 1, 1, 0 }, + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER }; + conversation_update(state, conv, + &ecounts, /*size*/0, /*counts*/NULL, /*modseq*/100, - /*createdmodseq*/0); + /*createdmodseq*/0, + /*silent*/0); } - r = conversation_save(state, cid, conv, NULL); + r = conversation_save(state, cid, conv); CU_ASSERT_EQUAL(r, 0); conversation_free(conv); conv = NULL; @@ -1288,7 +1324,7 @@ static void test_dump(void) CU_ASSERT_EQUAL(r, 0); fclose(fp); - unlink(filename); + xunlink(filename); strarray_fini(&mboxnames); arrayu64_fini(&cids); #undef N_MSGID_TO_CID @@ -1332,13 +1368,23 @@ static void test_subject_normalise(void) TESTCASE("subj gets long fast [SEC=UNCLASSIFIED] [DLM=Sensitive:Personal]", "subjgetslongfast"); TESTCASE("inter[SEC=UNCLASSIFIED]jection", - "inter[SEC=UNCLASSIFIED]jection"); + "interjection"); TESTCASE("walks, talks, doesn't quack [SEC=UN", - "walks,talks,doesn'tquack[SEC=UN"); + "walks,talks,doesn'tquack"); TESTCASE("Re[2]: another reply", "anotherreply"); TESTCASE("Re[peat]: another reply", - "Re[peat]:anotherreply"); + "anotherreply"); + TESTCASE("non" "\xC2\xA0" "breaking space", + "nonbreakingspace"); + TESTCASE("回复: test no ascii", + "testnoascii"); + TESTCASE("unmatched left] foobar", + "foobar"); + TESTCASE("unmatched left] foobar [unmatched=[matched]", + "foobar"); + TESTCASE("no re: foobar", "nore:foobar"); + TESTCASE("re: no re: foobar", "nore:foobar"); } #undef TESTCASE @@ -1367,6 +1413,11 @@ static int set_up(void) } libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "conversations_keep_existing: false\n" + ); + cyrusdb_init(); #ifdef HAVE_ZEROSKIP config_conversations_db = "zeroskip"; @@ -1382,9 +1433,11 @@ static int tear_down(void) int r; cyrusdb_done(); + config_reset(); config_conversations_db = NULL; r = system("rm -rf " DBDIR); return r; } +/* vim: set ft=c: */ diff --git a/cunit/crc32.testc b/cunit/crc32.testc index beb1a8173f..4e5b992d19 100644 --- a/cunit/crc32.testc +++ b/cunit/crc32.testc @@ -1,107 +1,40 @@ /* Unit test for lib/crc32.c */ +#include #include "cunit/cyrunit.h" #include "crc32.h" -#include "crc32c.h" -static void test_rfc3720(void) -{ - // resulting CRCs are reversed endianness - - const char ZEROS[] = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 - }; - uint32_t ZEROSCRC = 0x8a9136aa; - - const char ONES[] = { - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff - }; - uint32_t ONESCRC = 0x62a8ab43; - - const char INCR[] = { - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, - 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f - }; - uint32_t INCRCRC = 0x46dd794e; - - const char DECR[] = { - 0x1f, 0x1e, 0x1d, 0x1c, 0x1b, 0x1a, 0x19, 0x18, - 0x17, 0x16, 0x15, 0x14, 0x13, 0x12, 0x11, 0x10, - 0x0f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, - 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x00 - }; - uint32_t DECRCRC = 0x113fdb5c; - - const char SCSIREAD[] = { - 0x01, 0xc0, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x14, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x04, 0x00, - 0x00, 0x00, 0x00, 0x14, - 0x00, 0x00, 0x00, 0x18, - 0x28, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x02, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00 - }; - uint32_t SCSIREADCRC = 0xd9963a56; - - uint32_t c; - - c = crc32c_map(ZEROS, sizeof(ZEROS)); - CU_ASSERT_EQUAL(c, ZEROSCRC); - - c = crc32c_map(ONES, sizeof(ONES)); - CU_ASSERT_EQUAL(c, ONESCRC); - - c = crc32c_map(INCR, sizeof(INCR)); - CU_ASSERT_EQUAL(c, INCRCRC); - - c = crc32c_map(DECR, sizeof(DECR)); - CU_ASSERT_EQUAL(c, DECRCRC); - - c = crc32c_map(SCSIREAD, sizeof(SCSIREAD)); - CU_ASSERT_EQUAL(c, SCSIREADCRC); -} - -static void test_crc32c(void) +static void test_map(void) { static const char TEXT[] = "lorem ipsum"; - static const char TEXT1[] = "lorem"; - static const char TEXT2[] = " ipsum"; - static uint32_t CRC32C = 0xdfb4e6c9; + static uint32_t CRC32 = 0x72d7748e; uint32_t c; - struct iovec iov[2]; - memset(iov, 0, sizeof(iov)); - iov[0].iov_base = (char *)TEXT1; - iov[0].iov_len = sizeof(TEXT1)-1; - iov[1].iov_base = (char *)TEXT2; - iov[1].iov_len = sizeof(TEXT2)-1; - - c = crc32c_iovec(iov, 2); - CU_ASSERT_EQUAL(c, CRC32C); - - c = crc32c_map(TEXT, sizeof(TEXT)-1); - CU_ASSERT_EQUAL(c, CRC32C); + c = crc32_map(TEXT, sizeof(TEXT)-1); + CU_ASSERT_EQUAL(c, CRC32); } -static void test_map(void) +static void test_unaligned(void) { - static const char TEXT[] = "lorem ipsum"; - static uint32_t CRC32 = 0x72d7748e; + struct aligned_data { + char pad1[1]; + char UNALIGNED_TEXT[12]; + char pad2[3]; + char ALIGNED_TEXT[12]; + } __attribute__((packed, aligned (ALIGNOF_UINT32_T))); + + static const struct aligned_data data = { + { 0 }, + "lorem ipsum", + { 0 }, + "lorem ipsum", + }; + static const uint32_t CRC32 = 0x72d7748e; uint32_t c; - c = crc32_map(TEXT, sizeof(TEXT)-1); + c = crc32_map(data.UNALIGNED_TEXT, sizeof(data.UNALIGNED_TEXT)-1); + CU_ASSERT_EQUAL(c, CRC32); + + c = crc32_map(data.ALIGNED_TEXT, sizeof(data.ALIGNED_TEXT)-1); CU_ASSERT_EQUAL(c, CRC32); } @@ -236,6 +169,420 @@ static void test_iovec_blank(void) CU_ASSERT_EQUAL(c, CRC32); } +static void test_heaps(void) +{ + static const char TEXTBLOCK[] = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + "the quick brown fox jumped over the lazy dog" + "\000\001\002\003\004\005\006\007\008\009\010" + "FIHU(E)WJHF*(EWJF98&88u90r9832q7648032768hef" + "da39a3ee5e6b4b0d3255bfef95601890afd80709\127" + "YYYYYYYYYYYY01234567890123456790123456789012"; + + /* generated checks to ensure all sorts of offsets are right */ + + /* GENERATED WITH THE FOLLOWING CODE: + * int i, j; + * for (j = 1; j < 10; j++) { + * for (i = 20; i < 60; i++) { + * printf(" CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+%d, %d), 0x%08x);\n", i, j, crc32_map(TEXTBLOCK+i, j)); + * } + * } + */ + + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 1), 0xb7b2364b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 1), 0x856a5aa8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 1), 0x916b06e7); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 1), 0xefda7a5a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 1), 0xe96ccf45); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 1), 0xf500ae27); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 1), 0xf26d6a3e); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 1), 0xe66c3671); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 1), 0x06b9df6f); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 1), 0x0862575d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 1), 0xe96ccf45); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 1), 0x71beeff9); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 1), 0x6c09ff9d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 1), 0x0f0f9344); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 1), 0x1c630b12); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 1), 0x7808a3d2); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 1), 0xe96ccf45); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 2), 0x560b1c65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 2), 0x64d37086); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 2), 0x49e34767); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 2), 0xd1256687); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 2), 0x623dadd5); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 2), 0xf35f77f7); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 2), 0x5792dffb); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 2), 0x27ff46b0); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 2), 0x215df2f3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 2), 0xd569924b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 2), 0xfcbe805b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 2), 0x77e13629); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 2), 0xa8190bca); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 2), 0x81dd7542); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 2), 0x6ddd8108); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 2), 0x8badb191); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 2), 0x81c9741e); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 2), 0x708cf230); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 3), 0x8a3ca880); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 3), 0xb8e4c463); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 3), 0x95d4f382); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 3), 0x3c456de6); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 3), 0x9a61fca0); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 3), 0x03dbb5d1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 3), 0xd14752f6); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 3), 0xcc548f3a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 3), 0xcdff93a5); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 3), 0x2cf7a909); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 3), 0x08b73ecf); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 3), 0x8dfbd905); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 3), 0x2ecc86c7); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 3), 0x7416a1e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 3), 0x8430f6db); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 3), 0x76bef661); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 3), 0x6eefc126); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 3), 0x8c5ddff7); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 3), 0x29a62f1a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 4), 0x5a8089c3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 4), 0x6858e520); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 4), 0x4568d2c1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 4), 0xecf94ca5); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 4), 0xa039cd65); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 4), 0x234c6c33); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 4), 0x03ba53c9); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 4), 0xb263260a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 4), 0xc0795252); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 4), 0xae13ffa9); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 4), 0x909c8048); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 4), 0x7a6d87e6); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 4), 0x1ceef0cb); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 4), 0x0a2108d1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 4), 0xcb1acf5d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 4), 0x698d9878); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 4), 0xd3af207d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 4), 0xa4b041dc); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 4), 0x2c5aa924); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 4), 0x719749d6); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 5), 0xb58525c8); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 5), 0x875d492b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 5), 0xaa6d7eca); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 5), 0x03fce0ae); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 5), 0x4f3c616e); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 5), 0xc878023d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 5), 0x4d9e4744); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 5), 0x04d7f636); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 5), 0xe6de5557); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 5), 0x8dc71ed7); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 5), 0x46c8c7f6); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 5), 0x0929badb); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 5), 0x251ad557); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 5), 0x03a5668c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 5), 0xedbdc858); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 5), 0x6d199454); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 5), 0xb7dbbbd3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 5), 0x58b48941); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 5), 0x80cebdfa); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 5), 0xb0f3a8fb); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 5), 0x86ad2fed); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 6), 0x22b8f9ec); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 6), 0x1060950f); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 6), 0x3d50a2ee); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 6), 0x94c13c8a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 6), 0xd801bd4a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 6), 0x5f45de19); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 6), 0xaacd5e2d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 6), 0x97902dbf); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 6), 0xc9079d00); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 6), 0xfd8b4d5f); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 6), 0xf1564f2c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 6), 0x25267017); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 6), 0x7d01dd7b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 6), 0xfa254dc6); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 6), 0xf86e617f); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 6), 0x1d55c7dc); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 6), 0x8507433c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 6), 0x69be7335); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 6), 0x0e8c56cb); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 6), 0xd134c33c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 6), 0xc3b31779); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 6), 0xa1585421); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 7), 0x1e2c20e1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 7), 0x2cf44c02); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 7), 0x01c47be3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 7), 0xa855e587); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 7), 0xe4956447); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 7), 0x63d10714); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 7), 0x96598720); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 7), 0xa319a75a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 7), 0x5df0e15f); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 7), 0x08ab50c0); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 7), 0x1245086d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 7), 0x4397d555); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 7), 0xefff5c2a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 7), 0xc8a53a2d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 7), 0x6e9e49da); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 7), 0xb84aa11e); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 7), 0x6614047d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 7), 0x59395024); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 7), 0x59d5e914); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 7), 0x806481ed); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 7), 0xc6d28701); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 7), 0x567f1d44); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 7), 0xbea52234); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 8), 0x60a1c885); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 8), 0x5279a466); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 8), 0x7f499387); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 8), 0xd6d80de3); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 8), 0x9a188c23); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 8), 0x1d5cef70); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 8), 0xe8d46f44); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 8), 0xdd944f3e); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 8), 0x8da47e22); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 8), 0xf3ebebd9); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 8), 0x7200a6a5); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 8), 0x42afb714); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 8), 0x774bcd33); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 8), 0xd45ba5ce); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 8), 0x5974f25d); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 8), 0x1e600651); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 8), 0x13dbb887); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 8), 0x58013265); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 8), 0x33554ec5); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 8), 0x965f1717); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 8), 0x37573501); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 8), 0x08a485da); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 8), 0x838a90aa); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 8), 0xc00ba3ff); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+20, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+21, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+22, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+23, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+24, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+25, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+26, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+27, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+28, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+29, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+30, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+31, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+32, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+33, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+34, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+35, 9), 0x2a00e02c); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+36, 9), 0x18d88ccf); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+37, 9), 0x35e8bb2e); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+38, 9), 0x9c79254a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+39, 9), 0xd0b9a48a); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+40, 9), 0x57fdc7d9); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+41, 9), 0xa27547ed); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+42, 9), 0x97356797); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+43, 9), 0xc705568b); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+44, 9), 0xdd8fb2c7); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+45, 9), 0x16904ede); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+46, 9), 0xd770b838); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+47, 9), 0x76918457); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+48, 9), 0xb0a8b99f); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+49, 9), 0x606bbf00); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+50, 9), 0x6d8bfa69); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+51, 9), 0xf51ece21); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+52, 9), 0x051ce6db); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+53, 9), 0x328f07a1); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+54, 9), 0x67e175f2); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+55, 9), 0x6a291595); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+56, 9), 0x08550068); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+57, 9), 0x9463f571); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+58, 9), 0xd781320e); + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK+59, 9), 0xafc57a9f); + + /* full test of larger block plus iovec versions of same */ + CU_ASSERT_EQUAL(crc32_map(TEXTBLOCK, 230), 0x5fc0ba36); + + struct iovec iov[10]; + uint32_t c; + memset(iov, 0, sizeof(iov)); + iov[0].iov_base = (char *)TEXTBLOCK; + iov[0].iov_len = 66; + iov[1].iov_base = (char *)TEXTBLOCK+66; + iov[1].iov_len = 164; + + c = crc32_iovec(iov, 2); + CU_ASSERT_EQUAL(c, 0x5fc0ba36); + iov[0].iov_base = (char *)TEXTBLOCK; + iov[0].iov_len = 1; + iov[1].iov_base = (char *)TEXTBLOCK+1; + iov[1].iov_len = 0; + iov[2].iov_base = (char *)TEXTBLOCK+1; + iov[2].iov_len = 17; + iov[3].iov_base = (char *)TEXTBLOCK+18; + iov[3].iov_len = 46; + iov[4].iov_base = (char *)TEXTBLOCK+64; + iov[4].iov_len = 64; + iov[5].iov_base = (char *)TEXTBLOCK+128; + iov[5].iov_len = 72; + iov[6].iov_base = (char *)TEXTBLOCK+200; + iov[6].iov_len = 8; + iov[7].iov_base = (char *)TEXTBLOCK+208; + iov[7].iov_len = 22; + + c = crc32_iovec(iov, 8); + CU_ASSERT_EQUAL(c, 0x5fc0ba36); +} /* vim: set ft=c: */ diff --git a/cunit/cunit.pl b/cunit/cunit.pl index b5a3825e35..6b5251f31b 100755 --- a/cunit/cunit.pl +++ b/cunit/cunit.pl @@ -810,9 +810,7 @@ ($) # # Note: appending is an important semantic. It ensures # that the order of suites in the CUnit run matches the - # order in which test source is specified, in SUBDIRS - # in the top-level Makefile and then in TESTSOURCES - # in each Makefile below that. + # order in which they were added (alphabetic) # vmsg("adding suite $suite->{relpath} $suite->{basedir}"); push(@suites, $suite); @@ -872,7 +870,12 @@ (@) project_load(); - foreach my $path (@args) + # Test sources are listed mostly alphabetically in Makefile.am, but + # there are exceptions for uninteresting reasons. + # There should not be any order dependency, so we can disregard the + # order they were listed in, and instead force them into alphabetic + # order for better readability of the test suite output. + foreach my $path (sort @args) { die "$path: not a C source file" unless (-f $path && $path =~ m/\.(test)?(c|C|cc|cxx|c\+\+)$/); diff --git a/cunit/cyr_qsort_r.testc b/cunit/cyr_qsort_r.testc new file mode 100644 index 0000000000..5cceba52e5 --- /dev/null +++ b/cunit/cyr_qsort_r.testc @@ -0,0 +1,29 @@ +#include +#include "lib/cyr_qsort_r.h" + +#include "cunit/cyrunit.h" + +static int mysort QSORT_R_COMPAR_ARGS(const void *pa, + const void *pb, + void *arg) +{ + int *countptr = arg; + (*countptr)++; + return *((int*)pa) - *((int*)pb); + +} + +static void test_qsort_r(void) +{ + int data[10] = { 6, 8, 3, 7, 2, 0, 4, 9, 5, 1 }; + int count = 0; + + cyr_qsort_r(data, 10, sizeof(int), mysort, &count); + + int i; + for (i = 0; i < 10; i++) CU_ASSERT_EQUAL(data[i], i); + + CU_ASSERT_NOT_EQUAL(count, 0); +} + +/* vim: set ft=c: */ diff --git a/cunit/cyrunit.h b/cunit/cyrunit.h index a6ce171e6a..dd63c18ff1 100644 --- a/cunit/cyrunit.h +++ b/cunit/cyrunit.h @@ -50,12 +50,20 @@ extern int verbose; +/* initialise libconfig from a string */ +extern void config_read_string(const char *s); + /* * The standard CUnit assertion *EQUAL* macros have a flaw: they do * not report the actual values of the 'actual' and 'expected' values, * which makes it rather hard to see why an assertion failed. So we * replace the macros with improved ones, keeping the same API. */ +/* XXX Would like to add __attribute__((format(printf, 6, 7))) + * XXX to this so the compiler can warn if it's misused, but it looks + * XXX like it currently gets very confused by the layers of macros + * XXX and produces bogus warnings. :( + */ extern CU_BOOL CU_assertFormatImplementation(CU_BOOL bValue, unsigned int uiLine, char strFile[], char strFunction[], CU_BOOL bFatal, @@ -64,142 +72,177 @@ extern void __cunit_wrap_test(const char *name, void (*fn)(void)); extern int __cunit_wrap_fixture(const char *name, int (*fn)(void)); #undef CU_ASSERT_EQUAL -#define CU_ASSERT_EQUAL(actual,expected) \ - { long long _a = (actual), _e = (expected); \ - CU_assertFormatImplementation((_a == _e), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_EQUAL(" #actual "=%lld," #expected "=%lld)", _a, _e); } +#define CU_ASSERT_EQUAL(actual,expected) do { \ + long long _a = (actual), _e = (expected); \ + CU_assertFormatImplementation((_a == _e), __LINE__, \ + __FILE__, "", CU_FALSE, \ + "CU_ASSERT_EQUAL(%s=%lld,%s=%lld)", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_EQUAL_FATAL -#define CU_ASSERT_EQUAL_FATAL(actual,expected) \ - { long long _a = (actual), _e = (expected); \ - CU_assertFormatImplementation((_a == _e), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_EQUAL_FATAL(" #actual "=%lld," #expected "=%lld)", _a, _e); } +#define CU_ASSERT_EQUAL_FATAL(actual,expected) do { \ + long long _a = (actual), _e = (expected); \ + CU_assertFormatImplementation((_a == _e), __LINE__, \ + __FILE__, "", CU_TRUE, \ + "CU_ASSERT_EQUAL_FATAL(%s=%lld,%s=%lld)", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_NOT_EQUAL -#define CU_ASSERT_NOT_EQUAL(actual,expected) \ - { long long _a = (actual), _e = (expected); \ - CU_assertFormatImplementation((_a != _e), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_NOT_EQUAL(" #actual "=%lld," #expected "=%lld)", _a, _e); } +#define CU_ASSERT_NOT_EQUAL(actual,expected) do { \ + long long _a = (actual), _e = (expected); \ + CU_assertFormatImplementation((_a != _e), __LINE__, \ + __FILE__, "", CU_FALSE, \ + "CU_ASSERT_NOT_EQUAL(%s=%lld,%s=%lld)", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_NOT_EQUAL_FATAL -#define CU_ASSERT_NOT_EQUAL_FATAL(actual,expected) \ - { long long _a = (actual), _e = (expected); \ - CU_assertFormatImplementation((_a != _e), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_NOT_EQUAL_FATAL(" #actual "=%lld," #expected "=%lld)", _a, _e); } +#define CU_ASSERT_NOT_EQUAL_FATAL(actual,expected) do { \ + long long _a = (actual), _e = (expected); \ + CU_assertFormatImplementation((_a != _e), __LINE__, \ + __FILE__, "", CU_TRUE, \ + "CU_ASSERT_NOT_EQUAL_FATAL(%s=%lld,%s=%lld)", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_PTR_EQUAL -#define CU_ASSERT_PTR_EQUAL(actual,expected) \ - { const void *_a = (actual), *_e = (expected); \ - CU_assertFormatImplementation((_a == _e), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_PTR_EQUAL(" #actual "=%p," #expected "=%p)", _a, _e); } +#define CU_ASSERT_PTR_EQUAL(actual,expected) do { \ + const void *_a = (actual), *_e = (expected); \ + CU_assertFormatImplementation((_a == _e), __LINE__, \ + __FILE__, "", CU_FALSE, \ + "CU_ASSERT_PTR_EQUAL(%s=%p,%s=%p)", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_PTR_EQUAL_FATAL -#define CU_ASSERT_PTR_EQUAL_FATAL(actual,expected) \ - { const void *_a = (actual), *_e = (expected); \ - CU_assertFormatImplementation((_a == _e), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_PTR_EQUAL_FATAL(" #actual "=%p," #expected "=%p)", _a, _e); } +#define CU_ASSERT_PTR_EQUAL_FATAL(actual,expected) do { \ + const void *_a = (actual), *_e = (expected); \ + CU_assertFormatImplementation((_a == _e), __LINE__, \ + __FILE__, "", CU_TRUE, \ + "CU_ASSERT_PTR_EQUAL_FATAL(%s=%p,%s=%p)", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_PTR_NOT_EQUAL -#define CU_ASSERT_PTR_NOT_EQUAL(actual,expected) \ - { const void *_a = (actual), *_e = (expected); \ - CU_assertFormatImplementation((_a != _e), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_PTR_NOT_EQUAL(" #actual "=%p," #expected "=%p)", _a, _e); } +#define CU_ASSERT_PTR_NOT_EQUAL(actual,expected) do { \ + const void *_a = (actual), *_e = (expected); \ + CU_assertFormatImplementation((_a != _e), __LINE__, \ + __FILE__, "", CU_FALSE, \ + "CU_ASSERT_PTR_NOT_EQUAL(%s=%p,%s=%p)", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_PTR_NOT_EQUAL_FATAL -#define CU_ASSERT_PTR_NOT_EQUAL_FATAL(actual,expected) \ - { const void *_a = (actual), *_e = (expected); \ - CU_assertFormatImplementation((_a != _e), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_PTR_NOT_EQUAL_FATAL(" #actual "=%p," #expected "=%p)", _a, _e); } +#define CU_ASSERT_PTR_NOT_EQUAL_FATAL(actual,expected) do { \ + const void *_a = (actual), *_e = (expected); \ + CU_assertFormatImplementation((_a != _e), __LINE__, \ + __FILE__, "", CU_TRUE, \ + "CU_ASSERT_PTR_NOT_EQUAL_FATAL(%s=%p,%s=%p)", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_PTR_NULL -#define CU_ASSERT_PTR_NULL(actual) \ - { const void *_a = (actual); \ - CU_assertFormatImplementation(!(_a), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_PTR_NULL(" #actual ")"); } +#define CU_ASSERT_PTR_NULL(actual) do { \ + const void *_a = (actual); \ + CU_assertFormatImplementation(!(_a), __LINE__, \ + __FILE__, "", CU_FALSE, \ + "CU_ASSERT_PTR_NULL(%s)", #actual); \ +} while(0) #undef CU_ASSERT_PTR_NULL_FATAL -#define CU_ASSERT_PTR_NULL_FATAL(actual) \ - { const void *_a = (actual); \ - CU_assertFormatImplementation(!(_a), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_PTR_NULL_FATAL(" #actual ")"); } +#define CU_ASSERT_PTR_NULL_FATAL(actual) do { \ + const void *_a = (actual); \ + CU_assertFormatImplementation(!(_a), __LINE__, \ + __FILE__, "", CU_TRUE, \ + "CU_ASSERT_PTR_NULL(%s)", #actual); \ +} while(0) #undef CU_ASSERT_PTR_NOT_NULL -#define CU_ASSERT_PTR_NOT_NULL(actual) \ - { const void *_a = (actual); \ - CU_assertFormatImplementation(!!(_a), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_PTR_NOT_NULL(" #actual ")"); } +#define CU_ASSERT_PTR_NOT_NULL(actual) do { \ + const void *_a = (actual); \ + CU_assertFormatImplementation(!!(_a), __LINE__, \ + __FILE__, "", CU_FALSE, \ + "CU_ASSERT_PTR_NULL(%s)", #actual); \ +} while(0) #undef CU_ASSERT_PTR_NOT_NULL_FATAL -#define CU_ASSERT_PTR_NOT_NULL_FATAL(actual) \ - { const void *_a = (actual); \ - CU_assertFormatImplementation(!!(_a), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_PTR_NOT_NULL_FATAL(" #actual ")"); } +#define CU_ASSERT_PTR_NOT_NULL_FATAL(actual) do { \ + const void *_a = (actual); \ + CU_assertFormatImplementation(!!(_a), __LINE__, \ + __FILE__, "", CU_TRUE, \ + "CU_ASSERT_PTR_NULL(%s)", #actual); \ +} while(0) #undef CU_ASSERT_STRING_EQUAL -#define CU_ASSERT_STRING_EQUAL(actual,expected) \ - { const char *_a = (actual), *_e = (expected); \ +#define CU_ASSERT_STRING_EQUAL(actual,expected) do { \ + const char *_a = (actual), *_e = (expected); \ CU_assertFormatImplementation(!strcmp(_a?_a:"",_e?_e:""), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_STRING_EQUAL(" #actual "=\"%s\"," #expected "=\"%s\")", _a, _e); } + __FILE__, "", CU_FALSE, \ + "CU_ASSERT_STRING_EQUAL(%s=\"%s\",%s=\"%s\")", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_STRING_EQUAL_FATAL -#define CU_ASSERT_STRING_EQUAL_FATAL(actual,expected) \ - { const char *_a = (actual), *_e = (expected); \ +#define CU_ASSERT_STRING_EQUAL_FATAL(actual,expected) do { \ + const char *_a = (actual), *_e = (expected); \ CU_assertFormatImplementation(!strcmp(_a?_a:"",_e?_e:""), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_STRING_EQUAL_FATAL(" #actual "=\"%s\"," #expected "=\"%s\")", _a, _e); } + __FILE__, "", CU_TRUE, \ + "CU_ASSERT_STRING_EQUAL_FATAL(%s=\"%s\",%s=\"%s\")", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_STRING_NOT_EQUAL -#define CU_ASSERT_STRING_NOT_EQUAL(actual,expected) \ - { const char *_a = (actual), *_e = (expected); \ - CU_assertFormatImplementation(!!strcmp(_a?_a:"",_e?_e:""), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_STRING_NOT_EQUAL(" #actual "=\"%s\"," #expected "=\"%s\")", _a, _e); } +#define CU_ASSERT_STRING_NOT_EQUAL(actual,expected) do { \ + const char *_a = (actual), *_e = (expected); \ + CU_assertFormatImplementation(!!strcmp(_a?_a:"",_e?_e:""), \ + __LINE__, __FILE__, "", CU_FALSE, \ + "CU_ASSERT_STRING_NOT_EQUAL(%s=\"%s\",%s=\"%s\")", \ + #actual, _a, #expected, _e); \ +} while(0) #undef CU_ASSERT_STRING_NOT_EQUAL_FATAL -#define CU_ASSERT_STRING_NOT_EQUAL_FATAL(actual,expected) \ - { const char *_a = (actual), *_e = (expected); \ - CU_assertFormatImplementation(!!strcmp(_a?_a:"",_e?_e:""), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_STRING_NOT_EQUAL_FATAL(" #actual "=\"%s\"," #expected "=\"%s\")", _a, _e); } +#define CU_ASSERT_STRING_NOT_EQUAL_FATAL(actual,expected) do { \ + const char *_a = (actual), *_e = (expected); \ + CU_assertFormatImplementation(!!strcmp(_a?_a:"",_e?_e:""), \ + __LINE__, __FILE__, "", CU_TRUE, \ + "CU_ASSERT_STRING_NOT_EQUAL_FATAL(%s=\"%s\",%s=\"%s\")", \ + #actual, _a, #expected, _e); \ +} while(0) #define CU_SYSLOG_MATCH(re) \ CU_syslogMatchBegin((re), __FILE__, __LINE__) -#define CU_ASSERT_SYSLOG(match, expected) \ - { const char *_s = NULL; unsigned int _e = (expected), \ - _a = CU_syslogMatchEnd((match), &_s); \ - CU_assertFormatImplementation((_a == _e), __LINE__, \ - __FILE__, "", CU_FALSE, \ - "CU_ASSERT_SYSLOG(/%s/=%u, " #expected "=%u)", _s, _a, _e); } -#define CU_ASSERT_SYSLOG_FATAL(match, expected) \ - { const char *_s = NULL; unsigned int _e = (expected), \ - _a = CU_syslogMatchEnd((match), &_s); \ - CU_assertFormatImplementation((_a == _e), __LINE__, \ - __FILE__, "", CU_TRUE, \ - "CU_ASSERT_SYSLOG_FATAL(/%s/=%u, " #expected "=%u)", _s, _a, _e); } + +#define CU_ASSERT_SYSLOG(match, expected) do { \ + const char *_s = NULL; unsigned int _e = (expected), \ + _a = CU_syslogMatchEnd((match), &_s); \ + CU_assertFormatImplementation((_a == _e), __LINE__, \ + __FILE__, "", CU_FALSE, \ + "CU_ASSERT_SYSLOG(/%s/=%u, %s=%u)", \ + _s, _a, #expected, _e); \ +} while(0) + +#define CU_ASSERT_SYSLOG_FATAL(match, expected) do { \ + const char *_s = NULL; unsigned int _e = (expected), \ + _a = CU_syslogMatchEnd((match), &_s); \ + CU_assertFormatImplementation((_a == _e), __LINE__, \ + __FILE__, "", CU_TRUE, \ + "CU_ASSERT_SYSLOG_FATAL(/%s/=%u, %s=%u)", \ + _s, _a, #expected, _e); \ +} while(0) extern jmp_buf fatal_jbuf; extern int fatal_expected; -extern const char *fatal_string; +extern char *fatal_string; extern int fatal_code; #define CU_EXPECT_CYRFATAL_BEGIN \ do { \ fatal_expected = 1; \ + if (fatal_string) free(fatal_string); \ fatal_string = NULL; \ fatal_code = 0; \ if (!setjmp(fatal_jbuf)) { \ @@ -212,6 +255,8 @@ do { \ const char *_es = (expected_string); \ CU_ASSERT_EQUAL(fatal_code, _ec); \ if (_es) CU_ASSERT_STRING_EQUAL(fatal_string, _es); \ + if (fatal_string) free(fatal_string); \ + fatal_string = NULL; \ } } while (0) diff --git a/cunit/dlist.testc b/cunit/dlist.testc index dc96a07404..d563a654e5 100644 --- a/cunit/dlist.testc +++ b/cunit/dlist.testc @@ -1,9 +1,35 @@ #include "config.h" #include "cunit/cyrunit.h" #include "prot.h" +#include "lib/libcyr_cfg.h" +#include "lib/libconfig.h" #include "imap/dlist.h" #include "util.h" +#define DBDIR "test-dbdir" + +static int set_up(void) +{ + /* need basic configuration for getxstring */ + libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + ); + + return 0; +} + +static int tear_down(void) +{ + int r; + + config_reset(); + + r = system("rm -rf " DBDIR); + + return r; +} + /* XXX - need LOTS of dlist tests */ static void test_nil(void) diff --git a/cunit/duplicate.testc b/cunit/duplicate.testc index 5769262900..fd61826680 100644 --- a/cunit/duplicate.testc +++ b/cunit/duplicate.testc @@ -256,18 +256,6 @@ static void test_find(void) } -static void config_read_string(const char *s) -{ - char *fname = xstrdup("/tmp/cyrus-cunit-configXXXXXX"); - int fd = mkstemp(fname); - retry_write(fd, s, strlen(s)); - config_reset(); - config_read(fname, 0); - unlink(fname); - free(fname); - close(fd); -} - static int set_up(void) { int r; diff --git a/cunit/dynarray.testc b/cunit/dynarray.testc new file mode 100644 index 0000000000..7ad50db3f8 --- /dev/null +++ b/cunit/dynarray.testc @@ -0,0 +1,99 @@ +#include +# +#include "cunit/cyrunit.h" +#include "xmalloc.h" +#include "dynarray.h" + +static void test_basic(void) +{ + struct dynarray *da = dynarray_new(sizeof(uint32_t)); + uint32_t *valp; + + CU_ASSERT_EQUAL(dynarray_size(da), 0); + CU_ASSERT_PTR_NULL(dynarray_nth(da, 0)); + CU_ASSERT_PTR_NULL(dynarray_nth(da, -1)); + + uint32_t val1 = 0xbeefc0de; + dynarray_append(da, &val1); + + CU_ASSERT_EQUAL(dynarray_size(da), 1); + valp = dynarray_nth(da, 0); + CU_ASSERT_EQUAL(*valp, val1); + valp = dynarray_nth(da, -1); + CU_ASSERT_EQUAL(*valp, val1); + + uint32_t val2 = 0x00c0fefe; + dynarray_append(da, &val2); + + CU_ASSERT_EQUAL(dynarray_size(da), 2); + valp = dynarray_nth(da, 1); + CU_ASSERT_EQUAL(*valp, val2); + valp = dynarray_nth(da, -1); + CU_ASSERT_EQUAL(*valp, val2); + + dynarray_free(&da); + CU_ASSERT_PTR_NULL(da); +} + +#define CU_ASSERT_MEMEQUAL(actual, expected, sz) \ + CU_ASSERT_EQUAL(memcmp(actual, expected, sz), 0) + +static void test_set(void) +{ + struct dynarray *da = dynarray_new(sizeof(uint32_t)); + uint32_t val; + const uint32_t zero = 0; + + CU_ASSERT_EQUAL(da->count, 0); + CU_ASSERT_PTR_NULL(dynarray_nth(da, 0)); + CU_ASSERT_PTR_NULL(dynarray_nth(da, -1)); + + val = 0xdeadbeef; + dynarray_set(da, 5, &val); + CU_ASSERT(da->count >= 6); + CU_ASSERT(da->alloc >= da->count); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 0), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 1), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 2), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 3), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 4), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 5), &val, sizeof(val)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, -1), &val, sizeof(val)); + + dynarray_set(da, 2, &val); + CU_ASSERT(da->count >= 3); + CU_ASSERT(da->alloc >= da->count); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 0), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 1), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 2), &val, sizeof(val)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 3), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 4), &zero, sizeof(zero)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, 5), &val, sizeof(val)); + CU_ASSERT_MEMEQUAL(dynarray_nth(da, -1), &val, sizeof(val)); + + dynarray_free(&da); +} + +static void test_truncate(void) +{ + struct dynarray *da = dynarray_new(sizeof(uint32_t)); + uint32_t val; + for (val = 0; val < 64; val++) dynarray_append(da, &val); + CU_ASSERT_EQUAL(64, da->count); + + val = 4; + CU_ASSERT_MEMEQUAL(da->data + sizeof(uint32_t)*4, &val, sizeof(uint32_t)); + + dynarray_truncate(da, 65); + CU_ASSERT_EQUAL(da->count, 65); + CU_ASSERT_MEMEQUAL(da->data + sizeof(uint32_t)*4, &val, sizeof(uint32_t)); + + dynarray_truncate(da, 3); + CU_ASSERT_EQUAL(da->count, 3); + val = 0; + CU_ASSERT_MEMEQUAL(da->data + sizeof(uint32_t)*4, &val, sizeof(uint32_t)); + + dynarray_free(&da); +} + +/* vim: set ft=c: */ diff --git a/cunit/getxstring.testc b/cunit/getxstring.testc index a8432fbd14..12efe86aab 100644 --- a/cunit/getxstring.testc +++ b/cunit/getxstring.testc @@ -2,8 +2,33 @@ #include "config.h" #include "cunit/cyrunit.h" #include "prot.h" +#include "lib/libcyr_cfg.h" #include "imap/global.h" +#define DBDIR "test-dbdir" + +static int set_up(void) +{ + /* need basic configuration for getxstring */ + libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + ); + + return 0; +} + +static int tear_down(void) +{ + int r; + + config_reset(); + + r = system("rm -rf " DBDIR); + + return r; +} + /* * Here's the ABNF describing the various types of string from RF3501. * This is included for amusement mainly, as the getxstring() code takes @@ -47,22 +72,19 @@ /* * Run a single testcase. - * - * Note: prot_setisclient() turns off off literal synchronising so - * we don't have to futz around with testing that. */ #define _TESTCASE_PRE(fut, input, retval, consumed) \ do { \ struct buf b = BUF_INITIALIZER; \ struct protstream *p; \ int c; \ + long long _consumed = (consumed); \ p = prot_readmap(input, sizeof(input)-1); \ CU_ASSERT_PTR_NOT_NULL_FATAL(p); \ - prot_setisclient(p, 1); \ c = fut(p, NULL, &b); \ CU_ASSERT_EQUAL(c, retval); \ - if (consumed >= 0) { \ - CU_ASSERT_EQUAL(prot_bytes_in(p), consumed); \ + if (_consumed >= 0) { \ + CU_ASSERT_EQUAL(prot_bytes_in(p), _consumed); \ } \ if (c != EOF) { diff --git a/cunit/glob.testc b/cunit/glob.testc index 74ddc97665..d3172c655b 100644 --- a/cunit/glob.testc +++ b/cunit/glob.testc @@ -40,6 +40,34 @@ static void test_star(void) CU_ASSERT_EQUAL(r, -1); glob_free(&g); + + g = glob_init(".*", '.'); + CU_ASSERT_PTR_NOT_EQUAL_FATAL(g, NULL); + + r = glob_test(g, ".foo"); + CU_ASSERT_EQUAL(r, 4); + + r = glob_test(g, "user.foo"); + CU_ASSERT_EQUAL(r, -1); + + r = glob_test(g, "user..foo"); + CU_ASSERT_EQUAL(r, -1); + + glob_free(&g); + + g = glob_init(".*", '/'); + CU_ASSERT_PTR_NOT_EQUAL_FATAL(g, NULL); + + r = glob_test(g, ".foo"); + CU_ASSERT_EQUAL(r, 4); + + r = glob_test(g, "user.foo"); + CU_ASSERT_EQUAL(r, -1); + + r = glob_test(g, "user/.foo"); + CU_ASSERT_EQUAL(r, -1); + + glob_free(&g); } static void test_globmatch(void) diff --git a/cunit/hash.testc b/cunit/hash.testc index 92e24dee82..d2c1a6bc63 100644 --- a/cunit/hash.testc +++ b/cunit/hash.testc @@ -56,6 +56,9 @@ static void test_old(void) void *j; strarray_t sa = STRARRAY_INITIALIZER; + /* n.b. This test uses mpool, which coincidentally gets us 100% test + * coverage of lib/mpool.c, which we do not otherwise test at all! + */ construct_hash_table(&table, 200, 1); for (i = 0 ; NULL != strings[i] ; i++ ) { @@ -117,6 +120,9 @@ static void test_empty(void) hash_enumerate(&ht, count_cb, &count); CU_ASSERT_EQUAL(0, count); + /* check hash_numrecords */ + CU_ASSERT_EQUAL(0, hash_numrecords(&ht)); + /* free the hash table */ free_hash_table(&ht, NULL); } @@ -146,6 +152,9 @@ static void test_reinsert(void) hash_enumerate(&ht, count_cb, &count); CU_ASSERT_EQUAL(1, count); + /* check hash_numrecords */ + CU_ASSERT_EQUAL(1, hash_numrecords(&ht)); + /* re-insert into the hash table */ d = hash_insert(KEY0, VALUE1, &ht); /* get the old value back */ @@ -160,6 +169,9 @@ static void test_reinsert(void) hash_enumerate(&ht, count_cb, &count); CU_ASSERT_EQUAL(1, count); + /* check hash_numrecords */ + CU_ASSERT_EQUAL(1, hash_numrecords(&ht)); + /* delete from the hash table */ d = hash_del(KEY0, &ht); CU_ASSERT_PTR_EQUAL(VALUE1, d); @@ -173,6 +185,9 @@ static void test_reinsert(void) hash_enumerate(&ht, count_cb, &count); CU_ASSERT_EQUAL(0, count); + /* check hash_numrecords */ + CU_ASSERT_EQUAL(0, hash_numrecords(&ht)); + /* free the hash table */ free_hash_table(&ht, NULL); } @@ -239,6 +254,9 @@ static void test_many(void) hash_enumerate(&ht, count_cb, &count); CU_ASSERT_EQUAL(N, count); + /* check hash_numrecords */ + CU_ASSERT_EQUAL(N, hash_numrecords(&ht)); + /* delete from the hash table */ for (i = 0 ; i < N ; i++) { d = hash_del(key(i), &ht); @@ -256,6 +274,9 @@ static void test_many(void) hash_enumerate(&ht, count_cb, &count); CU_ASSERT_EQUAL(0, count); + /* check hash_numrecords */ + CU_ASSERT_EQUAL(0, hash_numrecords(&ht)); + /* free the hash table */ freed_count = 0; free_hash_table(&ht, lincoln); @@ -286,6 +307,9 @@ static void test_freeing_nonempty(void) hash_enumerate(&ht, count_cb, &count); CU_ASSERT_EQUAL(N, count); + /* check hash_numrecords */ + CU_ASSERT_EQUAL(N, hash_numrecords(&ht)); + /* free the hash table */ freed_count = 0; free_hash_table(&ht, lincoln); @@ -347,4 +371,38 @@ static void test_iter(void) hash_iter_free(&iter); free_hash_table(&ht, NULL); } + +static void test_load_factor_warning(void) +{ + const char *const words[] = { + "id", "faucibus", "nisl", "tincidunt", "eget", "nullam", "non", "nisi", + "est", "sit", "amet", "facilisis", "magna", "etiam", "tempor", "orci", + "eu", "lobortis", "elementum", "nibh", "tellus", "molestie" + }; + const size_t n_words = sizeof words / sizeof words[0]; /* 22 */ + const size_t n_buckets = n_words / 4; /* 5 */ + unsigned i; + unsigned int syslog_index; + + hash_table ht; + + /* make sure numeric assumptions hold */ + CU_ASSERT_EQUAL_FATAL(22, n_words); + CU_ASSERT_EQUAL_FATAL(5, n_buckets); + + construct_hash_table(&ht, n_buckets, 0); + CU_ASSERT_EQUAL(n_buckets, ht.size); + + syslog_index = CU_SYSLOG_MATCH("hash table load factor exceeds 3.0"); + /* 5 buckets will hit load factor 3.0 after 15 insertions, 4.0 after 20 + * insertions, and it won't reach 5.0, so we should warn exactly twice */ + for (i = 0; i < n_words; i++) { + hash_insert(words[i], NULL, &ht); + } + CU_ASSERT_EQUAL(n_words, hash_numrecords(&ht)); + CU_ASSERT_SYSLOG(syslog_index, 2); + + free_hash_table(&ht, NULL); +} + /* vim: set ft=c: */ diff --git a/cunit/hashset.testc b/cunit/hashset.testc index 071e33bbef..d8e6bda1b5 100644 --- a/cunit/hashset.testc +++ b/cunit/hashset.testc @@ -53,6 +53,8 @@ static void test_exists(void) r = hashset_exists(hs, unvalues[i]); CU_ASSERT_EQUAL(r, 0); } + + hashset_free(&hs); } static void test_collisions(void) @@ -98,4 +100,6 @@ static void test_collisions(void) r = hashset_exists(hs, unvalues[i]); CU_ASSERT_EQUAL(r, 0); } + + hashset_free(&hs); } diff --git a/cunit/http_jwt.testc b/cunit/http_jwt.testc new file mode 100644 index 0000000000..293bb22ce9 --- /dev/null +++ b/cunit/http_jwt.testc @@ -0,0 +1,518 @@ +#include "config.h" +#include "cunit/cyrunit.h" + +#include +#include +#include +#include +#include +#include + +#include "charset.h" +#include "util.h" +#include "xunlink.h" + +#include "imap/http_jwt.h" + +#define HMAC_KEY_RAW "01234567890123456789012345678901234567890123456789012345" +#define HMAC_KEY_B64 "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU=" + +static const char HMAC_PEM[] = + "-----BEGIN HMAC KEY-----\n" + HMAC_KEY_B64 "\n" + "-----END HMAC KEY-----\n"; + +static const char RSA_PEM[] = + "-----BEGIN PUBLIC KEY-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n" + "4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u\n" + "+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh\n" + "kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ\n" + "0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg\n" + "cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc\n" + "mwIDAQAB\n" + "-----END PUBLIC KEY-----\n"; + +static const char RSA_PRIVATE_KEY_PEM[] = + "-----BEGIN PRIVATE KEY-----\n" + "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKj\n" + "MzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu\n" + "NMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ\n" + "qgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulg\n" + "p2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlR\n" + "ZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwi\n" + "VuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskV\n" + "laAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8\n" + "sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83H\n" + "mQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwY\n" + "dgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cw\n" + "ta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQ\n" + "DM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2T\n" + "N0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t\n" + "0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPv\n" + "t8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDU\n" + "AhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk\n" + "48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISL\n" + "DY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnK\n" + "xt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEA\n" + "mNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh\n" + "2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfz\n" + "et6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhr\n" + "VBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicD\n" + "TQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cnc\n" + "dn/RsYEONbwQSjIfMPkvxF+8HQ==\n" + "-----END PRIVATE KEY-----\n"; + +static void test_init_multi(void) +{ + char dname[] = "/tmp/cyrus-cunit-XXXXXX"; + CU_ASSERT_PTR_NOT_NULL(mkdtemp(dname)); + + char *fnames[2] = { + strconcat(dname, "/hmac.pem", NULL), + strconcat(dname, "/rsa.pem", NULL) + }; + + FILE *fp = fopen(fnames[0], "w"); + CU_ASSERT_PTR_NOT_NULL_FATAL(fp); + fputs(HMAC_PEM, fp); + fclose(fp); + + fp = fopen(fnames[1], "w"); + CU_ASSERT_PTR_NOT_NULL_FATAL(fp); + fputs(RSA_PEM, fp); + fclose(fp); + + int r = http_jwt_init(dname, 0); + CU_ASSERT_EQUAL(0, r); + CU_ASSERT_EQUAL(1, http_jwt_is_enabled()); + + xunlink(fnames[0]); + xunlink(fnames[1]); + free(fnames[0]); + free(fnames[1]); + rmdir(dname); + http_jwt_reset(); +} + +static void test_init_nokeys(void) +{ + char dname[] = "/tmp/cyrus-cunit-XXXXXX"; + CU_ASSERT_PTR_NOT_NULL(mkdtemp(dname)); + + char *fname = strconcat(dname, "/key.pem", NULL); + FILE *fp = fopen(fname, "w"); + CU_ASSERT_PTR_NOT_NULL_FATAL(fp); + fputs("xxx", fp); + fclose(fp); + + int r = http_jwt_init(dname, 0); + CU_ASSERT_NOT_EQUAL(-1, r); + CU_ASSERT_NOT_EQUAL(1, http_jwt_is_enabled()); + + xunlink(fname); + free(fname); + rmdir(dname); + http_jwt_reset(); +} + +static void token(struct buf *buf, + const char *joh, + const char *jws, + EVP_PKEY *pkey, + const EVP_MD *emd) +{ + buf_reset(buf); + if (joh) + charset_encode(buf, joh, strlen(joh), ENCODING_BASE64URL); + if (jws) { + buf_putc(buf, '.'); + charset_encode(buf, jws, strlen(jws), ENCODING_BASE64URL); + } + if (pkey) { + EVP_MD_CTX *ctx = EVP_MD_CTX_new(); + CU_ASSERT_PTR_NOT_NULL_FATAL(ctx); + + int r = EVP_DigestSignInit(ctx, NULL, emd, NULL, pkey); + CU_ASSERT_EQUAL_FATAL(1, r); + + r = EVP_DigestSignUpdate(ctx, buf_base(buf), buf_len(buf)); + CU_ASSERT_EQUAL_FATAL(1, r); + + size_t siglen = 0; + r = EVP_DigestSignFinal(ctx, NULL, &siglen); + CU_ASSERT_EQUAL_FATAL(1, r); + + char *sig = xmalloc(siglen); + r = EVP_DigestSignFinal(ctx, (unsigned char*)sig, &siglen); + CU_ASSERT_EQUAL_FATAL(1, r); + buf_putc(buf, '.'); + charset_encode(buf, sig, siglen, ENCODING_BASE64URL); + free(sig); + + EVP_MD_CTX_free(ctx); + } +} + +static void test_validate(void) +{ + char dname[] = "/tmp/cyrus-cunit-XXXXXX"; + CU_ASSERT_PTR_NOT_NULL(mkdtemp(dname)); + + char *fname = strconcat(dname, "/key.pem", NULL); + FILE *fp = fopen(fname, "w"); + CU_ASSERT_PTR_NOT_NULL_FATAL(fp); + fputs(HMAC_PEM, fp); + fclose(fp); + + int r = http_jwt_init(dname, 0); + CU_ASSERT_EQUAL(0, r); + CU_ASSERT_NOT_EQUAL(0, http_jwt_is_enabled()); + + EVP_PKEY *pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_HMAC, NULL, + (unsigned char*)HMAC_KEY_RAW, strlen(HMAC_KEY_RAW)); + + struct buf tok = BUF_INITIALIZER; + char out[256]; + + // valid token + token(&tok, + "{\"alg\":\"HS256\",\"typ\":\"JWT\"}", + "{\"sub\":\"test\"}", pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, sizeof(out)); + CU_ASSERT_EQUAL(SASL_OK, r); + CU_ASSERT_STRING_EQUAL("test", out); + + // valid token: ignored iat + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", + "{\"sub\": \"test\", \"iat\":1516239022}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_OK, r); + CU_ASSERT_STRING_EQUAL("test", out); + + // valid token: ignore unknown claim + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", + "{\"sub\": \"test\", \"iss\":\"foo\"}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_OK, r); + CU_ASSERT_STRING_EQUAL("test", out); + + // invalid token: no signature + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", + "{\"sub\": \"test\", \"iat\":1516239022}", + NULL, NULL); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: no header + token(&tok, + NULL, + "{\"sub\": \"test\", \"iat\":1516239022}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: no jws + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", + NULL, + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: unsupported algo + token(&tok, + "{\"alg\": \"PS512\", \"typ\": \"JWT\"}", + "{\"sub\": \"test\"}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: wrong type + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"XXX\"}", + "{\"sub\": \"test\"}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: bad JSON header + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"", + "{\"sub\": \"test\"}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: no sub + token(&tok, + "{\"alg\": \"HS512\", \"typ\": \"JWT\"}", + "{}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: bad JWS + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", + "{\"sub\": \"test\"", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: unsupported header parameter + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\", \"iss\":\"foo\"}", + "{\"sub\": \"test\"}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: non-number iat claim + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", + "{\"sub\": \"test\", \"iat\": \"xxx\"}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: non-number nbf claim + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", + "{\"sub\": \"test\", \"nbf\": \"xxx\"}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + // invalid token: non-number exp claim + token(&tok, + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", + "{\"sub\": \"test\", \"exp\": \"xxx\"}", + pkey, EVP_sha256()); + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); + CU_ASSERT_EQUAL(SASL_BADAUTH, r); + CU_ASSERT_STRING_EQUAL("", out); + + buf_free(&tok); + + EVP_PKEY_free(pkey); + xunlink(fname); + free(fname); + rmdir(dname); + http_jwt_reset(); +} + +#define TEST_TIMECLAIMS(nbf, exp, iat, want_r) \ + { \ + json_t *jws = json_pack("{s:s}", "sub", "test"); \ + if (nbf) json_object_set_new(jws, "nbf", json_integer(nbf)); \ + if (exp) json_object_set_new(jws, "exp", json_integer(exp)); \ + if (iat) json_object_set_new(jws, "iat", json_integer(iat)); \ + char *s = json_dumps(jws, JSON_COMPACT); \ + token(&tok, \ + "{\"alg\": \"HS256\", \"typ\": \"JWT\"}", s, pkey, EVP_sha256()); \ + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, 256); \ + CU_ASSERT_EQUAL((want_r), r); \ + CU_ASSERT_STRING_EQUAL((want_r == SASL_OK) ? "test" : "", out); \ + free(s); \ + json_decref(jws); \ + } + + +static void test_validate_claims_no_max_age(void) +{ + char dname[] = "/tmp/cyrus-cunit-XXXXXX"; + CU_ASSERT_PTR_NOT_NULL(mkdtemp(dname)); + + char *fname = strconcat(dname, "/key.pem", NULL); + FILE *fp = fopen(fname, "w"); + CU_ASSERT_PTR_NOT_NULL_FATAL(fp); + fputs(HMAC_PEM, fp); + fclose(fp); + + int r = http_jwt_init(dname, 0); + CU_ASSERT_EQUAL(0, r); + CU_ASSERT_NOT_EQUAL(0, http_jwt_is_enabled()); + + EVP_PKEY *pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_HMAC, NULL, + (unsigned char*)HMAC_KEY_RAW, strlen(HMAC_KEY_RAW)); + + struct buf tok = BUF_INITIALIZER; + char out[256]; + time_t now = time(NULL); + + // valid token: no time claim set + TEST_TIMECLAIMS(0, 0, 0, SASL_OK); + + // valid token: iat = now + TEST_TIMECLAIMS(0, 0, now, SASL_OK); + + // valid token: iat < now + TEST_TIMECLAIMS(0, 0, now - 2, SASL_OK); + + // valid token: iat > now ignored (no max_age) + TEST_TIMECLAIMS(0, 0, now + 2, SASL_OK); + + // valid token: exp = now + 2s + TEST_TIMECLAIMS(0, now + 2, 0, SASL_OK); + + // valid token: exp = now + 2s, nbf = now + TEST_TIMECLAIMS(now, now + 2, 0, SASL_OK); + + // invalid token: exp = now + TEST_TIMECLAIMS(0, now, 0, SASL_BADAUTH); + + // invalid token: nbf > now + TEST_TIMECLAIMS(now + 2, now + 2, 0, SASL_BADAUTH); + + buf_free(&tok); + + EVP_PKEY_free(pkey); + xunlink(fname); + free(fname); + rmdir(dname); + http_jwt_reset(); +} + +static void test_validate_claims_with_max_age(void) +{ + char dname[] = "/tmp/cyrus-cunit-XXXXXX"; + CU_ASSERT_PTR_NOT_NULL(mkdtemp(dname)); + + char *fname = strconcat(dname, "/key.pem", NULL); + FILE *fp = fopen(fname, "w"); + CU_ASSERT_PTR_NOT_NULL_FATAL(fp); + fputs(HMAC_PEM, fp); + fclose(fp); + + int max_age = 60; + + int r = http_jwt_init(dname, max_age); + CU_ASSERT_EQUAL(0, r); + CU_ASSERT_NOT_EQUAL(0, http_jwt_is_enabled()); + + EVP_PKEY *pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_HMAC, NULL, + (unsigned char*)HMAC_KEY_RAW, strlen(HMAC_KEY_RAW)); + + struct buf tok = BUF_INITIALIZER; + char out[256]; + time_t now = time(NULL); + + // invalid token: no iat set, but max_age configured + TEST_TIMECLAIMS(0, 0, 0, SASL_BADAUTH); + + // valid token: iat + max_age > now + TEST_TIMECLAIMS(0, 0, now - 2, SASL_OK); + + // invalid token: iat + max_age <= now + TEST_TIMECLAIMS(0, 0, now - max_age, SASL_BADAUTH); + + // invalid token: iat > now + TEST_TIMECLAIMS(0, 0, now + 2, SASL_BADAUTH); + + // valid token: exp > now + TEST_TIMECLAIMS(0, now + 2, 0, SASL_OK); + + // valid token: exp > now, even if iat+max_age expired + TEST_TIMECLAIMS(0, now + 2, now - max_age, SASL_OK); + + // invalid token: exp == now, at+max_age expired + TEST_TIMECLAIMS(0, now, now - max_age, SASL_BADAUTH); + + buf_free(&tok); + + EVP_PKEY_free(pkey); + xunlink(fname); + free(fname); + rmdir(dname); + http_jwt_reset(); +} + +#undef TEST_TIMECLAIMS + +static void test_auth(void) +{ + char dname[] = "/tmp/cyrus-cunit-XXXXXX"; + CU_ASSERT_PTR_NOT_NULL(mkdtemp(dname)); + + char *fnames[2] = { + strconcat(dname, "/hmac.pem", NULL), + strconcat(dname, "/rsa.pem", NULL) + }; + + FILE *fp = fopen(fnames[0], "w"); + CU_ASSERT_PTR_NOT_NULL_FATAL(fp); + fputs(HMAC_PEM, fp); + fclose(fp); + + fp = fopen(fnames[1], "w"); + CU_ASSERT_PTR_NOT_NULL_FATAL(fp); + fputs(RSA_PEM, fp); + fclose(fp); + + int r = http_jwt_init(dname, 0); + CU_ASSERT_EQUAL(0, r); + CU_ASSERT_EQUAL(1, http_jwt_is_enabled()); + +#define TESTCASE(alg, pkey, emd) \ + { \ + struct buf tok = BUF_INITIALIZER; \ + char out[256]; \ + token(&tok, \ + "{\"alg\":\"" alg "\",\"typ\":\"JWT\"}", \ + "{\"sub\":\"test\"}", pkey, emd); \ + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, sizeof(out)); \ + CU_ASSERT_EQUAL(SASL_OK, r); \ + CU_ASSERT_STRING_EQUAL("test", out); \ + char c = buf_base(&tok)[buf_len(&tok)-1]; \ + c = c == 'A' ? '/' : 'A'; \ + buf_truncate(&tok, -1); \ + buf_putc(&tok, c); \ + r = http_jwt_auth(buf_base(&tok), buf_len(&tok), out, sizeof(out)); \ + CU_ASSERT_NOT_EQUAL(SASL_OK, r); \ + CU_ASSERT_STRING_EQUAL("", out); \ + buf_free(&tok); \ + } + + EVP_PKEY *pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_HMAC, NULL, + (unsigned char*)HMAC_KEY_RAW, strlen(HMAC_KEY_RAW)); + TESTCASE("HS256", pkey, EVP_sha256()); + TESTCASE("HS384", pkey, EVP_sha384()); + TESTCASE("HS512", pkey, EVP_sha512()); + EVP_PKEY_free(pkey); + + BIO *bp = BIO_new_mem_buf(RSA_PRIVATE_KEY_PEM, -1); + CU_ASSERT_PTR_NOT_NULL_FATAL(bp); + pkey = PEM_read_bio_PrivateKey(bp, NULL, NULL, NULL); + CU_ASSERT_PTR_NOT_NULL_FATAL(pkey); + TESTCASE("RS256", pkey, EVP_sha256()); + TESTCASE("RS384", pkey, EVP_sha384()); + TESTCASE("RS512", pkey, EVP_sha512()); + EVP_PKEY_free(pkey); + BIO_free(bp); + + xunlink(fnames[0]); + xunlink(fnames[1]); + free(fnames[0]); + free(fnames[1]); + rmdir(dname); + http_jwt_reset(); +} + +/* vim: set ft=c: */ diff --git a/cunit/ical_support.testc b/cunit/ical_support.testc new file mode 100644 index 0000000000..b584e2787d --- /dev/null +++ b/cunit/ical_support.testc @@ -0,0 +1,200 @@ +#if HAVE_CONFIG_H +#include +#endif +#include "cunit/cyrunit.h" + +#include "imap/ical_support.h" + +// caldav_db.h defines these + +extern time_t caldav_epoch; +extern time_t caldav_eternity; + +static void init_caldav() +{ + if (caldav_epoch == -1) caldav_epoch = INT_MIN; + if (caldav_eternity == -1) caldav_eternity = INT_MAX; +} + +static void test_icalrecurrenceset_get_utc_timespan(void) +{ + init_caldav(); + + char *eternitystr = icaltime_as_ical_string_r( + icaltime_from_timet_with_zone(caldav_eternity, 0, NULL)); + + struct testcase { + const char *desc; + const char *icalstr; + const char *start; + const char *end; + int recurring; + }; + + struct testcase tcs[] = {{ + "not recurring", + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//foo//bar\r\n" + "CALSCALE:GREGORIAN\r\n" + "BEGIN:VEVENT\r\n" + "DTSTART:20160928T160000Z\r\n" + "DURATION:PT1H\r\n" + "UID:123456789\r\n" + "DTSTAMP:20150928T132434Z\r\n" + "SUMMARY:test\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n", + "20160928T160000Z", + "20160928T170000Z", + 0 + }, { + "eternal rrule", + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//foo//bar\r\n" + "CALSCALE:GREGORIAN\r\n" + "BEGIN:VEVENT\r\n" + "DTSTART:20160928T160000Z\r\n" + "DURATION:PT1H\r\n" + "RRULE:FREQ=WEEKLY\r\n" + "UID:123456789\r\n" + "DTSTAMP:20150928T132434Z\r\n" + "SUMMARY:test\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n", + "20160928T160000Z", + eternitystr, + 1 + }, { + "bounded rrule", + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//foo//bar\r\n" + "CALSCALE:GREGORIAN\r\n" + "BEGIN:VEVENT\r\n" + "DTSTART:20160928T160000Z\r\n" + "DURATION:PT1H\r\n" + "RRULE:FREQ=WEEKLY;COUNT=3\r\n" + "UID:123456789\r\n" + "DTSTAMP:20150928T132434Z\r\n" + "SUMMARY:test\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n", + "20160928T160000Z", + "20161012T170000Z", + 1 + }, { + "one bounded rrule, one eternal rrule", + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//foo//bar\r\n" + "CALSCALE:GREGORIAN\r\n" + "BEGIN:VEVENT\r\n" + "DTSTART:20160928T160000Z\r\n" + "DURATION:PT1H\r\n" + "RRULE:FREQ=WEEKLY;COUNT=3\r\n" + "RRULE:FREQ=MONTHLY\r\n" + "UID:123456789\r\n" + "DTSTAMP:20150928T132434Z\r\n" + "SUMMARY:test\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n", + "20160928T160000Z", + eternitystr, + 1 + }, { + "two bounded rrules", + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//foo//bar\r\n" + "CALSCALE:GREGORIAN\r\n" + "BEGIN:VEVENT\r\n" + "DTSTART:20160928T160000Z\r\n" + "DURATION:PT1H\r\n" + "RRULE:FREQ=WEEKLY;COUNT=3\r\n" + "RRULE:FREQ=MONTHLY;UNTIL=20170228T160000Z\r\n" + "UID:123456789\r\n" + "DTSTAMP:20150928T132434Z\r\n" + "SUMMARY:test\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n", + "20160928T160000Z", + "20170228T170000Z", + 1 + }}; + const size_t n_tcs = sizeof(tcs) / sizeof(tcs[0]); + + struct buf buf = BUF_INITIALIZER; + unsigned i; + for (i = 0; i < n_tcs; i++) { + const struct testcase *tc = &tcs[i]; + buf_setcstr(&buf, tc->icalstr); + icalcomponent *ical = ical_string_as_icalcomponent(&buf); + CU_ASSERT_PTR_NOT_NULL(ical); + unsigned _recurring = 0; + struct icalperiodtype span = icalrecurrenceset_get_utc_timespan(ical, + ICAL_VEVENT_COMPONENT, NULL, &_recurring, NULL, NULL); + CU_ASSERT_STRING_EQUAL(icaltime_as_ical_string(span.start), tc->start); + CU_ASSERT_STRING_EQUAL(icaltime_as_ical_string(span.end), tc->end); + CU_ASSERT_EQUAL(_recurring, tc->recurring); + icalcomponent_free(ical); + } + buf_free(&buf); + + free(eternitystr); +} + +static int icalcomponent_myforeach_duplicate_rrule_cb(icalcomponent *comp __attribute__((unused)), + icaltimetype start __attribute__((unused)), + icaltimetype end __attribute__((unused)), + icaltimetype recurid, + int is_standalone __attribute__((unused)), + void *data) +{ + strarray_t *recurids = data; + strarray_append(recurids, icaltime_as_ical_string(recurid)); + return 1; +} + +static void test_icalcomponent_myforeach_duplicate_rrule(void) +{ + init_caldav(); + + struct buf buf = BUF_INITIALIZER; + buf_setcstr(&buf, + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Foo//Bar//EN\r\n" + "CALSCALE:GREGORIAN\r\n" + "BEGIN:VEVENT\r\n" + "UID:574E2CD0-2D2A-4554-8B63-C7504481D3A9\r\n" + "SUMMARY:test\r\n" + "DTSTART:20240101T010203Z\r\n" + "SEQUENCE:0\r\n" + "RRULE:FREQ=DAILY;COUNT=3\r\n" + "RRULE:FREQ=DAILY;COUNT=3\r\n" + "RRULE:FREQ=WEEKLY;COUNT=2\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR"); + + icalcomponent *ical = ical_string_as_icalcomponent(&buf); + CU_ASSERT_PTR_NOT_NULL(ical); + + strarray_t recurids = STRARRAY_INITIALIZER; + struct icalperiodtype range = ICALPERIODTYPE_INITIALIZER; + int r = icalcomponent_myforeach(ical, range, NULL, + icalcomponent_myforeach_duplicate_rrule_cb, &recurids); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(strarray_size(&recurids), 4); + CU_ASSERT_STRING_EQUAL(strarray_nth(&recurids, 0), "20240101T010203Z"); + CU_ASSERT_STRING_EQUAL(strarray_nth(&recurids, 1), "20240102T010203Z"); + CU_ASSERT_STRING_EQUAL(strarray_nth(&recurids, 2), "20240103T010203Z"); + CU_ASSERT_STRING_EQUAL(strarray_nth(&recurids, 3), "20240108T010203Z"); + + strarray_fini(&recurids); + icalcomponent_free(ical); + buf_free(&buf); +} + +/* vim: set ft=c: */ diff --git a/cunit/imapurl.testc b/cunit/imapurl.testc index 7f983d12b5..8cfaa8eab8 100644 --- a/cunit/imapurl.testc +++ b/cunit/imapurl.testc @@ -1,4 +1,4 @@ -#include +#include #include #include "cunit/cyrunit.h" #include "imapurl.h" @@ -188,44 +188,44 @@ static void test_tourl(void) { static const char URL[] = "imap://jeeves/deverill"; struct imapurl iurl; - char buf[300]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(iurl)); iurl.server = "jeeves"; iurl.mailbox = "deverill"; - memset(buf, 0x45, sizeof(buf)); - imapurl_toURL(buf, &iurl); - CU_ASSERT_STRING_EQUAL(buf, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); + buf_free(&buf); } static void test_tourl_server(void) { static const char URL[] = "imap://jeeves"; struct imapurl iurl; - char buf[300]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(iurl)); iurl.server = "jeeves"; - memset(buf, 0x45, sizeof(buf)); - imapurl_toURL(buf, &iurl); - CU_ASSERT_STRING_EQUAL(buf, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); + buf_free(&buf); } static void test_tourl_user(void) { static const char URL[] = "imap://wooster@jeeves/deverill"; struct imapurl iurl; - char buf[300]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(iurl)); iurl.user = "wooster"; iurl.server = "jeeves"; iurl.mailbox = "deverill"; - memset(buf, 0x45, sizeof(buf)); - imapurl_toURL(buf, &iurl); - CU_ASSERT_STRING_EQUAL(buf, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); + buf_free(&buf); } static void test_tourl_options(void) @@ -236,7 +236,7 @@ static void test_tourl_options(void) "/;SECTION=1.4" "/;PARTIAL=1.1023"; struct imapurl iurl; - char buf[300]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(iurl)); iurl.server = "jeeves"; @@ -247,9 +247,9 @@ static void test_tourl_options(void) iurl.start_octet = 1; iurl.octet_count = 1023; - memset(buf, 0x45, sizeof(buf)); - imapurl_toURL(buf, &iurl); - CU_ASSERT_STRING_EQUAL(buf, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); + buf_free(&buf); } static void test_tourl_urlauth(void) @@ -259,7 +259,7 @@ static void test_tourl_urlauth(void) ";EXPIRE=2010-11-24T06:57:26Z" ";URLAUTH=submit+fred:internal:91354a473744909de610943775f92038"; struct imapurl iurl; - char buf[300]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(iurl)); iurl.server = "jeeves"; @@ -270,9 +270,9 @@ static void test_tourl_urlauth(void) iurl.urlauth.token = "91354a473744909de610943775f92038"; iurl.urlauth.expire = 1290581846; - memset(buf, 0x45, sizeof(buf)); - imapurl_toURL(buf, &iurl); - CU_ASSERT_STRING_EQUAL(buf, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); + buf_free(&buf); } static void test_tourl_ampersand(void) @@ -284,15 +284,15 @@ static void test_tourl_ampersand(void) */ static const char URL[] = "imap://goons/Goosey%26Bawks"; struct imapurl iurl; - char buf[300]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(iurl)); iurl.server = "goons"; iurl.mailbox = "Goosey&-Bawks"; - memset(buf, 0x45, sizeof(buf)); - imapurl_toURL(buf, &iurl); - CU_ASSERT_STRING_EQUAL(buf, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); + buf_free(&buf); } static void test_tourl_urlunsafe(void) @@ -303,15 +303,15 @@ static void test_tourl_urlunsafe(void) */ static const char URL[] = "imap://gibberish/%20%22%23%25%2B%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E%60%7B%7C%7D"; struct imapurl iurl; - char buf[300]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(iurl)); iurl.server = "gibberish"; iurl.mailbox = " \"#%+:;<=>?@[\\]^`{|}"; - memset(buf, 0x45, sizeof(buf)); - imapurl_toURL(buf, &iurl); - CU_ASSERT_STRING_EQUAL(buf, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); + buf_free(&buf); } static void test_tourl_utf7_high(void) @@ -331,15 +331,15 @@ static void test_tourl_utf7_high(void) */ static const char URL[] = "imap://uruk/%F0%92%81%B3%F0%92%80%A0%F0%92%84%A9"; struct imapurl iurl; - char buf[300]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(iurl)); iurl.server = "uruk"; iurl.mailbox = "&2Ajcc9gI3CDYCN0p-"; - memset(buf, 0x45, sizeof(buf)); - imapurl_toURL(buf, &iurl); - CU_ASSERT_STRING_EQUAL(buf, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); + buf_free(&buf); } static void test_fromurl_utf2_high(void) @@ -369,14 +369,13 @@ static void test_fromurl_utf2_high(void) free(iurl.freeme); } -/* This test was located in lib/test/imapurl.c. */ static void test_cycle(void) { struct imapurl iurl; struct imapurl iurl2; static const char URL[] = "imap://;AUTH=*@server/%C3%A4%20%C3%84;UIDVALIDITY=1234567890"; int r; - char url[400]; + struct buf buf = BUF_INITIALIZER; memset(&iurl, 0, sizeof(struct imapurl)); iurl.server = "server"; @@ -384,15 +383,16 @@ static void test_cycle(void) iurl.mailbox = "&AOQ- &AMQ-"; /* "ä Ä" */ iurl.uidvalidity = 1234567890; - memset(url, 0x45, sizeof(url)); - imapurl_toURL(url, &iurl); - CU_ASSERT_STRING_EQUAL(url, URL); + imapurl_toURL(&buf, &iurl); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), URL); - r = imapurl_fromURL(&iurl2, url); + r = imapurl_fromURL(&iurl2, buf_cstring(&buf)); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_STRING_EQUAL(iurl2.mailbox, "&AOQ- &AMQ-"); CU_ASSERT_EQUAL(iurl2.uidvalidity, 1234567890); free(iurl2.freeme); + + buf_free(&buf); } /* vim: set ft=c: */ diff --git a/cunit/imparse.testc b/cunit/imparse.testc index 27ba9877a8..5acca3aa09 100644 --- a/cunit/imparse.testc +++ b/cunit/imparse.testc @@ -62,3 +62,62 @@ static void test_isatom(void) /* XXX test imparse_issequence() */ /* XXX test imparse_isnumber() */ + +static void test_parse_range(void) +{ + range_t range; + + /* + * https://tools.ietf.org/html/rfc9051#name-formal-syntax + * + * nz-number = digit-nz *DIGIT + * ; Non-zero unsigned 32-bit integer + * ; (0 < n < 4,294,967,296) + * + * + * https://tools.ietf.org/html/rfc9394#name-formal-syntax + * + * MINUS = "-" + * + * partial-range-first = nz-number ":" nz-number + * ;; Request to search from oldest (lowest UIDs) to + * ;; more recent messages. + * ;; A range 500:400 is the same as 400:500. + * ;; This is similar to from [RFC3501] + * ;; but cannot contain "*". + * + * partial-range-last = MINUS nz-number ":" MINUS nz-number + * ;; Request to search from newest (highest UIDs) to + * ;; oldest messages. + * ;; A range -500:-400 is the same as -400:-500. + * + * partial-range = partial-range-first / partial-range-last + */ + + CU_ASSERT_EQUAL(imparse_range("1:1", &range), 0); + CU_ASSERT_EQUAL(imparse_range("1:2", &range), 0); + CU_ASSERT_EQUAL(imparse_range("2:1", &range), 0); + CU_ASSERT_EQUAL(imparse_range("-1:-2", &range), 0); + CU_ASSERT_EQUAL(imparse_range("-2:-1", &range), 0); + + CU_ASSERT_EQUAL(imparse_range("1:-2", &range), 0); + CU_ASSERT_EQUAL(imparse_range("-1:2", &range), 0); + + CU_ASSERT_NOT_EQUAL(imparse_range("0:1", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("--1:-2", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("+1:-2", &range), 0); + + CU_ASSERT_NOT_EQUAL(imparse_range("1", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("1:", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range(":1", &range), 0); + + CU_ASSERT_NOT_EQUAL(imparse_range("1:a", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("-1:-a", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("1a:2", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("1:2a", &range), 0); + + CU_ASSERT_NOT_EQUAL(imparse_range("1:4294967296", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("-1:-4294967296", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("1:18446744073709551616", &range), 0); + CU_ASSERT_NOT_EQUAL(imparse_range("-1:-18446744073709551616", &range), 0); +} diff --git a/cunit/jmap_util.testc b/cunit/jmap_util.testc index 6acd15f2ae..c2ed15072d 100644 --- a/cunit/jmap_util.testc +++ b/cunit/jmap_util.testc @@ -2,106 +2,112 @@ #include "cunit/cyrunit.h" #include "xmalloc.h" #include "util.h" +#include "hash.h" #include "imap/jmap_util.h" -static void test_patchobject_create(void) +static void test_patchobject(void) { -#define TESTCASE(src, dst, want) \ +#define TESTCASE(_from, _dest, _want, _flags) \ { \ - json_t *jsrc = json_loads(src, JSON_DECODE_ANY, NULL); \ - json_t *jdst = json_loads(dst, JSON_DECODE_ANY, NULL); \ - \ - json_t *jwant = json_loads(want, JSON_DECODE_ANY, NULL); \ - json_t *jdiff = jmap_patchobject_create(jsrc, jdst); \ + unsigned myflags = (_flags); \ + json_t *jfrom = json_loads((_from), JSON_DECODE_ANY, NULL); \ + json_t *jdest = json_loads((_dest), JSON_DECODE_ANY, NULL); \ + json_t *jwant = json_loads((_want), JSON_DECODE_ANY, NULL); \ + json_t *jdiff = jmap_patchobject_create(jfrom, jdest, myflags); \ CU_ASSERT_PTR_NOT_NULL(jdiff); \ - \ char *swant = json_dumps(jwant, JSON_SORT_KEYS|JSON_ENCODE_ANY); \ char *sdiff = json_dumps(jdiff, JSON_SORT_KEYS|JSON_ENCODE_ANY); \ CU_ASSERT_STRING_EQUAL(swant, sdiff); \ - \ + json_t *jback = jmap_patchobject_apply(jfrom, jdiff, NULL, myflags); \ + char *sback = json_dumps(jback, JSON_SORT_KEYS|JSON_ENCODE_ANY); \ + char *sdest = json_dumps(jdest, JSON_SORT_KEYS|JSON_ENCODE_ANY); \ + CU_ASSERT_STRING_EQUAL(sdest, sback); \ + free(sdest); \ + free(sback); \ + json_decref(jback); \ free(sdiff); \ free(swant); \ json_decref(jdiff); \ json_decref(jwant); \ - json_decref(jdst); \ - json_decref(jsrc); \ + json_decref(jdest); \ + json_decref(jfrom); \ } - const char *src, *dst, *want; + const char *from, *dest, *want; /* Remove one property at top-level */ - src = "{" + from = "{" " \"a\": 1," " \"b\": 1" "}"; - dst = "{" + dest = "{" " \"a\": 1" "}"; want = "{" " \"b\": null" "}"; - TESTCASE(src, dst, want); + TESTCASE(from, dest, want, 0); /* Add one property at top-level */ - src = "{" + from = "{" " \"a\": 1" "}"; - dst = "{" + dest = "{" " \"a\": 1," " \"b\": 1" "}"; want = "{" " \"b\": 1" "}"; - TESTCASE(src, dst, want); + TESTCASE(from, dest, want, 0); /* Replace one scalar property at top-level with another */ - src = "{" + from = "{" " \"a\": 1" "}"; - dst = "{" + dest = "{" " \"a\": 2" "}"; want = "{" " \"a\": 2" "}"; - TESTCASE(src, dst, want); + TESTCASE(from, dest, want, 0); /* Replace one object property at top-level with a scalar */ - src = "{" + from = "{" " \"a\": {" " \"b\": 1" " }" "}"; - dst = "{" + dest = "{" " \"a\": 2" "}"; want = "{" " \"a\": 2" "}"; - TESTCASE(src, dst, want); + TESTCASE(from, dest, want, 0); /* Replace one scalar property at top-level with an object */ - src = "{" + from = "{" " \"a\": {" " \"b\": 1" " }" "}"; - dst = "{" + dest = "{" " \"a\": 2" "}"; want = "{" " \"a\": 2" "}"; - TESTCASE(src, dst, want); + TESTCASE(from, dest, want, 0); /* Add a nested property */ - src = "{" + from = "{" " \"a\": {" " \"b\": 1" " }" "}"; - dst = "{" + dest = "{" " \"a\": {" " \"b\": 1," " \"c\": 2" @@ -110,16 +116,16 @@ static void test_patchobject_create(void) want = "{" " \"a/c\": 2" "}"; - TESTCASE(src, dst, want); + TESTCASE(from, dest, want, 0); /* Remove a nested property */ - src = "{" + from = "{" " \"a\": {" " \"b\": 1," " \"c\": 2" " }" "}"; - dst = "{" + dest = "{" " \"a\": {" " \"b\": 1" " }" @@ -127,10 +133,297 @@ static void test_patchobject_create(void) want = "{" " \"a/c\": null" "}"; - TESTCASE(src, dst, want); + TESTCASE(from, dest, want, 0); + /* Changing array member replaces array */ + from = "{" + " \"a\": [{" + " \"val\": \"foo\"" + " }, {" + " \"val\": \"bar\"" + " }]" + "}"; + dest = "{" + " \"a\": [{" + " \"val\": \"foo\"" + " }, {" + " \"val\": \"baz\"" + " }]" + "}"; + want = "{" + " \"a\": [{" + " \"val\": \"foo\"" + " }, {" + " \"val\": \"baz\"" + " }]" + "}"; + TESTCASE(from, dest, want, 0); + + /* PATCH_ALLOW_ARRAY: replaces array member */ + from = "{" + " \"a\": [{" + " \"val\": \"foo\"" + " }, {" + " \"val\": \"bar\"" + " }]" + "}"; + dest = "{" + " \"a\": [{" + " \"val\": \"foo\"" + " }, {" + " \"val\": \"baz\"" + " }]" + "}"; + want = "{" + " \"a/1/val\": \"baz\"" + "}"; + TESTCASE(from, dest, want, PATCH_ALLOW_ARRAY); #undef TESTCASE } +static void test_patchobject_invalid(void) +{ +#define TESTCASE(_from, _patch, _want_invalid, _flags) \ + { \ + unsigned myflags = (_flags); \ + json_t *jfrom = json_loads((_from), JSON_DECODE_ANY, NULL); \ + json_t *jpatch = json_loads((_patch), JSON_DECODE_ANY, NULL); \ + json_t *jwant_invalid = json_loads((_want_invalid), JSON_DECODE_ANY, NULL); \ + json_t *jhave_invalid = json_array(); \ + json_t *jhave = jmap_patchobject_apply(jfrom, jpatch, jhave_invalid, myflags); \ + CU_ASSERT_PTR_NULL(jhave); \ + char *want_invalid = json_dumps(jwant_invalid, JSON_SORT_KEYS|JSON_ENCODE_ANY); \ + char *have_invalid = json_dumps(jhave_invalid, JSON_SORT_KEYS|JSON_ENCODE_ANY); \ + CU_ASSERT_STRING_EQUAL(want_invalid, have_invalid); \ + free(have_invalid); \ + free(want_invalid); \ + json_decref(jhave_invalid); \ + json_decref(jwant_invalid); \ + json_decref(jpatch); \ + json_decref(jfrom); \ + } + + const char *from, *patch, *want_invalid; + + /* Set non-existent member */ + from = "{" + " \"a\": \"foo\"" + "}"; + patch = "{" + " \"x/y\": \"bar\"" + "}"; + want_invalid = "[" + "\"x/y\"" + "]"; + TESTCASE(from, patch, want_invalid, 0); + + /* Remove non-existent member */ + from = "{" + " \"a\": \"foo\"" + "}"; + patch = "{" + " \"x/y\": null" + "}"; + want_invalid = "[" + "\"x/y\"" + "]"; + TESTCASE(from, patch, want_invalid, 0); + + /* Patch inside array - but no PATCH_ALLOW_ARRAY flag */ + from = "{" + " \"a\": [" + " \"foo\"," + " \"bar\"" + " ]" + "}"; + patch = "{" + " \"a/1\": \"bam\"" + "}"; + want_invalid = "[" + " \"a/1\" " + "]"; + TESTCASE(from, patch, want_invalid, 0); + + /* Delete from array */ + from = "{" + " \"a\": [" + " \"foo\"," + " \"bar\"" + " ]" + "}"; + patch = "{" + " \"a/1\": null" + "}"; + want_invalid = "[" + " \"a/1\" " + "]"; + TESTCASE(from, patch, want_invalid, PATCH_ALLOW_ARRAY); + + /* Patch non-existent array entry */ + from = "{" + " \"a\": [" + " \"foo\"," + " \"bar\"" + " ]" + "}"; + patch = "{" + " \"a/2\": \"bam\"" + "}"; + want_invalid = "[" + " \"a/2\" " + "]"; + TESTCASE(from, patch, want_invalid, PATCH_ALLOW_ARRAY); + + /* Patch with special JSON pointer '-' */ + from = "{" + " \"a\": [" + " \"foo\"," + " \"bar\"" + " ]" + "}"; + patch = "{" + " \"a/-\": \"bam\"" + "}"; + want_invalid = "[" + " \"a/-\" " + "]"; + TESTCASE(from, patch, want_invalid, PATCH_ALLOW_ARRAY); + +#undef TESTCASE +} + +static void test_decode_to_utf8(void) +{ + struct testcase { + const char *data; + const char *charset; + int encoding; + float confidence; + const char *want_val; + int want_is_encoding_problem; + }; + + // this is all about "Adélaïde" + + struct testcase tcs[] = {{ + // ISO-8859-1 encoded data claims to be UTF-8, confidence 0.51 +#ifdef HAVE_LIBCHARDET + "Ad""\xe9""la""\xef""de", + "utf-8", + ENCODING_NONE, + 0.51, + "Ad""\xc3\xa9""la""\xc3\xaf""de", + 1 +#else + "Ad""\xe9""la""\xef""de", + "utf-8", + ENCODING_NONE, + 0.51, + "Ad""\xef\xbf\xbd""la""\xef\xbf\xbd""de", + 1 +#endif + }, { + // ISO-8859-1 encoded data claims to be UTF-8, confidence 1.0 + "Ad""\xe9""la""\xef""de", + "utf-8", + ENCODING_NONE, + 1.0, + "Ad""\xef\xbf\xbd""la""\xef\xbf\xbd""de", + 1 + }, { + // Fast-path valid UTF-8 + "Ad""\xc3\xa9""la""\xc3\xaf""de", + "utf-8", + ENCODING_NONE, + 0.51, + "Ad""\xc3\xa9""la""\xc3\xaf""de", + 0 + }, { + // Fast-path valid UTF-8 with replacement chars + "Ad""\xef\xbf\xbd""la""\xef\xbf\xbd""de", + "utf-8", + ENCODING_NONE, + 0.51, + "Ad""\xef\xbf\xbd""la""\xef\xbf\xbd""de", + 0 + }, { + // Multi-byte UTF-8 with invalid byte sequence: + // "Hello,😛🪐world🍎🏓!💾," "\xc3\x28" + "SGVsbG8s8J+Ym/CfqpB3b3JsZPCfjY7wn4+TIfCfkr4swyg=", + "utf-8", + ENCODING_BASE64, + 0.0, + "\x48\x65\x6c\x6c\x6f\x2c\xf0\x9f\x98\x9b\xf0\x9f" + "\xaa\x90\x77\x6f\x72\x6c\x64\xf0\x9f\x8d\x8e\xf0" + "\x9f\x8f\x93\x21\xf0\x9f\x92\xbe" + "," "\xef\xbf\xbd" "(", + 1 + }, { + NULL, NULL, ENCODING_UNKNOWN, 0.0, NULL, 0 + }}; + + struct buf buf = BUF_INITIALIZER; + struct testcase *tc; + for (tc = tcs; tc->data; tc++) { + int is_problem = 0; + buf_reset(&buf); + + jmap_decode_to_utf8(tc->charset, tc->encoding, + tc->data, strlen(tc->data), tc->confidence, &buf, &is_problem); + if (tc->want_val) + CU_ASSERT_STRING_EQUAL(tc->want_val, buf_cstring(&buf)); + else + CU_ASSERT_EQUAL(0, buf_len(&buf)); + CU_ASSERT_EQUAL(tc->want_is_encoding_problem, is_problem); + } + buf_free(&buf); +} + +static void test_caleventid(void) +{ + +#define TESTCASE(s, want_uid, want_recurid, want_eid) \ + { \ + struct jmap_caleventid *eid = jmap_caleventid_decode(s); \ + CU_ASSERT_PTR_NOT_NULL_FATAL(eid); \ + if (want_uid) \ + CU_ASSERT_STRING_EQUAL(want_uid, eid->ical_uid); \ + else \ + CU_ASSERT_PTR_NULL(eid->ical_uid); \ + CU_ASSERT_PTR_EQUAL(eid->ical_uid, eid->_alloced[0]); \ + if (want_recurid) \ + CU_ASSERT_STRING_EQUAL(want_recurid, eid->ical_recurid); \ + else \ + CU_ASSERT_PTR_NULL(eid->ical_recurid); \ + CU_ASSERT_PTR_EQUAL(eid->ical_recurid, eid->_alloced[1]); \ + struct buf buf = BUF_INITIALIZER; \ + CU_ASSERT_PTR_NOT_NULL(jmap_caleventid_encode(eid, &buf)); \ + if (want_eid) \ + CU_ASSERT_STRING_EQUAL(want_eid, buf_cstring(&buf)); \ + else \ + CU_ASSERT_STRING_EQUAL(s, buf_cstring(&buf)); \ + buf_free(&buf); \ + jmap_caleventid_free(&eid); \ + CU_ASSERT_PTR_NULL(eid); \ + } + + TESTCASE("61928725-A80F-4B2B-9704-ECC71E58F9E1", + "61928725-A80F-4B2B-9704-ECC71E58F9E1", NULL, + "E-61928725-A80F-4B2B-9704-ECC71E58F9E1"); + + TESTCASE("E-61928725-A80F-4B2B-9704-ECC71E58F9E1", + "61928725-A80F-4B2B-9704-ECC71E58F9E1", NULL, NULL); + + TESTCASE("ER-20211207-61928725-A80F-4B2B-9704-ECC71E58F9E1", + "61928725-A80F-4B2B-9704-ECC71E58F9E1", "20211207", NULL); + + TESTCASE("EB-NjE5Mjg3MjVAZXhhbXBsZS5jb20", + "61928725@example.com", NULL, NULL); + + TESTCASE("ERB-20211207T233000-NjE5Mjg3MjVAZXhhbXBsZS5jb20", + "61928725@example.com", "20211207T233000", NULL); + +} + /* vim: set ft=c: */ diff --git a/cunit/key.pem b/cunit/key.pem index c4655452e7..2ad85a4427 100644 --- a/cunit/key.pem +++ b/cunit/key.pem @@ -1,15 +1,51 @@ -----BEGIN RSA PRIVATE KEY----- -MIICXAIBAAKBgQDyzJYnkpOtVpx4hWjQvQAAYnrVIrNU8EobvBiNfjcfy7AEq9iR -VTfIiXkvlM0C0zQPScpogByLm75DxI+hUwSoNbeN2GfskjCJh1ULqZ1FN4iv6plk -EZxewtWVF983I/EMdekLth6wgH2g2odsgD9zcvTUtF1UeAdr739/CgiEuwIDAQAB -AoGAD31X0vx56WQOJW41aqO0HVIrDe/hyvhtcemFE3nK4H9pIlMoRNVP8r46efuf -PJ/mwVbuz83khE+SStZ+Z0dZk5XPa+12Zm/Se9OEvTRlIO/Owph191HZN6UgvlQv -hbMEpYd/olrVQGC37byf+sC8ZbBOeNgUJ4OsOd7t/7FQOCECQQD/4JB5jwxA+bQp -GOtVva/Ff8LUe/7YKDI83J/wnbKJ6hdxltBJyCnf71w6IpCplVbstIuTs8esKFwl -/P12uVmRAkEA8upqXanrYtpmDY+o+75wOM3QqZT9ZAlcE9ExGerDVCSuWWuHYVc/ -a1QiLoZT/Ox1Q4//QFFnlFopIGb53z0ziwJAa1Vk5UjnJ/5W34JvfWjzaZJdRMq6 -rSu3XyZhBQrxkL6clO5hcwG+5wu+ETjcm/ZkHyjg/9VDJelMNjc4j2vSsQJAKYVL -/QqIQ3NVMkg1+CHLCMqVOgdPcIPxCyocnHN2Q7GpY3tvvoGtQ2k0FoO7Y2X/fTbl -yFahv8fRc8pmN3Q8lQJBALHR6t9w5pHa4OykBGiho2HjnFJLgU0lbHfCr0jWZHgN -dZ4rE6d7bq5NvjaKxUk5nO/Flx2A6AkkxtJwFr8VYsA= +MIIJKQIBAAKCAgEAxXsnq+nsoM06mu6/1uhA2l3/I3V+t8eUd19loqZYGEzYtle1 +7UbmLEXNCf+7JBt1FFTRlaPSmxNd3EzlIOsH0IYrHlMa+l7GApx0goxxZk175eaS +m2hsUqHRzaYSuEQEnlXSkQX8g4ZHauPWubKmATwepseVkIE28Xly4QeXHKpBPTpg +3Tssd266bcsniQmp28n+/5Wope/BfzC7pNnTr0QW1kUc+knjJhBV+rWhkZm8ef6O +S5KkMMr0ICGsDf7GSmmMpYA+Z+n90gKRjErPLM5UfMt2+ufAoN7Q/NzpKCHN5CY6 +U/29PqxRrqkxpD1tw6a1Ba8+wgI0CECW7tMRl9MKr1EOqQ/dASgbUVZEkXt1E3HD +cT+GpsX0GGktU5/AhEKPnlVfXW/J6KlA2www9CCU5tg8tn/qX7On/ksDIY/1Mc7P +wXe1PW5GYNzEcUwYaW5ita3v2vgdSfw/AG7Rrh4BlwxzgYlFYUc3fyKIWb+HWTnh +xkK2BKatVWpTQZGgYNDHkHdXPZd6JpKm7Bo5sl6XpAha87OmmreE9zOYqhUU1vm5 +vgqYhfji7uXJ3LUPMBuL+u+UOlmNA8tHBQd3R3xXL7MZD4JZuQWSym+hDilmUpl3 +jT8HYRSvY+Suk20bLwOto/bkiTQlwce87zfjiP+SZ5GcopFq95u34GfE0dsCAwEA +AQKCAgAA3J65s1WjBgJBdtVDfNP7n/ljEDozVx2gv7vTz+IGiR9Q/GUA2hRbERrp +9kG80JncMtqPSp26q4T3VyaQ1DW+hTde9IHjodI/ZKtlfnNoPOJTiIQPRY9jdO1T +dmwSfcl/X2SB2YLWmBlrr/7Z5Juw2bBQjgJrFQVGXH9R2BSivWN3fu+5R27UPpl1 +rTNI98/T87e3KdIIl1lC0tWezIyN8UAgQ0DzHqttGRkm9O/1kLQv3BqG3eb1h401 +LrBvhzMaVAeXGU4saer/pZ84+4KX8XaQ7NpiEezXRuGmmNgzoqIhYsFSaIMQ6POa +TYa37sSx2+JiWfduJVBQ0OdXt3gWLY1kOHfWFcrRNqxlfGVvjky2/ByZjbd0SV+O +z01MV8/CW5UnuUnAPiVOX2zy8GIv6sU5sN8HZBgU1Edx4ZnrYiwbkyt+FRindBBP +iUytpaLZa9yieo6xG/rYhjIYXCtGbXilrtbSFr09hVDWV3pDihemh4r5gWvs6jFY +mNl+MVloblDhHZceq+RQgcFfCnHVO4chETeXwQyq00Y/YgeHrzgwpTwzEc0kgwjM +iU63+OU+UVfWzmu8+kJ59cFoQGJLZRyPSfmjzWu7Bnfq7XsTbnt8qldPm6pKoAek +GCmS+uTADFjxBIykVjWrCsixp3R9GRQQGjCIpXixLTskVp+G8QKCAQEA/lHCwAGN +nyyP4N5t3A/7/cv+krAzfwy6P7TJPVXgt5+GFudpd7jUT9uQlvKWX0H/U3fkPBmi +/LJuHOEtnGidFYGbTUe2mzZ229ZQ1NP1wMDBEsPxEjNi0+F3DM9Kn4pK6U5Uy9GY +nfu1iZJlS3DuXS09S3I2TpXshLo/pQMz5sFdGFI/VME/dMy6WmsfdCDRSV/NiTfz +jm6bKhGXpEw0oneo5W7ujvftjeW369xJgcMvUkIzwZWPJKqqlFC/riyEFiRQ9xjQ +5wuwyapgfhKiPij206hofO5Ar+MUAxpAIt4pl8QOJhE+Dp61edyMM7XuZ7nStTzm +bh95XjToH/wxZwKCAQEAxsk9QHDdHYgoVeibKNIqUKm41LSo4ZaQe6Uv99QvrPXg +U3pIKU88PPppUZH4S0EJbTWpA1MtXE5j1vqWdX7IbJXUBQ0v7bzw5P+jLVd3rrPN +UNQrX5yLO4vupXu+hjTzi3VbxnKIpEel31ajZ450BDIq+Z71pBNC/eltUN5WT+2I +cXedsZeMclq3WJEET2KRGHmK/3Dmge62NMUicdaPoUWUosCxDHHFA8YaPNUoIhjK +jhIl0/81Df0BSW41bH7gkv6Yb/sSsTSR5uyI6mRuGsDOgAMa7vF7aCPrzi8NDZzZ +eVklfP13I7uiRvOv3Kpsb2wcsNbuQlIS5YGQqJNPbQKCAQEAzOiLhbC6rvl0o7YT +xi+K1Z67au1VUJSsrA+55RWAjfKWU3X44GGnjwBVq4mh5vaCBnqfBl2RmREa72Hv +IgqYJm/a9ZVGaCCl+9LeJdzyMXAdIEWHwyZsBlOvXD7Y3VrLqNdYMzCZSxE337R4 +sSQ4qhJ9RICtiPv7KaX3Cble5BoALEx4go2B11XtAFU3bpXSitAKBvlx39z2YBr0 +l4hfEFhhWRrcU40ndiEU45EGGOtvAVQd52fdgamQ7xdwmaF8e2qfYbg4+S/OLW59 +eJcC6hqPZVJXffFpZU4NHcLU0kM2N/XbgIh7+8OcbKdqv29iu2hZgXWkJC5v15vB +O6QzGQKCAQEAxAXZ4tvpD6AetmiD6MMmexiCbS4hgyMoIuWH4clZoiNsLKVe122N +J0x/4rIguITPuOO7YM364xViGrJNAFwfZARzaO/SHYu9uPPlg2bHXH1tr5EpnEUQ +f43DrWfTPyCkMRdvgseauvT0OsKCrDGrch/OhQ0dich8vUocRCybzIGdlNaxqFib +ZIDUX//Q0j+OeSYRzUcV53bwMiVbjApa5Ftq8Ps3G+BsuQX3BZnk04rC40o+B0mY +lcyyIikNgYm0BwAMbhCWJCyE28TQVuLmOHd8qntlac6zNMSHWXDIXG4ZfjJMZ27C +t3fl1DWla+Kav11LBY9MsBWjELKtZa6uGQKCAQBMoZNz23ZjHMaZ7nyBG8huDTPF +6ll/3+7WvdL6o+YYmtcM8rKp2HAqr8xrh+QtKk1q3L55s8XyQbht82hdmXoXb08e +eF3QNGoC/urQ1usMz+lKeET/LoAG7z3lNKBBYZPEUl4T644ZqgbiLShb4KDsL+Xi +pIJAUut1YvOrcgiGP8fsjO43AcMev/dzfmfHL8YQ2JqVIMAZkVfdNnBP5lQ7mQ9y +QnNK1BPKd+apevGp7Cf0SQHL1j5MZW5A3Zwt5c41ZoyiDnDVFJheoMCcNDOiQ/VL +PyYckEI8JGkXj9TQiQaJCUNMx+cItxKTZWwsvA7XGl1eaFjjlpbVxhCjEQfh -----END RSA PRIVATE KEY----- diff --git a/cunit/libconfig.testc b/cunit/libconfig.testc index 3b094ae365..c502fe027f 100644 --- a/cunit/libconfig.testc +++ b/cunit/libconfig.testc @@ -38,8 +38,10 @@ * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -#include +#include +#include #include +#include #include "cunit/cyrunit.h" @@ -49,19 +51,6 @@ #define DBDIR "test-libconfig-dbdir" -/* XXX dedup this, it's c&p'd everywhere */ -static void config_read_string(const char *s) -{ - char *fname = xstrdup("/tmp/cyrus-cunit-configXXXXXX"); - int fd = mkstemp(fname); - retry_write(fd, s, strlen(s)); - config_reset(); - config_read(fname, 0); - unlink(fname); - free(fname); - close(fd); -} - static int set_up(void) { return 0; @@ -69,27 +58,34 @@ static int set_up(void) static int tear_down(void) { - return 0; + int r; + + /* all these tests will initialise some config, clean up after! */ + config_reset(); + + r = system("rm -rf " DBDIR); + + return r; } static void test_int(void) { - int archive_days = -1; - int archive_maxsize = -1; + int boundary_limit = -1; + int conversations_max_thread = -1; config_read_string( "configdirectory: "DBDIR"/conf\n" - "archive_days: 9\n" - /* archive_maxsize: 1024 (default) */ + "boundary_limit: 120\n" + /* conversations_max_thread: 100 (default) */ ); /* test a value that has been set */ - archive_days = config_getint(IMAPOPT_ARCHIVE_DAYS); - CU_ASSERT_EQUAL(archive_days, 9); + boundary_limit = config_getint(IMAPOPT_BOUNDARY_LIMIT); + CU_ASSERT_EQUAL(boundary_limit, 120); /* test a value that is defaulted */ - archive_maxsize = config_getint(IMAPOPT_ARCHIVE_MAXSIZE); - CU_ASSERT_EQUAL(archive_maxsize, 1024); + conversations_max_thread = config_getint(IMAPOPT_CONVERSATIONS_MAX_THREAD); + CU_ASSERT_EQUAL(conversations_max_thread, 100); } static void test_string(void) @@ -236,6 +232,29 @@ static void test_bitfield_value(void) IMAP_ENUM_HTTPMODULES_JMAP); } +static void test_bitfield_value_wide(void) +{ + unsigned long sieve_extensions = (unsigned long) -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "sieve_extensions: vnd.cyrus.implicit_keep_target\n" + ); + + /* vnd.cyrus.implicit_keep_target is notable for being bit 33. + * Let's make sure we aren't accidentally truncating bitfields to + * 32 bits... + */ + + /* but first let's make sure it is actually bit 33! */ + CU_ASSERT_EQUAL_FATAL(IMAP_ENUM_SIEVE_EXTENSIONS_VND_CYRUS_IMPLICIT_KEEP_TARGET, + (1LL << 33)); + + sieve_extensions = config_getbitfield(IMAPOPT_SIEVE_EXTENSIONS); + CU_ASSERT_EQUAL(sieve_extensions, + IMAP_ENUM_SIEVE_EXTENSIONS_VND_CYRUS_IMPLICIT_KEEP_TARGET); +} + static void test_bitfield_default(void) { unsigned long httpmodules = (unsigned long) -1; @@ -259,6 +278,551 @@ static void test_bitfield_invalid(void) CU_EXPECT_CYRFATAL_END(EX_CONFIG, "invalid value 'junk' for httpmodules in line 2"); } +static void test_duration_value_days(void) +{ + int timeout = -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "timeout: 3d\n" + ); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 's'); + CU_ASSERT_EQUAL(timeout, 3 * 24 * 60 * 60); +} + +static void test_duration_value_hours(void) +{ + int timeout = -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "timeout: 3h\n" + ); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 's'); + CU_ASSERT_EQUAL(timeout, 3 * 60 * 60); +} + +static void test_duration_value_minutes(void) +{ + int timeout = -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "timeout: 45m\n" + ); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 's'); + CU_ASSERT_EQUAL(timeout, 45 * 60); +} + +static void test_duration_value_seconds(void) +{ + int timeout = -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "timeout: 25s\n" + ); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 's'); + CU_ASSERT_EQUAL(timeout, 25); +} + +static void test_duration_value_combined(void) +{ + int timeout = -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "timeout: 1h30m\n" + ); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 's'); + CU_ASSERT_EQUAL(timeout, 1.5 * 60 * 60); +} + +static void test_duration_value_nounits(void) +{ + int timeout = -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "timeout: 13\n" + ); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 's'); + CU_ASSERT_EQUAL(timeout, 13); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 'm'); + CU_ASSERT_EQUAL(timeout, 13 * 60); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 'h'); + CU_ASSERT_EQUAL(timeout, 13 * 60 * 60); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 'd'); + CU_ASSERT_EQUAL(timeout, 13 * 60 * 60 * 24); +} + +static void test_duration_value_negative(void) +{ + int caldav_historical_age = 0xdeadbeef; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "caldav_historical_age: -1\n" + ); + + caldav_historical_age = config_getduration(IMAPOPT_CALDAV_HISTORICAL_AGE, 'd'); + CU_ASSERT_EQUAL(caldav_historical_age, -1 * 60 * 60 * 24); +} + +static void test_duration_default(void) +{ + int timeout = -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + /* timeout: 32m (default) */ + ); + + timeout = config_getduration(IMAPOPT_TIMEOUT, 's'); + CU_ASSERT_EQUAL(timeout, 32 * 60); +} + +static void test_duration_nodefault(void) +{ + int plaintextloginpause = -1; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + /* plaintextloginpause: (default) */ + ); + + plaintextloginpause = config_getduration(IMAPOPT_PLAINTEXTLOGINPAUSE, 's'); + CU_ASSERT_EQUAL(plaintextloginpause, 0); +} + +static void test_duration_invalid(void) +{ + CU_EXPECT_CYRFATAL_BEGIN + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "timeout: junk\n" + ); + CU_EXPECT_CYRFATAL_END(EX_CONFIG, "unparsable duration 'junk' for timeout in line 2"); +} + +struct duration_parse_data { + const char *str; + int expected_result; + int expected_duration; +}; + +static const struct duration_parse_data duration_parse_tests[] = { + { "", 0, 0 }, + { "0", 0, 0 }, + { "0s", 0, 0 }, + { "0m", 0, 0 }, + { "0h", 0, 0 }, + { "0d", 0, 0 }, + { "0h0m", 0, 0 }, + { "0h0m0s", 0, 0 }, + { "0h0m1s", 0, 1 }, + { "1", 0, 1 }, + { "1s", 0, 1 }, + { "1m", 0, 60 }, + { "1h", 0, 60 * 60 }, + { "1d", 0, 24 * 60 * 60 }, + { "123", 0, 123 }, + { "123s", 0, 123 }, + { "123m", 0, 123 * 60 }, + { "123h", 0, 123 * 60 * 60 }, + { "123d", 0, 123 * 24 * 60 * 60 }, + { "1m1", 0, 60 + 1 }, + { "1m1s", 0, 60 + 1 }, + { "1h1m", 0, (60 * 60) + 60 }, + { "1h1s", 0, (60 * 60) + 1 }, + { "1d1h", 0, (24 * 60 * 60) + (60 * 60) }, + { "1h1m1s", 0, (60 * 60) + 60 + 1 }, + { "1m1m", 0, 60 + 60 }, + { "123m456", 0, (123 * 60) + 456 }, + { "123m456s", 0, (123 * 60) + 456 }, + { "123h456m", 0, (123 * 60 * 60) + (456 * 60) }, + { "123h456s", 0, (123 * 60 * 60) + 456 }, + { "123d456h", 0, (123 * 24 * 60 * 60) + (456 * 60 * 60) }, + { "123h456m789s", 0, (123 * 60 * 60) + (456 * 60) + 789 }, + { "123m456m", 0, (123 * 60) + (456 * 60) }, + + { "0c", -1, 0xdeadbeef }, + { "1c", -1, 0xdeadbeef }, + { "123c", -1, 0xdeadbeef }, + { "1h1c", -1, 0xdeadbeef }, + { "1c23s", -1, 0xdeadbeef }, + + { "-1", 0, -1 }, + { "-0", 0, 0 }, + { "-", -1, 0xdeadbeef }, + { "-s", -1, 0xdeadbeef }, + { "--1", -1, 0xdeadbeef }, + { "-1s", 0, -1 }, + { "-1m", 0, -60 }, + { "1-2m", -1, 0xdeadbeef }, + { "1m-2s", -1, 0xdeadbeef }, + { "1m0-2s", -1, 0xdeadbeef }, + + { "1h1h", 0, 2 * 60 * 60 }, /* silly, but let it work */ + { "1hh", -1, 0xdeadbeef }, /* bogus, reject it */ + { "1hhh", -1, 0xdeadbeef }, /* bogus, reject it */ + + /* XXX config_parseduration uses int type and INT_MAX, but these limit + * XXX tests use hardcoded strings, which means they'll fail on a platform + * XXX where int is something other than 32 bits and has different limits. + * XXX I figure we can worry about that later, if/when it ever happens. + */ + /* exercise all the multipliers against overflow */ + { "2147483647s", 0, INT_MAX }, + { "2147483648s", -1, 0xdeadbeef }, + { "-2147483647s", 0, -INT_MAX }, + { "-2147483648s", -1, 0xdeadbeef }, + { "35791394m", 0, 60 * (INT_MAX / 60) }, + { "35791395m", -1, 0xdeadbeef }, + { "-35791394m", 0, -60 * (INT_MAX / 60) }, + { "-35791395m", -1, 0xdeadbeef }, + { "596523h", 0, 3600 * (INT_MAX / 3600) }, + { "596524h", -1, 0xdeadbeef }, + { "-596523h", 0, -3600 * (INT_MAX / 3600) }, + { "-596524h", -1, 0xdeadbeef }, + { "24855d", 0, 86400 * (INT_MAX / 86400) }, + { "24856d", -1, 0xdeadbeef }, + { "-24855d", 0, -86400 * (INT_MAX / 86400) }, + { "-24856d", -1, 0xdeadbeef }, + + { "24855d3h14m7s", 0, INT_MAX }, + { "24855d3h14m8s", -1, 0xdeadbeef }, +}; + +static void test_duration_parse(void) +{ + const size_t n = sizeof(duration_parse_tests) / sizeof(duration_parse_tests[0]); + size_t i; + + for (i = 0; i < n; i++) { + const struct duration_parse_data *test = &duration_parse_tests[i]; + int duration = 0xdeadbeef; + int r = config_parseduration(test->str, 's', &duration); + CU_ASSERT_EQUAL(r, test->expected_result); + CU_ASSERT_EQUAL(duration, test->expected_duration); + } +} + +static void test_bytesize_value_gibibytes(void) +{ + int64_t archive_maxsize = -1LL; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_maxsize: 1G\n" + ); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'K'); + CU_ASSERT_EQUAL(archive_maxsize, 1LL * 1024 * 1024 * 1024); +} + +static void test_bytesize_value_mebibytes(void) +{ + int64_t archive_maxsize = -1LL; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_maxsize: 3M\n" + ); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'K'); + CU_ASSERT_EQUAL(archive_maxsize, 3LL * 1024 * 1024); +} + +static void test_bytesize_value_kibibytes(void) +{ + int64_t archive_maxsize = -1LL; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_maxsize: 45K\n" + ); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'K'); + CU_ASSERT_EQUAL(archive_maxsize, 45LL * 1024); +} + +static void test_bytesize_value_bytes(void) +{ + int64_t archive_maxsize = -1LL; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_maxsize: 25B\n" + ); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'B'); + CU_ASSERT_EQUAL(archive_maxsize, 25LL); +} + +static void test_bytesize_value_nounits(void) +{ + int64_t archive_maxsize = -1LL; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_maxsize: 13\n" + ); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'B'); + CU_ASSERT_EQUAL(archive_maxsize, 13LL); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'K'); + CU_ASSERT_EQUAL(archive_maxsize, 13LL * 1024); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'M'); + CU_ASSERT_EQUAL(archive_maxsize, 13LL * 1024 * 1024); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'G'); + CU_ASSERT_EQUAL(archive_maxsize, 13LL * 1024 * 1024 * 1024); +} + +static void test_bytesize_value_negative(void) +{ + int64_t archive_maxsize = 0xdeadbeef; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_maxsize: -1\n" + ); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'B'); + CU_ASSERT_EQUAL(archive_maxsize, -1LL); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'K'); + CU_ASSERT_EQUAL(archive_maxsize, -1LL * 1024); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'M'); + CU_ASSERT_EQUAL(archive_maxsize, -1LL * 1024 * 1024); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'G'); + CU_ASSERT_EQUAL(archive_maxsize, -1LL * 1024 * 1024 * 1024); +} + +static void test_bytesize_default(void) +{ + int64_t archive_maxsize = -1LL; + + config_read_string( + "configdirectory: "DBDIR"/conf\n" + /* archive_maxsize: 1024 K (default) */ + ); + + archive_maxsize = config_getbytesize(IMAPOPT_ARCHIVE_MAXSIZE, 'K'); + CU_ASSERT_EQUAL(archive_maxsize, 1024LL * 1024); +} + +static void test_bytesize_invalid(void) +{ + CU_EXPECT_CYRFATAL_BEGIN + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_maxsize: junk\n" + ); + CU_EXPECT_CYRFATAL_END( + EX_CONFIG, + "unparsable byte size 'junk' for archive_maxsize in line 2" + ); +} + +struct bytesize_parse_data { + const char *str; + int expected_result; + int64_t expected_bytesize; +}; + +static const struct bytesize_parse_data bytesize_parse_tests[] = { + /* no digits */ + { "", -1, 0xdeadbeefLL }, + { "B", -1, 0xdeadbeefLL }, + { "KB", -1, 0xdeadbeefLL }, + { "KiB", -1, 0xdeadbeefLL }, + { "-B", -1, 0xdeadbeefLL }, + { "-KB", -1, 0xdeadbeefLL }, + { "-KiB", -1, 0xdeadbeefLL }, + + /* no suffix */ + { "0", 0, 0LL }, + { "1", 0, 1LL }, + { "9876", 0, 9876LL }, + { "-1234", 0, -1234LL }, + + /* bytes suffix */ + { "0b", 0, 0LL }, + { "0B", 0, 0LL }, + { "1b", 0, 1LL }, + { "1B", 0, 1LL }, + { "9876b", 0, 9876LL }, + { "9876B", 0, 9876LL }, + { "-1234b", 0, -1234LL }, + { "-1234B", 0, -1234LL }, + + /* no such thing as "ibibytes"! */ + { "0ib", -1, 0xdeadbeefLL }, + { "0iB", -1, 0xdeadbeefLL }, + { "1ib", -1, 0xdeadbeefLL }, + { "1iB", -1, 0xdeadbeefLL }, + { "9876ib", -1, 0xdeadbeefLL }, + { "9876iB", -1, 0xdeadbeefLL }, + { "-1234ib", -1, 0xdeadbeefLL }, + { "-1234iB", -1, 0xdeadbeefLL }, + + /* K suffix */ + { "0k", 0, 0LL * 1024 }, + { "0K", 0, 0LL * 1024 }, + { "1k", 0, 1LL * 1024 }, + { "1K", 0, 1LL * 1024 }, + { "9876k", 0, 9876LL * 1024 }, + { "9876K", 0, 9876LL * 1024 }, + { "-1234k", 0, -1234LL * 1024 }, + { "-1234K", 0, -1234LL * 1024 }, + + /* KB suffix */ + { "0kb", 0, 0LL * 1024 }, + { "0KB", 0, 0LL * 1024 }, + { "1kb", 0, 1LL * 1024 }, + { "1KB", 0, 1LL * 1024 }, + { "9876kb", 0, 9876LL * 1024 }, + { "9876KB", 0, 9876LL * 1024 }, + { "-1234kb", 0, -1234LL * 1024 }, + { "-1234KB", 0, -1234LL * 1024 }, + + /* KiB suffix */ + { "0kib", 0, 0LL * 1024 }, + { "0KiB", 0, 0LL * 1024 }, + { "1kib", 0, 1LL * 1024 }, + { "1KiB", 0, 1LL * 1024 }, + { "9876kib", 0, 9876LL * 1024 }, + { "9876KiB", 0, 9876LL * 1024 }, + { "-1234kib", 0, -1234LL * 1024 }, + { "-1234KiB", 0, -1234LL * 1024 }, + + /* M suffix */ + { "0m", 0, 0LL * 1024 * 1024 }, + { "0M", 0, 0LL * 1024 * 1024 }, + { "1m", 0, 1LL * 1024 * 1024 }, + { "1M", 0, 1LL * 1024 * 1024 }, + { "9876m", 0, 9876LL * 1024 * 1024 }, + { "9876M", 0, 9876LL * 1024 * 1024 }, + { "-1234m", 0, -1234LL * 1024 * 1024 }, + { "-1234M", 0, -1234LL * 1024 * 1024 }, + + /* MB suffix */ + { "0mb", 0, 0LL * 1024 * 1024 }, + { "0MB", 0, 0LL * 1024 * 1024 }, + { "1mb", 0, 1LL * 1024 * 1024 }, + { "1MB", 0, 1LL * 1024 * 1024 }, + { "9876mb", 0, 9876LL * 1024 * 1024 }, + { "9876MB", 0, 9876LL * 1024 * 1024 }, + { "-1234mb", 0, -1234LL * 1024 * 1024 }, + { "-1234MB", 0, -1234LL * 1024 * 1024 }, + + /* MiB suffix */ + { "0mib", 0, 0LL * 1024 * 1024 }, + { "0MiB", 0, 0LL * 1024 * 1024 }, + { "1mib", 0, 1LL * 1024 * 1024 }, + { "1MiB", 0, 1LL * 1024 * 1024 }, + { "9876mib", 0, 9876LL * 1024 * 1024 }, + { "9876MiB", 0, 9876LL * 1024 * 1024 }, + { "-1234mib", 0, -1234LL * 1024 * 1024 }, + { "-1234MiB", 0, -1234LL * 1024 * 1024 }, + + /* G suffix */ + { "0g", 0, 0LL * 1024 * 1024 * 1024 }, + { "0G", 0, 0LL * 1024 * 1024 * 1024 }, + { "1g", 0, 1LL * 1024 * 1024 * 1024 }, + { "1G", 0, 1LL * 1024 * 1024 * 1024 }, + { "9876g", 0, 9876LL * 1024 * 1024 * 1024 }, + { "9876G", 0, 9876LL * 1024 * 1024 * 1024 }, + { "-1234g", 0, -1234LL * 1024 * 1024 * 1024 }, + { "-1234G", 0, -1234LL * 1024 * 1024 * 1024 }, + + /* GB suffix */ + { "0gb", 0, 0LL * 1024 * 1024 * 1024 }, + { "0GB", 0, 0LL * 1024 * 1024 * 1024 }, + { "1gb", 0, 1LL * 1024 * 1024 * 1024 }, + { "1GB", 0, 1LL * 1024 * 1024 * 1024 }, + { "9876gb", 0, 9876LL * 1024 * 1024 * 1024 }, + { "9876GB", 0, 9876LL * 1024 * 1024 * 1024 }, + { "-1234gb", 0, -1234LL * 1024 * 1024 * 1024 }, + { "-1234GB", 0, -1234LL * 1024 * 1024 * 1024 }, + + /* GiB suffix */ + { "0gib", 0, 0LL * 1024 * 1024 * 1024 }, + { "0GiB", 0, 0LL * 1024 * 1024 * 1024 }, + { "1gib", 0, 1LL * 1024 * 1024 * 1024 }, + { "1GiB", 0, 1LL * 1024 * 1024 * 1024 }, + { "9876gib", 0, 9876LL * 1024 * 1024 * 1024 }, + { "9876GiB", 0, 9876LL * 1024 * 1024 * 1024 }, + { "-1234gib", 0, -1234LL * 1024 * 1024 * 1024 }, + { "-1234GiB", 0, -1234LL * 1024 * 1024 * 1024 }, + + /* trailing junk */ + { "23MB my friends", -1, 0xdeadbeefLL }, + + /* unrecognised multiplier */ + { "6TB", -1, 0xdeadbeefLL }, + { "6PB", -1, 0xdeadbeefLL }, + + /* i case insensitivity */ + { "25KiB", 0, 25LL * 1024 }, + { "25KIB", 0, 25LL * 1024 }, + + /* optional whitespace between number and multiplier */ + { "12 K", 0, 12LL * 1024 }, + { "12 KB", 0, 12LL * 1024 }, + { "12 KiB", 0, 12LL * 1024 }, + { "12 K", 0, 12LL * 1024 }, + { "12 KB", 0, 12LL * 1024 }, + { "12 KiB", 0, 12LL * 1024 }, + + /* overflow tests */ + { "9223372036854775807 B", 0, INT64_MAX }, + { "-9223372036854775808 B", 0, INT64_MIN }, + { "9223372036854775808 B", -1, 0xdeadbeefLL }, + { "-9223372036854775809 B", -1, 0xdeadbeefLL }, + { "9007199254740991 K", 0, 1024LL * (INT64_MAX / 1024LL) }, + { "-9007199254740992 K", 0, 1024LL * (INT64_MIN / 1024LL) }, + { "9007199254740992 K", -1, 0xdeadbeefLL }, + { "-9007199254740993 K", -1, 0xdeadbeefLL }, + { "8796093022207 M", 0, 1048576LL * (INT64_MAX / 1048576LL) }, + { "-8796093022208 M", 0, 1048576LL * (INT64_MIN / 1048576LL) }, + { "8796093022208 M", -1, 0xdeadbeefLL }, + { "-8796093022209 M", -1, 0xdeadbeefLL }, + { "8589934591 G", 0, 1073741824LL * (INT64_MAX / 1073741824LL) }, + { "-8589934592 G", 0, 1073741824LL * (INT64_MIN / 1073741824LL) }, + { "8589934592 G", -1, 0xdeadbeefLL }, + { "-8589934593 G", -1, 0xdeadbeefLL }, +}; + +static void test_bytesize_parse(void) +{ + const size_t n = sizeof(bytesize_parse_tests) / sizeof(bytesize_parse_tests[0]); + size_t i; + + for (i = 0; i < n; i++) { + const struct bytesize_parse_data *test = &bytesize_parse_tests[i]; + int64_t bytesize = 0xdeadbeefLL; + int r = config_parsebytesize(test->str, 'B', &bytesize); + CU_ASSERT_EQUAL(r, test->expected_result); + CU_ASSERT_EQUAL(bytesize, test->expected_bytesize); + } +} + static void test_magic_configdirectory_value(void) { const char *idlesocket = NULL; @@ -287,4 +851,265 @@ static void test_magic_configdirectory_default(void) CU_ASSERT_STRING_EQUAL(idlesocket, DBDIR"/conf/socket/idle"); } +static void test_deprecated_int(void) +{ + /* { "autocreatequotamsg", -1, INT, "2.5.0", "2.5.0", "autocreate_quota_messages" } */ + int val; + + /* set the deprecated name */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "autocreatequotamsg: 12\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should not be able to read the deprecated name */ + CU_EXPECT_CYRFATAL_BEGIN + val = config_getint(IMAPOPT_AUTOCREATEQUOTAMSG); + CU_EXPECT_CYRFATAL_END(EX_SOFTWARE, + "Option 'autocreatequotamsg' is deprecated in favor of " + "'autocreate_quota_messages' since version 2.5.0."); + + /* should be able to read the value at the new name */ + val = config_getint(IMAPOPT_AUTOCREATE_QUOTA_MESSAGES); + CU_ASSERT_EQUAL(val, 12); + + /* set the new name */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "autocreate_quota_messages: 12\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 0); + + /* should be able to read it at the new name */ + val = config_getint(IMAPOPT_AUTOCREATE_QUOTA_MESSAGES); + CU_ASSERT_EQUAL(val, 12); + + /* set both names to different values */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "autocreatequotamsg: 12\n" + "autocreate_quota_messages: 15\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should read new value at the new name */ + val = config_getint(IMAPOPT_AUTOCREATE_QUOTA_MESSAGES); + CU_ASSERT_EQUAL(val, 15); +} + +static void test_deprecated_string(void) +{ + /* { "tlscache_db_path", NULL, STRING, "2.5.0", "tls_sessions_db_path" } */ + const char *val; + + /* set the deprecated name */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "tlscache_db_path: foo\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should not be able to read the deprecated name */ + CU_EXPECT_CYRFATAL_BEGIN + val = config_getstring(IMAPOPT_TLSCACHE_DB_PATH); + CU_EXPECT_CYRFATAL_END(EX_SOFTWARE, + "Option 'tlscache_db_path' is deprecated in favor of " + "'tls_sessions_db_path' since version 2.5.0."); + + /* should be able to read the value at the new name */ + val = config_getstring(IMAPOPT_TLS_SESSIONS_DB_PATH); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val, "foo"); + + /* set the new name */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "tls_sessions_db_path: foo\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 0); + + /* should be able to read it at the new name */ + val = config_getstring(IMAPOPT_TLS_SESSIONS_DB_PATH); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val, "foo"); + + /* set both names to different values */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "tlscache_db_path: foo\n" + "tls_sessions_db_path: bar\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should read new value at the new name */ + val = config_getstring(IMAPOPT_TLS_SESSIONS_DB_PATH); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val, "bar"); +} + +static void test_deprecated_stringlist(void) +{ + /* { "tlscache_db", "twoskip", + * STRINGLIST("skiplist", "sql", "twoskip", "zeroskip"), + * "2.5.0", "tls_sessions_db" } + */ + const char *val; + + /* set the deprecated name */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "tlscache_db: sql\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should not be able to read the deprecated name */ + CU_EXPECT_CYRFATAL_BEGIN + val = config_getstring(IMAPOPT_TLSCACHE_DB); + CU_EXPECT_CYRFATAL_END(EX_SOFTWARE, + "Option 'tlscache_db' is deprecated in favor of " + "'tls_sessions_db' since version 2.5.0."); + + /* should be able to read it at the new name */ + val = config_getstring(IMAPOPT_TLS_SESSIONS_DB); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val, "sql"); + + /* set the new name */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "tls_sessions_db: sql\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 0); + + /* should be able to read it at the new name */ + val = config_getstring(IMAPOPT_TLS_SESSIONS_DB); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val, "sql"); + + /* set both names to different values */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "tlscache_db: sql\n" + "tls_sessions_db: zeroskip\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should read new value at the new name */ + val = config_getstring(IMAPOPT_TLS_SESSIONS_DB); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val, "zeroskip"); +} + +static void test_deprecated_duration(void) +{ + /* { "archive_days", "7d", DURATION, "3.1.8", "archive_after" } */ + /* { "archive_after", "7d", DURATION } */ + int val; + + /* set the deprecated name only */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_days: 8\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should not be able to read the deprecated name */ + CU_EXPECT_CYRFATAL_BEGIN; + val = config_getduration(IMAPOPT_ARCHIVE_DAYS, 'd'); + CU_EXPECT_CYRFATAL_END(EX_SOFTWARE, + "Option 'archive_days' is deprecated in favor of " + "'archive_after' since version 3.1.8."); + + /* should be able to read it at the new name */ + val = config_getduration(IMAPOPT_ARCHIVE_AFTER, 'd'); + CU_ASSERT_EQUAL(val, 8 * 24 * 60 * 60); + + /* set the new name */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_after: 12d\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 0); + + /* should be able to read it at the new name */ + val = config_getduration(IMAPOPT_ARCHIVE_AFTER, 'd'); + CU_ASSERT_EQUAL(val, 12 * 24 * 60 * 60); + + /* set both names to different values */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "archive_days: 7\n" + "archive_after: 12d\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should read new value at the new name */ + val = config_getduration(IMAPOPT_ARCHIVE_AFTER, 'd'); + CU_ASSERT_EQUAL(val, 12 * 24 * 60 * 60); +} + +static void test_deprecated_bytesize(void) +{ + /* { "autocreatequota", NULL, BYTESIZE, "UNRELEASED", "2.5.0", "autocreate_quota" } */ + /* { "autocreate_quota", "-1", BYTESIZE, "UNRELEASED" } */ + int64_t val; + + /* set the deprecated name only */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "autocreatequota: 8\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should not be able to read the deprecated name */ + CU_EXPECT_CYRFATAL_BEGIN; + val = config_getbytesize(IMAPOPT_AUTOCREATEQUOTA, 'K'); + CU_EXPECT_CYRFATAL_END(EX_SOFTWARE, + "Option 'autocreatequota' is deprecated in favor of " + "'autocreate_quota' since version 2.5.0."); + + /* should be able to read it at the new name */ + val = config_getbytesize(IMAPOPT_AUTOCREATE_QUOTA, 'K'); + CU_ASSERT_EQUAL(val, 8LL * 1024); + + /* set the new name */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "autocreate_quota: 12M\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 0); + + /* should be able to read it at the new name */ + val = config_getbytesize(IMAPOPT_AUTOCREATE_QUOTA, 'K'); + CU_ASSERT_EQUAL(val, 12LL * 1024 * 1024); + + /* set both names to different values */ + CU_SYSLOG_MATCH("Option '.*' is deprecated"); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "autocreatequota: 7\n" + "autocreate_quota: 12M\n" + ); + CU_ASSERT_SYSLOG(/*all*/0, 1); + + /* should read new value at the new name */ + val = config_getbytesize(IMAPOPT_AUTOCREATE_QUOTA, 'K'); + CU_ASSERT_EQUAL(val, 12LL * 1024 * 1024); +} + /* vim: set ft=c: */ diff --git a/cunit/mailbox.testc b/cunit/mailbox.testc new file mode 100644 index 0000000000..da84dbbff6 --- /dev/null +++ b/cunit/mailbox.testc @@ -0,0 +1,291 @@ +#if HAVE_CONFIG_H +#include +#endif +#if HAVE_STDALIGN_H +#include +#endif + +#include + +#include "cunit/cyrunit.h" +#include "imap/mailbox.h" + +/* XXX Time fields are time_t in memory, but are read from and written to + * XXX disk in mailbox.c as bit32! Don't fail for now, but we really ought + * XXX to move to 64 bit time fields... + * XXX Remove this kludge when the in memory and on disk types match. + */ +typedef bit32 XXX_TIME32_TYPE; + +/* XXX quota_annot_used is quota_t (int64_t) in memory, but bit32 on disk */ +typedef bit32 XXX_QUOTA32_TYPE; + +/* XXX record cache_offset is 64b in memory but 32b on disk, and cache_version + * XXX is 16b in memory but 32b on disk. + */ +typedef bit32 XXX_CACHE32_TYPE; + +extern int verbose; + +struct offset { + char *name; + unsigned pos; + size_t val; +}; + +#define OFFSET(name, val) { #name, name, val } + +static int offset_compar(const void *a, const void *b) +{ + const struct offset *aa = (const struct offset *) a; + const struct offset *bb = (const struct offset *) b; + + return (int) ((intmax_t) aa->pos - (intmax_t) bb->pos); +} + +static void test_aligned_header_offsets(void) +{ +#if !defined HAVE_STDALIGN_H + if (verbose) { + fputs("no C11 alignment macros, can't do anything useful\n", stderr); + } + return; /* can't do anything without C11 alignment macros */ +#elif !defined HAVE_GNU_ALIGNOF_EXPRESSION + if (verbose) { + fputs("no alignof(expression), can't do anything useful\n", stderr); + } + return; +#else + struct index_header h; + + /* The order of the offsets tends to change over time, but the test does + * not need to care about that. Instead, keep this list sorted + * alphabetically by the OFFSET_... name, for ease of maintenance. + */ + CU_ASSERT_EQUAL(0, OFFSET_ANSWERED % alignof(h.answered)); + CU_ASSERT_EQUAL(0, OFFSET_CHANGES_EPOCH % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_DELETED % alignof(h.deleted)); + CU_ASSERT_EQUAL(0, OFFSET_DELETEDMODSEQ % alignof(h.deletedmodseq)); + CU_ASSERT_EQUAL(0, OFFSET_EXISTS % alignof(h.exists)); + CU_ASSERT_EQUAL(0, OFFSET_FIRST_EXPUNGED % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_FLAGGED % alignof(h.flagged)); + CU_ASSERT_EQUAL(0, OFFSET_FORMAT % alignof(h.format)); + CU_ASSERT_EQUAL(0, OFFSET_GENERATION_NO % alignof(h.generation_no)); + CU_ASSERT_EQUAL(0, OFFSET_HEADER_CRC % alignof(uint32_t)); /* not in struct */ + CU_ASSERT_EQUAL(0, OFFSET_HEADER_FILE_CRC % alignof(h.header_file_crc)); + CU_ASSERT_EQUAL(0, OFFSET_HIGHESTMODSEQ % alignof(h.highestmodseq)); + CU_ASSERT_EQUAL(0, OFFSET_LAST_APPENDDATE % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_LAST_REPACK_TIME % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_LAST_UID % alignof(h.last_uid)); + CU_ASSERT_EQUAL(0, OFFSET_LEAKED_CACHE % alignof(h.leaked_cache_records)); + CU_ASSERT_EQUAL(0, OFFSET_MAILBOX_CREATEDMODSEQ % alignof(h.createdmodseq)); + CU_ASSERT_EQUAL(0, OFFSET_MAILBOX_OPTIONS % alignof(h.options)); + CU_ASSERT_EQUAL(0, OFFSET_MINOR_VERSION % alignof(h.minor_version)); + CU_ASSERT_EQUAL(0, OFFSET_NUM_RECORDS % alignof(h.num_records)); + CU_ASSERT_EQUAL(0, OFFSET_POP3_LAST_LOGIN % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_POP3_SHOW_AFTER % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_QUOTA_ANNOT_USED % alignof(XXX_QUOTA32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_QUOTA_DELETED_USED % alignof(h.quota_deleted_used)); + CU_ASSERT_EQUAL(0, OFFSET_QUOTA_EXPUNGED_USED % alignof(h.quota_expunged_used)); + CU_ASSERT_EQUAL(0, OFFSET_QUOTA_MAILBOX_USED % alignof(h.quota_mailbox_used)); + CU_ASSERT_EQUAL(0, OFFSET_RECENTTIME % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_RECENTUID % alignof(h.recentuid)); + CU_ASSERT_EQUAL(0, OFFSET_RECORD_SIZE % alignof(h.record_size)); + CU_ASSERT_EQUAL(0, OFFSET_START_OFFSET % alignof(h.start_offset)); + CU_ASSERT_EQUAL(0, OFFSET_SYNCCRCS_ANNOT % alignof(h.synccrcs.annot)); + CU_ASSERT_EQUAL(0, OFFSET_SYNCCRCS_BASIC % alignof(h.synccrcs.basic)); + CU_ASSERT_EQUAL(0, OFFSET_UIDVALIDITY % alignof(h.uidvalidity)); + CU_ASSERT_EQUAL(0, OFFSET_UNSEEN % alignof(h.unseen)); + /* this list is sorted alphabetically, don't just append */ +#endif +} + +static void test_aligned_record_offsets(void) +{ +#if !defined HAVE_STDALIGN_H + if (verbose) { + fputs("no C11 alignment macros, can't do anything useful\n", stderr); + } + return; /* can't do anything without C11 alignment macros */ +#elif !defined HAVE_GNU_ALIGNOF_EXPRESSION + if (verbose) { + fputs("no alignof(expression), can't do anything useful\n", stderr); + } + return; +#else + struct index_record r; + + /* The order of the offsets tends to change over time, but the test does + * not need to care about that. Instead, keep this list sorted + * alphabetically by the OFFSET_... name, for ease of maintenance. + */ + CU_ASSERT_EQUAL(0, OFFSET_CACHE_CRC % alignof(r.cache_crc)); + CU_ASSERT_EQUAL(0, OFFSET_CACHE_OFFSET % alignof(XXX_CACHE32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_CACHE_VERSION % alignof(XXX_CACHE32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_CREATEDMODSEQ % alignof(r.createdmodseq)); + CU_ASSERT_EQUAL(0, OFFSET_GMTIME % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_HEADER_SIZE % alignof(r.header_size)); + CU_ASSERT_EQUAL(0, OFFSET_INTERNALDATE % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_LAST_UPDATED % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_MESSAGE_GUID % alignof(char)); /* r/w uses memcpy */ + CU_ASSERT_EQUAL(0, OFFSET_MODSEQ % alignof(r.modseq)); + CU_ASSERT_EQUAL(0, OFFSET_RECORD_CRC % alignof(uint32_t)); /* not in struct */ + CU_ASSERT_EQUAL(0, OFFSET_SAVEDATE % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_SENTDATE % alignof(XXX_TIME32_TYPE)); + CU_ASSERT_EQUAL(0, OFFSET_SIZE % alignof(r.size)); + CU_ASSERT_EQUAL(0, OFFSET_SYSTEM_FLAGS % alignof(r.system_flags)); + CU_ASSERT_EQUAL(0, OFFSET_THRID % alignof(r.cid)); + CU_ASSERT_EQUAL(0, OFFSET_UID % alignof(r.uid)); + CU_ASSERT_EQUAL(0, OFFSET_USER_FLAGS % alignof(r.user_flags)); + /* this list is sorted alphabetically, don't just append */ +#endif +} + +static void test_header_size_multiple_of_modseq(void) +{ +#ifndef HAVE_STDALIGN_H + CU_ASSERT_EQUAL(0, INDEX_HEADER_SIZE % 8); +#else + CU_ASSERT_EQUAL(0, INDEX_HEADER_SIZE % alignof(modseq_t)); +#endif +} + +static void test_record_size_multiple_of_modseq(void) +{ +#ifndef HAVE_STDALIGN_H + CU_ASSERT_EQUAL(0, INDEX_RECORD_SIZE % 8); +#else + CU_ASSERT_EQUAL(0, INDEX_RECORD_SIZE % alignof(modseq_t)); +#endif +} + +/* the stock CU_FAIL() macro stringises its argument rather than using it */ +#define CU_FAIL_FMT(fmt, ...) do \ +{ \ + char failbuf[1024]; \ + snprintf(failbuf, sizeof(failbuf), fmt, __VA_ARGS__); \ + CU_assertImplementation(CU_FALSE, __LINE__, failbuf, \ + __FILE__, "", CU_FALSE); \ +} while (0) + +static void test_unique_header_offsets(void) +{ + struct index_header h; + struct offset offsets[] = { + /* Keep this sorted alphabetically by the OFFSET_... name, for ease of + * maintenance. We'll qsort it into the order the test needs shortly. + */ + OFFSET(OFFSET_ANSWERED, sizeof(h.answered)), + OFFSET(OFFSET_CHANGES_EPOCH, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_DELETED, sizeof(h.deleted)), + OFFSET(OFFSET_DELETEDMODSEQ, sizeof(h.deletedmodseq)), + OFFSET(OFFSET_EXISTS, sizeof(h.exists)), + OFFSET(OFFSET_FIRST_EXPUNGED, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_FLAGGED, sizeof(h.flagged)), + OFFSET(OFFSET_FORMAT, sizeof(h.format)), + OFFSET(OFFSET_GENERATION_NO, sizeof(h.generation_no)), + OFFSET(OFFSET_HEADER_CRC, sizeof(uint32_t) /* not in struct */), + OFFSET(OFFSET_HEADER_FILE_CRC, sizeof(h.header_file_crc)), + OFFSET(OFFSET_HIGHESTMODSEQ, sizeof(h.highestmodseq)), + OFFSET(OFFSET_LAST_APPENDDATE, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_LAST_REPACK_TIME, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_LAST_UID, sizeof(h.last_uid)), + OFFSET(OFFSET_LEAKED_CACHE, sizeof(h.leaked_cache_records)), + OFFSET(OFFSET_MAILBOX_CREATEDMODSEQ, sizeof(h.createdmodseq)), + OFFSET(OFFSET_MAILBOX_OPTIONS, sizeof(h.options)), + OFFSET(OFFSET_MINOR_VERSION, sizeof(h.minor_version)), + OFFSET(OFFSET_NUM_RECORDS, sizeof(h.num_records)), + OFFSET(OFFSET_POP3_LAST_LOGIN, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_POP3_SHOW_AFTER, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_QUOTA_ANNOT_USED, sizeof(XXX_QUOTA32_TYPE)), + OFFSET(OFFSET_QUOTA_DELETED_USED, sizeof(h.quota_deleted_used)), + OFFSET(OFFSET_QUOTA_EXPUNGED_USED, sizeof(h.quota_expunged_used)), + OFFSET(OFFSET_QUOTA_MAILBOX_USED, sizeof(h.quota_mailbox_used)), + OFFSET(OFFSET_RECENTTIME, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_RECENTUID, sizeof(h.recentuid)), + OFFSET(OFFSET_RECORD_SIZE, sizeof(h.record_size)), + OFFSET(OFFSET_START_OFFSET, sizeof(h.start_offset)), + OFFSET(OFFSET_SYNCCRCS_ANNOT, sizeof(h.synccrcs.annot)), + OFFSET(OFFSET_SYNCCRCS_BASIC, sizeof(h.synccrcs.basic)), + OFFSET(OFFSET_UIDVALIDITY, sizeof(h.uidvalidity)), + OFFSET(OFFSET_UNSEEN, sizeof(h.unseen)), + /* this list is sorted alphabetically, don't just append */ + }; + const size_t n_offsets = sizeof(offsets) / sizeof(offsets[0]); + unsigned i; + + qsort(offsets, n_offsets, sizeof(offsets[0]), offset_compar); + + for (i = 0; i < n_offsets - 1; i++) { + /* better not have the same offset */ + CU_ASSERT_NOT_EQUAL(offsets[i].pos, offsets[i + 1].pos); + + /* better not overlap the next one */ + if (offsets[i].pos + offsets[i].val > offsets[i + 1].pos) { + CU_FAIL_FMT("%s at %u length " SIZE_T_FMT " overlaps %s at %u", + offsets[i].name, offsets[i].pos, offsets[i].val, + offsets[i + 1]. name, offsets[i + 1].pos); + } + + /* not ideal to leave unnamed gaps either */ + if (offsets[i].pos + offsets[i].val < offsets[i + 1].pos) { + CU_FAIL_FMT("%s at %u length " SIZE_T_FMT " leaves gap before %s at %u", + offsets[i].name, offsets[i].pos, offsets[i].val, + offsets[i + 1]. name, offsets[i + 1].pos); + } + } +} + +static void test_unique_record_offsets(void) +{ + struct index_record r; + struct offset offsets[] = { + /* Keep this sorted alphabetically by the OFFSET_... name, for ease of + * maintenance. We'll qsort it into the order the test needs shortly. + */ + OFFSET(OFFSET_CACHE_CRC, sizeof(r.cache_crc)), + OFFSET(OFFSET_CACHE_OFFSET, sizeof(XXX_CACHE32_TYPE)), + OFFSET(OFFSET_CACHE_VERSION, sizeof(XXX_CACHE32_TYPE)), + OFFSET(OFFSET_CREATEDMODSEQ, sizeof(r.createdmodseq)), + OFFSET(OFFSET_GMTIME, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_HEADER_SIZE, sizeof(r.header_size)), + OFFSET(OFFSET_INTERNALDATE, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_LAST_UPDATED, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_MESSAGE_GUID, MESSAGE_GUID_SIZE), + OFFSET(OFFSET_MODSEQ, sizeof(r.modseq)), + OFFSET(OFFSET_RECORD_CRC, sizeof(uint32_t)), /* not in struct */ + OFFSET(OFFSET_SAVEDATE, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_SENTDATE, sizeof(XXX_TIME32_TYPE)), + OFFSET(OFFSET_SIZE, sizeof(r.size)), + OFFSET(OFFSET_SYSTEM_FLAGS, sizeof(r.system_flags)), + OFFSET(OFFSET_THRID, sizeof(r.cid)), + OFFSET(OFFSET_UID, sizeof(r.uid)), + OFFSET(OFFSET_USER_FLAGS, sizeof(r.user_flags)), + /* this list is sorted alphabetically, don't just append */ + }; + const size_t n_offsets = sizeof(offsets) / sizeof(offsets[0]); + unsigned i; + + qsort(offsets, n_offsets, sizeof(offsets[0]), offset_compar); + + for (i = 0; i < n_offsets - 1; i++) { + /* better not have the same offset */ + CU_ASSERT_NOT_EQUAL(offsets[i].pos, offsets[i + 1].pos); + + /* better not overlap the next one */ + if (offsets[i].pos + offsets[i].val > offsets[i + 1].pos) { + CU_FAIL_FMT("%s at %u length " SIZE_T_FMT " overlaps %s at %u", + offsets[i].name, offsets[i].pos, offsets[i].val, + offsets[i + 1]. name, offsets[i + 1].pos); + } + + /* not ideal to leave unnamed gaps either */ + if (offsets[i].pos + offsets[i].val < offsets[i + 1].pos) { + CU_FAIL_FMT("%s at %u length " SIZE_T_FMT " leaves gap before %s at %u", + offsets[i].name, offsets[i].pos, offsets[i].val, + offsets[i + 1]. name, offsets[i + 1].pos); + } + } +} + +/* vim: set ft=c: */ diff --git a/cunit/mboxname.testc b/cunit/mboxname.testc index 53d282e3e4..5311371489 100644 --- a/cunit/mboxname.testc +++ b/cunit/mboxname.testc @@ -3,11 +3,15 @@ #endif #include #include "cunit/cyrunit.h" -#include "libconfig.h" +#include "lib/libconfig.h" +#include "lib/libcyr_cfg.h" +#include "lib/xunlink.h" #include "imap/mboxname.h" #include "imap/mailbox.h" #include "imap/global.h" +#define DBDIR "test-dbdir" + static void test_dir_hash_c(void) { static const char FRED[] = "fred"; @@ -323,36 +327,37 @@ static struct int unixhierarchysep; } conf; -static void toexternal_helper(const char *intname, - const char *extname_expected) -{ - struct namespace ns; - int r; - - config_virtdomains = conf.virtdomains; - config_defdomain = conf.defdomain; - imapopts[IMAPOPT_UNIXHIERARCHYSEP].val.b = conf.unixhierarchysep; - imapopts[IMAPOPT_CROSSDOMAINS].val.b = conf.crossdomains; - imapopts[IMAPOPT_CROSSDOMAINS_ONLYOTHER].val.b = conf.cdother; - imapopts[IMAPOPT_ALTNAMESPACE].val.b = conf.altnamespace; - imapopts[IMAPOPT_USERPREFIX].val.s = conf.userprefix; - imapopts[IMAPOPT_SHAREDPREFIX].val.s = conf.sharedprefix; - - r = mboxname_init_namespace(&ns, conf.isadmin); - CU_ASSERT_EQUAL_FATAL(r, 0); - - if (intname) { - char *extname = mboxname_to_external(intname, &ns, conf.userid); - CU_ASSERT_STRING_EQUAL(extname, extname_expected); - free(extname); - } - - if (extname_expected) { - char *intname_reversed = mboxname_from_external(extname_expected, &ns, conf.userid); - CU_ASSERT_STRING_EQUAL(intname, intname_reversed); - free(intname_reversed); - } -} +#define toexternal_helper(i, e) do { \ + const char *intname = (i); \ + const char *extname_expected = (e); \ + struct namespace ns; \ + int r; \ + \ + config_virtdomains = conf.virtdomains; \ + config_defdomain = conf.defdomain; \ + imapopts[IMAPOPT_UNIXHIERARCHYSEP].val.b = conf.unixhierarchysep; \ + imapopts[IMAPOPT_CROSSDOMAINS].val.b = conf.crossdomains; \ + imapopts[IMAPOPT_CROSSDOMAINS_ONLYOTHER].val.b = conf.cdother; \ + imapopts[IMAPOPT_ALTNAMESPACE].val.b = conf.altnamespace; \ + imapopts[IMAPOPT_USERPREFIX].val.s = conf.userprefix; \ + imapopts[IMAPOPT_SHAREDPREFIX].val.s = conf.sharedprefix; \ + \ + r = mboxname_init_namespace(&ns, conf.isadmin); \ + CU_ASSERT_EQUAL_FATAL(r, 0); \ + \ + if (intname) { \ + char *extname = mboxname_to_external(intname, &ns, conf.userid); \ + CU_ASSERT_STRING_EQUAL(extname, extname_expected); \ + free(extname); \ + } \ + \ + if (extname_expected) { \ + char *intname_reversed = mboxname_from_external(extname_expected, \ + &ns, conf.userid);\ + CU_ASSERT_STRING_EQUAL(intname, intname_reversed); \ + free(intname_reversed); \ + } \ +} while(0) static void test_toexternal_admin(void) { @@ -793,7 +798,7 @@ static void test_nextmodseq(void) /* ensure there is no file */ mbname = mbname_from_intname(FREDNAME); fname = mboxname_conf_getpath(mbname, "modseq"); - unlink(fname); + xunlink(fname); free(fname); mbname_free(&mbname); @@ -832,6 +837,222 @@ static void test_common_ancestor(void) CU_ASSERT_PTR_NULL(ancestor); } +#define mboxname_isa_helper(isa, results) do { \ + int *r = results; \ + \ + imapopts[IMAPOPT_NOTESMAILBOX].val.s = "Notes"; \ + \ + CU_ASSERT_EQUAL(*r++, isa("user.foo", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.A", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.Notes", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#addressbooks", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#addressbooks", \ + MBTYPE_COLLECTION)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#addressbooks.A", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#addressbooks.A", \ + MBTYPE_ADDRESSBOOK)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#calendars", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#calendars", \ + MBTYPE_COLLECTION)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#calendars.A", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#calendars.A", \ + MBTYPE_CALENDAR)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#drive", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#drive", \ + MBTYPE_COLLECTION)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#drive.A", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#drive.A", \ + MBTYPE_COLLECTION)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#notifications", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#notifications", \ + MBTYPE_COLLECTION)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#jmap", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#jmap", \ + MBTYPE_COLLECTION)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#jmapnotification", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#jmapnotification", \ + MBTYPE_JMAPNOTIFY)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#jmappushsubscription", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#jmappushsubscription", \ + MBTYPE_JMAPPUSHSUB)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#jmapsubmission", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#jmapsubmission", \ + MBTYPE_JMAPSUBMIT)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#sieve", 0)); \ + CU_ASSERT_EQUAL(*r++, isa("user.foo.#sieve", \ + MBTYPE_SIEVE)); \ +} while(0) + +static void test_isnotesmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 1, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 0, 0, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_isnotesmailbox, results); +} + +static void test_iscalendarmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 1, 1, 1, 1, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 0, 0, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_iscalendarmailbox, results); +} + +static void test_isaddressbookmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 1, 1, 1, 1, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 0, 0, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_isaddressbookmailbox, results); +} + +static void test_isdavdrivemailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 1, 1, 1, 1, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 0, 0, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_isdavdrivemailbox, results); +} + +static void test_isdavnotificationsmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 1, 1, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 0, 0, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_isdavnotificationsmailbox, results); +} + +static void test_isjmapuploadmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 1, 1, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 0, 0, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_isjmapuploadmailbox, results); +} + +static void test_isjmapnotificationsmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 1, 1, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 0, 0, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_isjmapnotificationsmailbox, results); +} + +static void test_ispushsubscriptionmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 1, 1, /* JMAP Submissions */ 0, 0, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_ispushsubscriptionmailbox, results); +} + +static void test_issubmissionmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 1, 1, + /* Sieve */ 0, 0 + }; + + mboxname_isa_helper(mboxname_issubmissionmailbox, results); +} + +static void test_issievemailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 0, 0, 0, 0, /* Calendar */ 0, 0, 0, 0, + /* DAV drive */ 0, 0, 0, 0, /* DAV Notifications */ 0, 0, + /* JMAP Upload */ 0, 0, /* JMAP Notifications */ 0, 0, + /* JMAP Push Sub */ 0, 0, /* JMAP Submissions */ 0, 0, + /* Sieve */ 1, 1 + }; + + mboxname_isa_helper(mboxname_issievemailbox, results); +} + +static void test_isnonimapmailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 0, + /* Addressbook */ 1, 1, 1, 1, /* Calendar */ 1, 1, 1, 1, + /* DAV drive */ 1, 1, 1, 1, /* DAV Notifications */ 1, 1, + /* JMAP Upload */ 1, 1, /* JMAP Notifications */ 1, 1, + /* JMAP Push Sub */ 1, 1, /* JMAP Submissions */ 1, 1, + /* Sieve */ 1, 1 + }; + + mboxname_isa_helper(mboxname_isnonimapmailbox, results); +} + +static void test_isnondeliverymailbox(void) +{ + int results[] = { + /* Email */ 0, 0, /* Notes */ 1, + /* Addressbook */ 1, 1, 1, 1, /* Calendar */ 1, 1, 1, 1, + /* DAV drive */ 1, 1, 1, 1, /* DAV Notifications */ 1, 1, + /* JMAP Upload */ 1, 1, /* JMAP Notifications */ 1, 1, + /* JMAP Push Sub */ 1, 1, /* JMAP Submissions */ 1, 1, + /* Sieve */ 1, 1 + }; + + mboxname_isa_helper(mboxname_isnondeliverymailbox, results); + + CU_ASSERT_EQUAL(1, mboxname_isdeletedmailbox("DELETED.user.foo.A", NULL)); +} + static enum enum_value old_config_virtdomains; @@ -841,12 +1062,14 @@ static union config_value old_config_userprefix; static union config_value old_config_sharedprefix; static union config_value old_config_conversations; static const char *old_config_defdomain; -static char *old_config_dir; static int set_up(void) { - char cwd[PATH_MAX]; - char *s; + /* need basic configuration */ + libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + ); /* * TODO: this is pretty hacky. There should be some @@ -857,11 +1080,6 @@ static int set_up(void) old_config_virtdomains = config_virtdomains; config_virtdomains = IMAP_ENUM_VIRTDOMAINS_ON; - old_config_dir = (char *)config_dir; - s = getcwd(cwd, sizeof(cwd)); - assert(s); - config_dir = strconcat(cwd, "/conf.d", (char *)NULL); - old_config_defdomain = config_defdomain; old_config_unixhierarchysep = imapopts[IMAPOPT_UNIXHIERARCHYSEP].val; @@ -875,25 +1093,13 @@ static int set_up(void) static int tear_down(void) { - char *cmd; int r; - cmd = strconcat("rm -rf \"", config_dir, "\"", (char *)NULL); - r = system(cmd); - assert(!r); - free(cmd); - free((char *)config_dir); - config_dir = old_config_dir; - - config_virtdomains = old_config_virtdomains; - config_defdomain = old_config_defdomain; - imapopts[IMAPOPT_UNIXHIERARCHYSEP].val = old_config_unixhierarchysep; - imapopts[IMAPOPT_ALTNAMESPACE].val = old_config_altnamespace; - imapopts[IMAPOPT_USERPREFIX].val = old_config_userprefix; - imapopts[IMAPOPT_SHAREDPREFIX].val = old_config_sharedprefix; - imapopts[IMAPOPT_CONVERSATIONS].val = old_config_conversations; + config_reset(); - return 0; + r = system("rm -rf " DBDIR); + + return r; } /* vim: set ft=c: */ diff --git a/cunit/message.testc b/cunit/message.testc index 8e47ce10cb..e9fe735231 100644 --- a/cunit/message.testc +++ b/cunit/message.testc @@ -4,9 +4,35 @@ #include "cunit/cyrunit.h" #include "parseaddr.h" #include "util.h" +#include "lib/libconfig.h" +#include "lib/libcyr_cfg.h" #include "imap/mailbox.h" #include "imap/message.h" +#define DBDIR "test-dbdir" + +static int set_up(void) +{ + /* need basic configuration for message_parse_headers */ + libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + ); + + return 0; +} + +static int tear_down(void) +{ + int r; + + config_reset(); + + r = system("rm -rf " DBDIR); + + return r; +} + static void test_parse_trivial(void) { static const char msg[] = @@ -22,7 +48,7 @@ static void test_parse_trivial(void) struct body body; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -77,7 +103,7 @@ static void test_parse_trivial(void) /* check cacheheaders */ CU_ASSERT_PTR_NOT_NULL_FATAL(body.cacheheaders.s); - CU_ASSERT(strstr(body.cacheheaders.s, "Norman") != NULL); + CU_ASSERT(strstr(buf_cstring(&body.cacheheaders), "Norman") != NULL); message_free_body(&body); } @@ -106,7 +132,7 @@ static void test_parse_simple(void) struct body body; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -182,7 +208,7 @@ static void test_parse_simple(void) /* check cacheheaders */ CU_ASSERT_PTR_NOT_NULL_FATAL(body.cacheheaders.s); - CU_ASSERT(strstr(body.cacheheaders.s, "Norman") != NULL); + CU_ASSERT(strstr(buf_cstring(&body.cacheheaders), "Norman") != NULL); message_free_body(&body); } @@ -277,41 +303,46 @@ static void test_parse_rxdate(void) /* Neither: no received_date */ memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg_neither, sizeof(msg_neither)-1, &body); + r = message_parse_mapped(msg_neither, sizeof(msg_neither)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_PTR_NULL(body.received_date); + CU_ASSERT_PTR_NULL(body.x_deliveredinternaldate); message_free_body(&body); /* Received only: first seen Received */ memset(&body, 0x45, sizeof(body)); r = message_parse_mapped(msg_only_received, - sizeof(msg_only_received)-1, &body); + sizeof(msg_only_received)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); CU_ASSERT_STRING_EQUAL(body.received_date, FIRST_RX); + CU_ASSERT_PTR_NULL(body.x_deliveredinternaldate); message_free_body(&body); /* X-DeliveredInternalDate only: use that */ memset(&body, 0x45, sizeof(body)); r = message_parse_mapped(msg_only_xdid, - sizeof(msg_only_xdid)-1, &body); + sizeof(msg_only_xdid)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); - CU_ASSERT_STRING_EQUAL(body.received_date, DELIVERED); + CU_ASSERT_PTR_NULL(body.received_date); + CU_ASSERT_STRING_EQUAL(body.x_deliveredinternaldate, DELIVERED); message_free_body(&body); /* both, Received first: use X-DeliveredInternalDate */ memset(&body, 0x45, sizeof(body)); r = message_parse_mapped(msg_received_then_xdid, - sizeof(msg_received_then_xdid)-1, &body); + sizeof(msg_received_then_xdid)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); - CU_ASSERT_STRING_EQUAL(body.received_date, DELIVERED); + CU_ASSERT_STRING_EQUAL(body.received_date, FIRST_RX); + CU_ASSERT_STRING_EQUAL(body.x_deliveredinternaldate, DELIVERED); message_free_body(&body); /* both, X-DeliveredInternalDate first: use X-DeliveredInternalDate */ memset(&body, 0x45, sizeof(body)); r = message_parse_mapped(msg_xdid_then_received, - sizeof(msg_xdid_then_received)-1, &body); + sizeof(msg_xdid_then_received)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); - CU_ASSERT_STRING_EQUAL(body.received_date, DELIVERED); + CU_ASSERT_STRING_EQUAL(body.received_date, FIRST_RX); + CU_ASSERT_STRING_EQUAL(body.x_deliveredinternaldate, DELIVERED); message_free_body(&body); } @@ -338,7 +369,7 @@ static void test_mime_trivial(void) struct body body; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -351,7 +382,7 @@ static void test_mime_trivial(void) CU_ASSERT_PTR_NULL(body.params->next); /* - * RFC2046 says that all headers and in particular the Content-Type: + * RFC 2046 says that all headers and in particular the Content-Type: * header may be missing in an entity, and if so the default * Content-Type is text/plain;charset="us-ascii" */ @@ -368,7 +399,67 @@ static void test_mime_trivial(void) /* check cacheheaders */ CU_ASSERT_PTR_NOT_NULL_FATAL(body.cacheheaders.s); - CU_ASSERT(strstr(body.cacheheaders.s, "Norman") != NULL); + CU_ASSERT(strstr(buf_cstring(&body.cacheheaders), "Norman") != NULL); + CU_ASSERT_PTR_NULL(body.subpart[0].cacheheaders.s); + + message_free_body(&body); +} + +static void test_mime_boundary_extended(void) +{ + static const char msg[] = +"From: Fred Bloggs \r\n" +"Reply-To: \r\n" +"To: Sarah Jane Smith \r\n" +"Date: Thu, 28 Oct 2010 18:37:26 +1100\r\n" +"Subject: MIME testing email\r\n" +"X-Mailer: Norman\r\n" +"MIME-Version: 1.0\r\n" +"Content-Type: multipart/mixed; boundary*0*=us-ascii''%32b47bc7b64285b8be25d;" +" boundary*1=cdca86fbc;" +" boundary*2*=501b048eab%31\r\n" +"Content-Language: en\r\n" +"Message-ID: \r\n" +"\r\n" +"--2b47bc7b64285b8be25dcdca86fbc501b048eab1\r\n" +"\r\n" +"Hello, World\n" +"\r\n--2b47bc7b64285b8be25dcdca86fbc501b048eab1--\r\n"; + int r; + struct body body; + + memset(&body, 0x45, sizeof(body)); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); + + CU_ASSERT_EQUAL(r, 0); + + /* Content-Type: */ + CU_ASSERT_STRING_EQUAL(body.type, "MULTIPART"); + CU_ASSERT_STRING_EQUAL(body.subtype, "MIXED"); + CU_ASSERT_PTR_NOT_NULL(body.params); + CU_ASSERT_STRING_EQUAL(body.params->attribute, "BOUNDARY*"); + CU_ASSERT_STRING_EQUAL(body.params->value, "us-ascii''%32b47bc7b64285b8be25dcdca86fbc501b048eab%31"); + CU_ASSERT_PTR_NULL(body.params->next); + + /* + * RFC 2046 says that all headers and in particular the Content-Type: + * header may be missing in an entity, and if so the default + * Content-Type is text/plain;charset="us-ascii" + */ + + /* simple body */ + CU_ASSERT_EQUAL(body.numparts, 1); + CU_ASSERT_PTR_NOT_NULL(body.subpart); + CU_ASSERT_STRING_EQUAL(body.subpart[0].type, "TEXT"); + CU_ASSERT_STRING_EQUAL(body.subpart[0].subtype, "PLAIN"); + CU_ASSERT_PTR_NOT_NULL(body.subpart[0].params); + CU_ASSERT_STRING_EQUAL(body.subpart[0].params->attribute, "CHARSET"); + CU_ASSERT_STRING_EQUAL(body.subpart[0].params->value, "us-ascii"); + CU_ASSERT_PTR_NULL(body.subpart[0].params->next); + + /* check cacheheaders */ + CU_ASSERT_PTR_NOT_NULL_FATAL(body.cacheheaders.s); + CU_ASSERT(strstr(buf_cstring(&body.cacheheaders), "Norman") != NULL); CU_ASSERT_PTR_NULL(body.subpart[0].cacheheaders.s); message_free_body(&body); @@ -425,12 +516,12 @@ HTML_PART "\r\n" int r; struct body body; struct body *part; - struct message_content mcontent; + struct message_content mcontent = MESSAGE_CONTENT_INITIALIZER; const char *types[2] = { NULL, NULL }; struct bodypart **parts = NULL; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -475,8 +566,7 @@ HTML_PART "\r\n" CU_ASSERT_STRING_EQUAL(part->disposition, "ATTACHMENT"); CU_ASSERT_STRING_EQUAL(part->encoding, "BASE64"); - mcontent.base = msg; - mcontent.len = sizeof(msg)-1; + buf_init_ro(&mcontent.map, msg, sizeof(msg)-1); mcontent.body = &body; types[0] = "TEXT/PLAIN"; @@ -507,7 +597,7 @@ HTML_PART "\r\n" /* check cacheheaders */ CU_ASSERT_PTR_NOT_NULL_FATAL(body.cacheheaders.s); - CU_ASSERT(strstr(body.cacheheaders.s, "Norman") != NULL); + CU_ASSERT(strstr(buf_cstring(&body.cacheheaders), "Norman") != NULL); CU_ASSERT_PTR_NULL(body.subpart[0].cacheheaders.s); CU_ASSERT_PTR_NULL(body.subpart[1].cacheheaders.s); CU_ASSERT_PTR_NULL(body.subpart[2].cacheheaders.s); @@ -518,7 +608,7 @@ HTML_PART "\r\n" } /* - * RFC2231 specifies, amongst other things, a method for + * RFC 2231 specifies, amongst other things, a method for * breaking up across multiple lines, long parameter values * which cannot have whitespace inserted into them. */ @@ -528,7 +618,7 @@ static void test_rfc2231_continuations(void) "From: Fred Bloggs \r\n" "To: Sarah Jane Smith \r\n" "Date: Wed, 27 Oct 2010 18:37:26 +1100\r\n" -/* This example based on one in RFC2231 */ +/* This example based on one in RFC 2231 */ "Content-Type: message/external-body; access-type=URL;\r\n" "\tURL*0=\"ftp://\";\r\n" "\tURL*1=\"cs.utk.edu/pub/moore/\";\r\n" @@ -542,7 +632,7 @@ static void test_rfc2231_continuations(void) struct body body; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -566,7 +656,7 @@ static void test_rfc2231_continuations(void) } /* - * RFC2231 has a second syntax for continuations, which + * RFC 2231 has a second syntax for continuations, which * indicates the language & charset info may be encoded * in the value and allows for %xx encoded chars. */ @@ -576,7 +666,7 @@ static void test_rfc2231_extended_continuations(void) "From: Fred Bloggs \r\n" "To: Sarah Jane Smith \r\n" "Date: Wed, 27 Oct 2010 18:37:26 +1100\r\n" -/* This example also loosely based on one in RFC2231 */ +/* This example also loosely based on one in RFC 2231 */ "Content-Type: application/x-stuff;\r\n" "\ttitle*0*=us-ascii'en'This%20is%20even%20more%20;\r\n" "\ttitle*1*=%2A%2A%2Afun%2A%2A%2A%20;\r\n" @@ -591,7 +681,7 @@ static void test_rfc2231_extended_continuations(void) struct body body; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -629,7 +719,7 @@ static void test_references(void) struct body body; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -662,7 +752,7 @@ static void test_x_me_message_id(void) struct body body; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -732,12 +822,12 @@ HTML_PART "\r\n" int r; struct body body; struct body *part; - struct message_content mcontent; + struct message_content mcontent = MESSAGE_CONTENT_INITIALIZER; const char *types[2] = { NULL, NULL }; struct bodypart **parts = NULL; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -782,8 +872,7 @@ HTML_PART "\r\n" CU_ASSERT_STRING_EQUAL(part->disposition, "ATTACHMENT"); CU_ASSERT_STRING_EQUAL(part->encoding, "BASE64"); - mcontent.base = msg; - mcontent.len = sizeof(msg)-1; + buf_init_ro(&mcontent.map, msg, sizeof(msg)-1); mcontent.body = &body; types[0] = "TEXT/PLAIN"; @@ -814,7 +903,7 @@ HTML_PART "\r\n" /* check cacheheaders */ CU_ASSERT_PTR_NOT_NULL_FATAL(body.cacheheaders.s); - CU_ASSERT(strstr(body.cacheheaders.s, "Norman") != NULL); + CU_ASSERT(strstr(buf_cstring(&body.cacheheaders), "Norman") != NULL); CU_ASSERT_PTR_NULL(body.subpart[0].cacheheaders.s); CU_ASSERT_PTR_NULL(body.subpart[1].cacheheaders.s); CU_ASSERT_PTR_NULL(body.subpart[2].cacheheaders.s); @@ -840,7 +929,7 @@ static void test_parameter_recovery(void) "Subject: MIME testing email\r\n" "X-Mailer: Norman\r\n" "MIME-Version: 1.0\r\n" -/* The value of the "type" parameter contains an / but is +/* The value of the "type" parameter contains a / but is * not quoted, which is illegal under RFC2045 rules. We should * be either accepting it, or ignoring it, but in either case * the "boundary" that follows it should be accepted and used @@ -876,7 +965,7 @@ HTML_PART "\r\n" struct body *part; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -912,6 +1001,7 @@ HTML_PART "\r\n" CU_ASSERT_PTR_NULL(part->params); CU_ASSERT_STRING_EQUAL(part->encoding, "BASE64"); + message_free_body(&body); #undef HTML_PART } @@ -1091,7 +1181,7 @@ static void test_cache_simple(void) struct body *body = xzmalloc(sizeof(struct body)); memset(body, 0x45, sizeof(struct body)); - r = message_parse_mapped(msg, sizeof(msg)-1, body); + r = message_parse_mapped(msg, sizeof(msg)-1, body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -1100,6 +1190,7 @@ static void test_cache_simple(void) CU_ASSERT_EQUAL(r, 0); message_free_body(body); + free(body); message_read_bodystructure(&record, &body); CU_ASSERT_PTR_NOT_NULL_FATAL(body); @@ -1129,7 +1220,7 @@ static void test_cache_appleheaders(void) struct body body; memset(&body, 0x45, sizeof(body)); - r = message_parse_mapped(msg, sizeof(msg)-1, &body); + r = message_parse_mapped(msg, sizeof(msg)-1, &body, NULL); CU_ASSERT_EQUAL(r, 0); @@ -1190,21 +1281,142 @@ static void test_cache_appleheaders(void) /* check cacheheaders */ CU_ASSERT_PTR_NOT_NULL_FATAL(body.cacheheaders.s); - CU_ASSERT_PTR_NOT_NULL(strstr(body.cacheheaders.s, "X-Universally-Unique-Identifier")); - CU_ASSERT_PTR_NOT_NULL(strstr(body.cacheheaders.s, "fake-uuid")); - CU_ASSERT_PTR_NOT_NULL(strstr(body.cacheheaders.s, "X-Uniform-Type-Identifier")); - CU_ASSERT_PTR_NOT_NULL(strstr(body.cacheheaders.s, "fake-uti")); - CU_ASSERT_PTR_NOT_NULL(strstr(body.cacheheaders.s, "X-Apple-Base-Url")); - CU_ASSERT_PTR_NOT_NULL(strstr(body.cacheheaders.s, "fake-baseurl")); - CU_ASSERT_PTR_NOT_NULL(strstr(body.cacheheaders.s, "X-Apple-Mail-Remote-Attachments")); - CU_ASSERT_PTR_NOT_NULL(strstr(body.cacheheaders.s, "fake-remoteattach")); + CU_ASSERT_PTR_NOT_NULL(strstr(buf_cstring(&body.cacheheaders), "X-Universally-Unique-Identifier")); + CU_ASSERT_PTR_NOT_NULL(strstr(buf_cstring(&body.cacheheaders), "fake-uuid")); + CU_ASSERT_PTR_NOT_NULL(strstr(buf_cstring(&body.cacheheaders), "X-Uniform-Type-Identifier")); + CU_ASSERT_PTR_NOT_NULL(strstr(buf_cstring(&body.cacheheaders), "fake-uti")); + CU_ASSERT_PTR_NOT_NULL(strstr(buf_cstring(&body.cacheheaders), "X-Apple-Base-Url")); + CU_ASSERT_PTR_NOT_NULL(strstr(buf_cstring(&body.cacheheaders), "fake-baseurl")); + CU_ASSERT_PTR_NOT_NULL(strstr(buf_cstring(&body.cacheheaders), "X-Apple-Mail-Remote-Attachments")); + CU_ASSERT_PTR_NOT_NULL(strstr(buf_cstring(&body.cacheheaders), "fake-remoteattach")); /* make sure we're not caching every x- header, just the ones we like */ CU_ASSERT_EQUAL(mailbox_cached_header("X-Dont-Cache-This"), BIT32_MAX); - CU_ASSERT_PTR_NULL(strstr(body.cacheheaders.s, "X-Dont-Cache-This")); - CU_ASSERT_PTR_NULL(strstr(body.cacheheaders.s, "dont-cache-this")); + CU_ASSERT_PTR_NULL(strstr(buf_cstring(&body.cacheheaders), "X-Dont-Cache-This")); + CU_ASSERT_PTR_NULL(strstr(buf_cstring(&body.cacheheaders), "dont-cache-this")); message_free_body(&body); } +static void test_cache_quotedaddr(void) +{ + static const char msg[] = +"From: \"Fred (Bloggs)\" \r\n" +"To: Sarah Smith \r\n" +"Cc: \"Al \\\"Scarface\\\" Capone\" \r\n" +"Bcc: =?ISO-8859-1?q?Caf=E9_del_Mar?= \r\n" +"Date: Wed, 27 Oct 2010 18:37:26 +1100\r\n" +"Subject: Trivial testing email\r\n" +"Message-ID: \r\n" +"Content-Type: text/plain\r\n" +"X-Mailer: Norman\r\n" +"\r\n" +"Hello, World\n"; + int r; + struct body *body = xzmalloc(sizeof(struct body)); + + memset(body, 0x45, sizeof(struct body)); + r = message_parse_mapped(msg, sizeof(msg)-1, body, NULL); + + CU_ASSERT_EQUAL(r, 0); + + struct index_record record; + r = message_write_cache(&record, body); + CU_ASSERT_EQUAL(r, 0); + + message_free_body(body); + free(body); + + struct buf buf = BUF_INITIALIZER; + + /* Needs quotes */ + struct cacheitem item = record.crec.item[CACHE_FROM]; + buf_setmap(&buf, record.crec.buf->s + item.offset, item.len); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), "\"Fred (Bloggs)\" "); + + item = record.crec.item[CACHE_CC]; + buf_setmap(&buf, record.crec.buf->s + item.offset, item.len); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), "\"Al \\\"Scarface\\\" Capone\" "); + + /* Needs quotes, with UTF-8 */ + item = record.crec.item[CACHE_BCC]; + buf_setmap(&buf, record.crec.buf->s + item.offset, item.len); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), "\"Café del Mar\" "); + struct address *addr = NULL; + parseaddr_list(buf_cstring(&buf), &addr); + CU_ASSERT_STRING_EQUAL(addr->name, "Café del Mar"); + parseaddr_free(addr); + + /* Does not need quotes */ + item = record.crec.item[CACHE_TO]; + buf_setmap(&buf, record.crec.buf->s + item.offset, item.len); + CU_ASSERT_STRING_EQUAL(buf_cstring(&buf), "Sarah Smith "); + + buf_free(&buf); +} + +static void test_parse_bogus_charset_params(void) +{ +#define TESTCASE(charsetparams, want) \ + { \ + static const char msg[] = \ + "From: Fred Bloggs \r\n" \ + "To: Sarah Jane Smith \r\n" \ + "Date: Wed, 27 Oct 2010 18:37:26 +1100\r\n" \ + "Subject: Trivial testing email\r\n" \ + "Message-ID: \r\n" \ + "Content-Type: text/plain; " charsetparams "\r\n" \ + "X-Mailer: Norman\r\n" \ + "\r\n" \ + "Hello, World\n"; \ + int r; \ + struct body *body = xzmalloc(sizeof(struct body)); \ + memset(body, 0x45, sizeof(struct body)); \ + r = message_parse_mapped(msg, sizeof(msg)-1, body, NULL); \ + CU_ASSERT_EQUAL(r, 0); \ + if (want) \ + CU_ASSERT_STRING_EQUAL(body->charset_id, want); \ + else \ + CU_ASSERT_PTR_NULL(body->charset_id); \ + message_free_body(body); \ + free(body); \ + } + + TESTCASE("charset=text/plain; charset=iso-8859-1", "iso-8859-1"); + TESTCASE("charset=us-ascii; charset=iso-8859-1", "iso-8859-1"); + TESTCASE("charset=iso-8859-1; charset=text/plain", "iso-8859-1"); + TESTCASE("charset=iso-8859-1; charset=us-ascii", "us-ascii"); + TESTCASE("charset=text/plain; charset=text/html", "us-ascii"); + +#undef TESTCASE +} + +/* + * Verifies that message_parse_received_date() does not + * read any but the first Received header for invalid values. + */ +static void test_parse_received_semicolon(void) +{ + static const char msg_emptydate[] = + "Received: abc;\r\n" + "Received: from foo by bar; Fri, 29 Oct 2010 13:05:01 +1100\r\n" + "\r\n"; + struct body body; + memset(&body, 0x45, sizeof(body)); + CU_ASSERT_EQUAL(message_parse_mapped(msg_emptydate, + sizeof(msg_emptydate)-1, &body, NULL), 0); + CU_ASSERT_STRING_EQUAL(body.received_date, ""); + message_free_body(&body); + + static const char msg_nodate[] = + "Received: abc\r\n" + "Received: from foo by bar; Fri, 29 Oct 2010 13:05:01 +1100\r\n" + "\r\n"; + memset(&body, 0x45, sizeof(body)); + CU_ASSERT_EQUAL(message_parse_mapped(msg_nodate, + sizeof(msg_nodate)-1, &body, NULL), 0); + CU_ASSERT_STRING_EQUAL(body.received_date, "abc"); + message_free_body(&body); + +} /* vim: set ft=c: */ diff --git a/cunit/message_guid.testc b/cunit/message_guid.testc new file mode 100644 index 0000000000..5ccffda5f2 --- /dev/null +++ b/cunit/message_guid.testc @@ -0,0 +1,13 @@ +#include "cunit/cyrunit.h" +#include "imap/message_guid.h" + +static void test_clone(void) +{ + struct message_guid guid_a = MESSAGE_GUID_INITIALIZER; + + message_guid_generate(&guid_a, "foobar", 6); + struct message_guid guid_b = message_guid_clone(&guid_a); + + CU_ASSERT_EQUAL(0, memcmp(guid_a.value, guid_b.value, MESSAGE_GUID_SIZE)); + CU_ASSERT_EQUAL(guid_a.status, guid_b.status); +} diff --git a/cunit/msgid.testc b/cunit/message_iter_msgid.testc similarity index 87% rename from cunit/msgid.testc rename to cunit/message_iter_msgid.testc index 7cb1d737f4..9983170f2d 100644 --- a/cunit/msgid.testc +++ b/cunit/message_iter_msgid.testc @@ -18,7 +18,7 @@ static void test_simple(void) /* first call returns a newly allocated string which is the * only msgid in the input */ - m = find_msgid(s, &s); + m = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m); CU_ASSERT_PTR_NOT_EQUAL(m, s); CU_ASSERT_PTR_NOT_EQUAL(m, buf); @@ -33,7 +33,7 @@ static void test_simple(void) free(m); /* second call returns NULL, there are no more msgids */ - m = find_msgid(s, &s); + m = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m); free(buf); @@ -57,34 +57,34 @@ static void test_multiple(void) char *m5; /* We checked in the "simple" test that buffers are unmolested, - * so this time just pass find_msgid() a const variable */ + * so this time just pass message_iter_msgid() a const variable */ s = (char *)C_MSGIDS; /* each call should returns a separate newly allocated string * which is the next msgid in the input */ - m1 = find_msgid(s, &s); + m1 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m1); CU_ASSERT_STRING_EQUAL(m1, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m2 = find_msgid(s, &s); + m2 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m2); CU_ASSERT_STRING_EQUAL(m2, C_MSGID2); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m3 = find_msgid(s, &s); + m3 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m3); CU_ASSERT_STRING_EQUAL(m3, C_MSGID3); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m4 = find_msgid(s, &s); + m4 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m4); CU_ASSERT_STRING_EQUAL(m4, C_MSGID4); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); /* last call returns NULL, there are no more msgids */ - m5 = find_msgid(s, &s); + m5 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m5); /* check the returned msgids are all distinct */ @@ -123,34 +123,34 @@ static void test_whitespace(void) char *m5; /* We checked in the "simple" test that buffers are unmolested, - * so this time just pass find_msgid() a const variable */ + * so this time just pass message_iter_msgid() a const variable */ s = (char *)C_MSGIDS; /* each call should returns a separate newly allocated string * which is the next msgid in the input */ - m1 = find_msgid(s, &s); + m1 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m1); CU_ASSERT_STRING_EQUAL(m1, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m2 = find_msgid(s, &s); + m2 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m2); CU_ASSERT_STRING_EQUAL(m2, C_MSGID2); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m3 = find_msgid(s, &s); + m3 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m3); CU_ASSERT_STRING_EQUAL(m3, C_MSGID3); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m4 = find_msgid(s, &s); + m4 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m4); CU_ASSERT_STRING_EQUAL(m4, C_MSGID4); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); /* last call returns NULL, there are no more msgids */ - m5 = find_msgid(s, &s); + m5 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m5); /* check the returned msgids are all distinct */ @@ -188,34 +188,34 @@ static void test_dups(void) char *m5; /* We checked in the "simple" test that buffers are unmolested, - * so this time just pass find_msgid() a const variable */ + * so this time just pass message_iter_msgid() a const variable */ s = (char *)C_MSGIDS; /* each call should returns a separate newly allocated string * which is the next msgid in the input */ - m1 = find_msgid(s, &s); + m1 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m1); CU_ASSERT_STRING_EQUAL(m1, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m2 = find_msgid(s, &s); + m2 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m2); CU_ASSERT_STRING_EQUAL(m2, C_MSGID2); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m3 = find_msgid(s, &s); + m3 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m3); CU_ASSERT_STRING_EQUAL(m3, C_MSGID2); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m4 = find_msgid(s, &s); + m4 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m4); CU_ASSERT_STRING_EQUAL(m4, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); /* last call returns NULL, there are no more msgids */ - m5 = find_msgid(s, &s); + m5 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m5); /* check the returned msgids are all distinct */ @@ -254,24 +254,24 @@ static void test_eol(void) char *m3; /* We checked in the "simple" test that buffers are unmolested, - * so this time just pass find_msgid() a const variable */ + * so this time just pass message_iter_msgid() a const variable */ s = (char *)C_MSGIDS; /* each call should returns a separate newly allocated string * which is the next msgid in the input */ - m1 = find_msgid(s, &s); + m1 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m1); CU_ASSERT_STRING_EQUAL(m1, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m2 = find_msgid(s, &s); + m2 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m2); CU_ASSERT_STRING_EQUAL(m2, C_MSGID2); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); /* we stop seeing msgids after the end of the first header */ - m3 = find_msgid(s, &s); + m3 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m3); /* check the returned msgids are all distinct */ @@ -300,24 +300,24 @@ static void test_noatsign(void) char *m3; /* We checked in the "simple" test that buffers are unmolested, - * so this time just pass find_msgid() a const variable */ + * so this time just pass message_iter_msgid() a const variable */ s = (char *)C_MSGIDS; /* each call should returns a separate newly allocated string * which is the next msgid in the input */ - m1 = find_msgid(s, &s); + m1 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m1); CU_ASSERT_STRING_EQUAL(m1, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m2 = find_msgid(s, &s); + m2 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m2); CU_ASSERT_STRING_EQUAL(m2, C_MSGID3); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); /* we stop seeing msgids after 2nd msgid */ - m3 = find_msgid(s, &s); + m3 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m3); /* check the returned msgids are all distinct */ @@ -347,29 +347,29 @@ static void test_quoted_localpart(void) char *m4; /* We checked in the "simple" test that buffers are unmolested, - * so this time just pass find_msgid() a const variable */ + * so this time just pass message_iter_msgid() a const variable */ s = (char *)C_MSGIDS; /* each call should returns a separate newly allocated string * which is the next msgid in the input */ - m1 = find_msgid(s, &s); + m1 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m1); CU_ASSERT_STRING_EQUAL(m1, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m2 = find_msgid(s, &s); + m2 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m2); CU_ASSERT_STRING_EQUAL(m2, C_MSGID2ret); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m3 = find_msgid(s, &s); + m3 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m3); CU_ASSERT_STRING_EQUAL(m3, C_MSGID3); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); /* we stop seeing msgids after 2nd msgid */ - m4 = find_msgid(s, &s); + m4 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m4); /* check the returned msgids are all distinct */ @@ -403,29 +403,29 @@ static void test_escaped_quoted_localpart(void) char *m4; /* We checked in the "simple" test that buffers are unmolested, - * so this time just pass find_msgid() a const variable */ + * so this time just pass message_iter_msgid() a const variable */ s = (char *)C_MSGIDS; /* each call should returns a separate newly allocated string * which is the next msgid in the input */ - m1 = find_msgid(s, &s); + m1 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m1); CU_ASSERT_STRING_EQUAL(m1, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m2 = find_msgid(s, &s); + m2 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m2); CU_ASSERT_STRING_EQUAL(m2, C_MSGID2ret); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m3 = find_msgid(s, &s); + m3 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m3); CU_ASSERT_STRING_EQUAL(m3, C_MSGID3); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); /* we stop seeing msgids after 2nd msgid */ - m4 = find_msgid(s, &s); + m4 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m4); /* check the returned msgids are all distinct */ @@ -457,24 +457,24 @@ static void test_malformed_angles(void) char *m3; /* We checked in the "simple" test that buffers are unmolested, - * so this time just pass find_msgid() a const variable */ + * so this time just pass message_iter_msgid() a const variable */ s = (char *)C_MSGIDS; /* each call should returns a separate newly allocated string * which is the next msgid in the input */ - m1 = find_msgid(s, &s); + m1 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m1); CU_ASSERT_STRING_EQUAL(m1, C_MSGID1); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); - m2 = find_msgid(s, &s); + m2 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NOT_NULL(m2); CU_ASSERT_STRING_EQUAL(m2, C_MSGID3); CU_ASSERT(s >= C_MSGIDS && s <= C_MSGIDS+sizeof(C_MSGIDS)); /* we stop seeing msgids after 2nd msgid */ - m3 = find_msgid(s, &s); + m3 = message_iter_msgid(s, &s); CU_ASSERT_PTR_NULL(m3); /* check the returned msgids are all distinct */ diff --git a/cunit/parse.testc b/cunit/parse.testc index 08ba33405b..1786706cb4 100644 --- a/cunit/parse.testc +++ b/cunit/parse.testc @@ -11,13 +11,58 @@ static void test_parsenum(void) { - const char NUM[] = "18338747846901181684 some other stuff"; + const char NUM0[] = "0 some other stuf"; + const char NUM1[] = "1somestuff"; + const char NUMBIG[] = "18446744073709551615ULL"; // non-digits parse OK + const char NUMTOOBIG[] = "18446744073709551616"; bit64 val = CANARY; int r; - r = parsenum(NUM, NULL, strlen(NUM), &val); + r = parsenum(NUM0, NULL, strlen(NUM0), &val); CU_ASSERT_EQUAL(r, 0); - CU_ASSERT_EQUAL(val, 18338747846901181684LLU); + CU_ASSERT_EQUAL(val, 0); + + val = CANARY; + r = parsenum(NUM1, NULL, strlen(NUM1), &val); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(val, 1); + + val = CANARY; + r = parsenum(NUMBIG, NULL, strlen(NUMBIG), &val); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(val, 18446744073709551615ULL); + + val = CANARY; + r = parsenum(NUMTOOBIG, NULL, strlen(NUMTOOBIG), &val); + CU_ASSERT_EQUAL(r, -1); +} + +static void test_parsehex(void) +{ + const char NUM0[] = "0 some other stuf"; + const char NUM1[] = "1somestuff"; + const char NUMBIG[] = "ffffffffffffffff"; + const char NUMTOOBIG[] = "10000000000000000"; + bit64 val = CANARY; + int r; + + r = parsehex(NUM0, NULL, strlen(NUM0), &val); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(val, 0); + + val = CANARY; + r = parsehex(NUM1, NULL, strlen(NUM1), &val); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(val, 1); + + val = CANARY; + r = parsehex(NUMBIG, NULL, strlen(NUMBIG), &val); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(val, 18446744073709551615ULL); + + val = CANARY; + r = parsehex(NUMTOOBIG, NULL, strlen(NUMTOOBIG), &val); + CU_ASSERT_EQUAL(r, -1); } #define wrap_int_parser(func, type, s, outp, valp, inp) do \ @@ -74,7 +119,7 @@ static void test_getint32(void) /* test a string with too many digits */ CU_EXPECT_CYRFATAL_BEGIN; wrap_int_parser(getint32, int32_t, STR3, &c, &val, NULL); - CU_EXPECT_CYRFATAL_END(EX_IOERR, "num too big"); + CU_EXPECT_CYRFATAL_END(EX_PROTOCOL, "num too big"); /* test a valid value with a different terminator */ wrap_int_parser(getint32, int32_t, STR4, &c, &val, &bytes_in); @@ -143,7 +188,7 @@ static void test_getsint32(void) /* test a string with too many digits */ CU_EXPECT_CYRFATAL_BEGIN; wrap_int_parser(getsint32, int32_t, STR3, &c, &val, NULL); - CU_EXPECT_CYRFATAL_END(EX_IOERR, "num too big"); + CU_EXPECT_CYRFATAL_END(EX_PROTOCOL, "num too big"); /* test a valid value with a different terminator */ wrap_int_parser(getsint32, int32_t, STR4, &c, &val, &bytes_in); @@ -210,7 +255,7 @@ static void test_getuint32(void) /* test a string with too many digits */ CU_EXPECT_CYRFATAL_BEGIN; wrap_int_parser(getuint32, uint32_t, STR3, &c, &val, NULL); - CU_EXPECT_CYRFATAL_END(EX_IOERR, "num too big"); + CU_EXPECT_CYRFATAL_END(EX_PROTOCOL, "num too big"); /* test a valid value with a different terminator */ wrap_int_parser(getuint32, uint32_t, STR4, &c, &val, &bytes_in); @@ -277,7 +322,7 @@ static void test_getint64(void) /* test a string with too many digits */ CU_EXPECT_CYRFATAL_BEGIN; wrap_int_parser(getint64, int64_t, STR3, &c, &val, NULL); - CU_EXPECT_CYRFATAL_END(EX_IOERR, "num too big"); + CU_EXPECT_CYRFATAL_END(EX_PROTOCOL, "num too big"); /* test a valid value with a different terminator */ wrap_int_parser(getint64, int64_t, STR4, &c, &val, &bytes_in); @@ -346,7 +391,7 @@ static void test_getsint64(void) /* test a string with too many digits */ CU_EXPECT_CYRFATAL_BEGIN; wrap_int_parser(getsint64, int64_t, STR3, &c, &val, NULL); - CU_EXPECT_CYRFATAL_END(EX_IOERR, "num too big"); + CU_EXPECT_CYRFATAL_END(EX_PROTOCOL, "num too big"); /* test a valid value with a different terminator */ wrap_int_parser(getsint64, int64_t, STR4, &c, &val, &bytes_in); @@ -413,7 +458,7 @@ static void test_getuint64(void) /* test a string with too many digits */ CU_EXPECT_CYRFATAL_BEGIN; wrap_int_parser(getuint64, uint64_t, STR3, &c, &val, NULL); - CU_EXPECT_CYRFATAL_END(EX_IOERR, "num too big"); + CU_EXPECT_CYRFATAL_END(EX_PROTOCOL, "num too big"); /* test a valid value with a different terminator */ wrap_int_parser(getuint64, uint64_t, STR4, &c, &val, &bytes_in); diff --git a/cunit/parseaddr.testc b/cunit/parseaddr.testc index 55923d88dd..8321ab3b39 100644 --- a/cunit/parseaddr.testc +++ b/cunit/parseaddr.testc @@ -1,4 +1,4 @@ -#include +#include #include "cunit/cyrunit.h" #include "parseaddr.h" @@ -205,7 +205,7 @@ static void test_quoted_name_folded(void) /* If a quoted string contains an embedded CR+LF+WSP, because we're * parsing a header value directly, the CR+LF should be stripped out * and the WSP and any following WSP* should be preserved (i.e. we - * should perform header field unfolding per RFC2822) */ + * should perform header field unfolding per RFC 2822) */ a = NULL; parseaddr_list("\"Akira\r\n \t Yoshizawa\" ", &a); @@ -236,15 +236,25 @@ static void test_quoted_name_crlf(void) parseaddr_list("\"Akira\r\n\r\nYoshizawa\" ", &a); CU_ASSERT_PTR_NULL_FATAL(a); - /* A lone CR is invalid and the parse should fail */ + /* A lone CR is replaced with space */ a = NULL; parseaddr_list("\"Akira\rYoshizawa\" ", &a); - CU_ASSERT_PTR_NULL_FATAL(a); + CU_ASSERT_PTR_NOT_NULL_FATAL(a); + CU_ASSERT_STRING_EQUAL(a->name, "Akira Yoshizawa"); + CU_ASSERT_STRING_EQUAL(a->mailbox, "akira"); + CU_ASSERT_STRING_EQUAL(a->domain, "origami.jp"); + CU_ASSERT_PTR_NULL(a->next); + parseaddr_free(a); - /* A lone LF is invalid and the parse should fail */ + /* A lone LF is replaced with space */ a = NULL; parseaddr_list("\"Akira\nYoshizawa\" ", &a); - CU_ASSERT_PTR_NULL_FATAL(a); + CU_ASSERT_PTR_NOT_NULL_FATAL(a); + CU_ASSERT_STRING_EQUAL(a->name, "Akira Yoshizawa"); + CU_ASSERT_STRING_EQUAL(a->mailbox, "akira"); + CU_ASSERT_STRING_EQUAL(a->domain, "origami.jp"); + CU_ASSERT_PTR_NULL(a->next); + parseaddr_free(a); } @@ -282,7 +292,7 @@ static void test_mailbox_comment(void) { struct address *a; - /* This example is from the RFC822 text */ + /* This example is from the RFC 822 text */ a = NULL; parseaddr_list("Wilt . (the Stilt) Chamberlain@NBA.US", &a); CU_ASSERT_PTR_NOT_NULL_FATAL(a); @@ -343,7 +353,7 @@ static void test_rfc2047_text(void) { struct address *a; - /* RFC2047 MIME-encoded text in an address is passed through + /* RFC 2047 MIME-encoded text in an address is passed through * unmolested, to be decoded by upper layers, or not, on demand */ a = NULL; @@ -357,6 +367,20 @@ static void test_rfc2047_text(void) parseaddr_free(a); } +static void test_utf8_domain(void) +{ + struct address *a; + + a = NULL; + parseaddr_list("J. Besteiro ", &a); + CU_ASSERT_PTR_NOT_NULL_FATAL(a); + CU_ASSERT_STRING_EQUAL(a->name, "J. Besteiro"); + CU_ASSERT_STRING_EQUAL(a->mailbox, "jb"); + CU_ASSERT_STRING_EQUAL(a->domain, "julián.example.com"); + CU_ASSERT_PTR_NULL(a->next); + + parseaddr_free(a); +} static void test_group(void) { @@ -705,7 +729,7 @@ static void test_quoted_crlf(void) struct address *head, *a; a = NULL; - parseaddr_list("foo@example.com, bar@example.com,\r\n \"Baz\\\r\n Baz\" ,\r\n bam@example.com", &a); + parseaddr_list("foo@example.com, bar@example.com,\r\n \"Baz\\\r\n Baz\" ,\r\n bam@example.com, \"A\rB" "\x07" "C\" ", &a); CU_ASSERT_PTR_NOT_NULL_FATAL(a); head = a; @@ -730,6 +754,12 @@ static void test_quoted_crlf(void) CU_ASSERT_PTR_NULL(a->name); CU_ASSERT_STRING_EQUAL(a->mailbox, "bam"); CU_ASSERT_STRING_EQUAL(a->domain, "example.com"); + CU_ASSERT_PTR_NOT_NULL(a->next); + + a = a->next; + CU_ASSERT_STRING_EQUAL(a->name, "A BC"); + CU_ASSERT_STRING_EQUAL(a->mailbox, "abc"); + CU_ASSERT_STRING_EQUAL(a->domain, "example.com"); CU_ASSERT_PTR_NULL(a->next); parseaddr_free(head); diff --git a/cunit/proc.testc b/cunit/proc.testc new file mode 100644 index 0000000000..98dd66e790 --- /dev/null +++ b/cunit/proc.testc @@ -0,0 +1,396 @@ +#if HAVE_CONFIG_H +#include +#endif + +#include +#include +#include + +#include "cunit/cyrunit.h" +#include "lib/hash.h" +#include "lib/libconfig.h" +#include "lib/proc.h" +#include "lib/util.h" + +static char *myconfigdir = NULL; + +/* copied declarations: these must match same in lib/proc.c */ +struct proc_handle { + pid_t pid; + char *fname; +}; +#define FNAME_PROCDIR "/proc" +/* end copied declarations */ + +static int set_up(void) +{ + char myconfigdir_template[] = "/tmp/cyrus-cunit-proctestc-XXXXXX"; + char *dir; + struct buf myconfig = BUF_INITIALIZER; + + dir = mkdtemp(myconfigdir_template); + if (!dir) return errno; + + myconfigdir = xstrdup(dir); + buf_printf(&myconfig, "configdirectory: %s\n", myconfigdir); + + config_read_string(buf_cstring(&myconfig)); + + buf_free(&myconfig); + return 0; +} + +static int tear_down(void) +{ + int r = 0; + + config_reset(); + + if (myconfigdir && myconfigdir[0]) { + struct buf rm_cmd = BUF_INITIALIZER; + + buf_printf(&rm_cmd, "rm -rf %s", myconfigdir); + + r = system(buf_cstring(&rm_cmd)); + if (r) r = -1; + + buf_free(&rm_cmd); + } + + xzfree(myconfigdir); + return r; +} + +static const char *predict_handle_fname(pid_t pid) +{ + static char buf[1024]; + + memset(buf, 0, sizeof buf); + snprintf(buf, sizeof buf, "%s%s/%u", myconfigdir, FNAME_PROCDIR, pid); + + return buf; +} + +static void test_register_self(void) +{ + struct proc_handle *handle = NULL; + struct proc_handle *savedptr; + int r; + + /* first call must create a valid handle */ + r = proc_register(&handle, 0, + "servicename", + "clienthost", + "userid", + "mailbox", + "cmd"); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NOT_NULL(handle); + CU_ASSERT_NOT_EQUAL(handle->pid, 0); + CU_ASSERT_EQUAL(handle->pid, getpid()); + CU_ASSERT_PTR_NOT_NULL(handle->fname); + CU_ASSERT_STRING_EQUAL(handle->fname, predict_handle_fname(getpid())); + savedptr = handle; + + /* must be okay to re-register (and keep same handle) */ + r = proc_register(&handle, 0, + "new_servicename", + "new_clienthost", + "new_userid", + "new_mailbox", + "new_cmd"); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NOT_NULL(handle); + CU_ASSERT_PTR_EQUAL(handle, savedptr); + CU_ASSERT_NOT_EQUAL(handle->pid, 0); + CU_ASSERT_EQUAL(handle->pid, getpid()); + CU_ASSERT_PTR_NOT_NULL(handle->fname); + CU_ASSERT_STRING_EQUAL(handle->fname, predict_handle_fname(getpid())); + + proc_cleanup(&handle); +} + +static void test_register_other(void) +{ + struct proc_handle *handle = NULL; + struct proc_handle *savedptr; + pid_t pid; + int r; + + /* choose some random pid > 0 */ + do { + pid = 1 + rand(); + } while (pid <= 0 || pid == getpid()); /* whoops, got ours! try again */ + + /* first call must create a handle */ + r = proc_register(&handle, pid, + "servicename", + "clienthost", + "userid", + "mailbox", + "cmd"); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NOT_NULL(handle); + CU_ASSERT_NOT_EQUAL(handle->pid, 0); + CU_ASSERT_NOT_EQUAL(handle->pid, getpid()); + CU_ASSERT_EQUAL(handle->pid, pid); + CU_ASSERT_PTR_NOT_NULL(handle->fname); + CU_ASSERT_STRING_EQUAL(handle->fname, predict_handle_fname(pid)); + savedptr = handle; + + /* must be okay to re-register (pid argument must be ignored) */ + r = proc_register(&handle, 0, + "new_servicename", + "new_clienthost", + "new_userid", + "new_mailbox", + "new_cmd"); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NOT_NULL(handle); + CU_ASSERT_PTR_EQUAL(handle, savedptr); + CU_ASSERT_NOT_EQUAL(handle->pid, 0); + CU_ASSERT_NOT_EQUAL(handle->pid, getpid()); + CU_ASSERT_EQUAL(handle->pid, pid); + CU_ASSERT_PTR_NOT_NULL(handle->fname); + CU_ASSERT_STRING_EQUAL(handle->fname, predict_handle_fname(pid)); + + proc_cleanup(&handle); +} + +static void test_cleanup(void) +{ + struct proc_handle *handle = NULL; + int r; + + /* gotta register something to clean it up... */ + r = proc_register(&handle, 1, + "servicename", + "clienthost", + "userid", + "mailbox", + "cmd"); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NOT_NULL(handle); + CU_ASSERT_EQUAL(handle->pid, 1); + CU_ASSERT_PTR_NOT_NULL(handle->fname); + CU_ASSERT_STRING_EQUAL(handle->fname, predict_handle_fname(1)); + + /* cleanup had better discard that handle */ + proc_cleanup(&handle); + CU_ASSERT_PTR_NULL(handle); + + /* re-register after cleanup must create a new handle */ + r = proc_register(&handle, 2, + "servicename", + "clienthost", + "userid", + "mailbox", + "cmd"); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NOT_NULL(handle); + CU_ASSERT_EQUAL(handle->pid, 2); + CU_ASSERT_PTR_NOT_NULL(handle->fname); + CU_ASSERT_STRING_EQUAL(handle->fname, predict_handle_fname(2)); + + /* cleanup had better discard that one too */ + proc_cleanup(&handle); + CU_ASSERT_PTR_NULL(handle); +} + +struct procdata_fields { + char *servicename; + char *clienthost; + char *userid; + char *mailbox; + char *cmd; +}; + +static void free_procdata_fields(void *p) +{ + struct procdata_fields *f = (struct procdata_fields *) p; + + free(f->servicename); + free(f->clienthost); + free(f->userid); + free(f->mailbox); + free(f->cmd); + free(f); +} + +static int collect_procs_cb(pid_t pid, + const char *servicename, + const char *clienthost, + const char *userid, + const char *mailbox, + const char *cmd, + void *rock) +{ + char pid_str[32] = {0}; + hash_table *results = (hash_table *) rock; + + snprintf(pid_str, sizeof pid_str, "%u", pid); + + struct procdata_fields *fields = xmalloc(sizeof *fields); + fields->servicename = xstrdupnull(servicename); + fields->clienthost = xstrdupnull(clienthost); + fields->userid = xstrdupnull(userid); + fields->mailbox = xstrdupnull(mailbox); + fields->cmd = xstrdupnull(cmd); + + /* better not have seen this pid already! */ + CU_ASSERT_PTR_NULL(hash_lookup(pid_str, results)); + + hash_insert(pid_str, fields, results); + return 0; +} + +static void test_proc_foreach(void) +{ + struct { + struct proc_handle *handle; + struct procdata_fields fields; + } tests[] = { + { NULL, { "sn0", "ch0", "ui0", "mb0", "cm0" } }, + { NULL, { "sn1", "ch1", "ui1", "mb1", "cm1" } }, + { NULL, { "sn2", "ch2", "ui2", "mb2", "cm2" } }, + { NULL, { "sn3", "ch3", "ui3", "mb3", "cm3" } }, + { NULL, { "sn4", "ch4", "ui4", "mb4", "cm4" } }, + { NULL, { "sn5", "ch5", "ui5", "mb5", "cm5" } }, + }; + const size_t n_tests = sizeof(tests) / sizeof(tests[0]); + const pid_t mypid = getpid(); + hash_table results = HASH_TABLE_INITIALIZER; + strarray_t *keys = NULL; + int i, r; + + /* register our "processes" */ + for (i = 0; (unsigned) i < n_tests; i++) { + r = proc_register(&tests[i].handle, + i, /* use test index as pid */ + tests[i].fields.servicename, + tests[i].fields.clienthost, + tests[i].fields.userid, + tests[i].fields.mailbox, + tests[i].fields.cmd); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NOT_NULL(&tests[i].handle); + if (i == 0) { + CU_ASSERT_EQUAL(tests[i].handle->pid, mypid); + } + else { + CU_ASSERT_EQUAL(tests[i].handle->pid, i); + } + } + + /* let's see if it finds everything */ + construct_hash_table(&results, n_tests, 0); + r = proc_foreach(&collect_procs_cb, &results); + CU_ASSERT_EQUAL(r, 0); + for (i = 0; (unsigned) i < n_tests; i++) { + char pid_str[32] = {0}; + struct procdata_fields *fields; + + snprintf(pid_str, sizeof pid_str, "%d", i ? i : mypid); + + fields = hash_lookup(pid_str, &results); + CU_ASSERT_PTR_NOT_NULL(fields); + CU_ASSERT_STRING_EQUAL(fields->servicename, tests[i].fields.servicename); + CU_ASSERT_STRING_EQUAL(fields->clienthost, tests[i].fields.clienthost); + CU_ASSERT_STRING_EQUAL(fields->userid, tests[i].fields.userid); + CU_ASSERT_STRING_EQUAL(fields->mailbox, tests[i].fields.mailbox); + CU_ASSERT_STRING_EQUAL(fields->cmd, tests[i].fields.cmd); + } + + /* better not have found anything extra! */ + keys = hash_keys(&results); + for (i = 0; i < strarray_size(keys); i++) { + int found_pid = atoi(strarray_nth(keys, i)); + + /* real process pid will be out of range but is legit */ + if (found_pid == mypid) continue; + + /* better not see a pid 0! */ + CU_ASSERT(found_pid > 0); + + /* better not see anything higher than those we created */ + CU_ASSERT((unsigned) found_pid < n_tests); + } + strarray_free(keys); + + /* reset results */ + free_hash_table(&results, &free_procdata_fields); + + /* reregistering with different strings should work */ + for (i = 0; (unsigned) i < n_tests; i++) { + r = proc_register(&tests[i].handle, + i, /* use test index as pid */ + "new servicename", + "new clienthost", + "new userid", + "new mailbox", + "new cmd"); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_PTR_NOT_NULL(&tests[i].handle); + if (i == 0) { + CU_ASSERT_EQUAL(tests[i].handle->pid, mypid); + } + else { + CU_ASSERT_EQUAL(tests[i].handle->pid, i); + } + } + + /* let's see if it finds everything */ + construct_hash_table(&results, n_tests, 0); + r = proc_foreach(&collect_procs_cb, &results); + CU_ASSERT_EQUAL(r, 0); + for (i = 0; (unsigned) i < n_tests; i++) { + char pid_str[32] = {0}; + struct procdata_fields *fields; + + snprintf(pid_str, sizeof pid_str, "%u", i ? i : mypid); + + fields = hash_lookup(pid_str, &results); + CU_ASSERT_PTR_NOT_NULL(fields); + CU_ASSERT_STRING_EQUAL(fields->servicename, "new servicename"); + CU_ASSERT_STRING_EQUAL(fields->clienthost, "new clienthost"); + CU_ASSERT_STRING_EQUAL(fields->userid, "new userid"); + CU_ASSERT_STRING_EQUAL(fields->mailbox, "new mailbox"); + CU_ASSERT_STRING_EQUAL(fields->cmd, "new cmd"); + } + + /* better not have found anything extra! */ + keys = hash_keys(&results); + for (i = 0; i < strarray_size(keys); i++) { + int found_pid = atoi(strarray_nth(keys, i)); + + /* real process pid will be out of range but is legit */ + if (found_pid == mypid) continue; + + /* better not see a pid 0! */ + CU_ASSERT(found_pid > 0); + + /* better not see anything higher than those we created */ + CU_ASSERT((unsigned) found_pid < n_tests); + } + strarray_free(keys); + + /* reset results */ + free_hash_table(&results, &free_procdata_fields); + + /* cleanup our "processes" */ + for (i = 0; (unsigned ) i < n_tests; i++) { + proc_cleanup(&tests[i].handle); + CU_ASSERT_PTR_NULL(tests[i].handle); + } + + /* shouldn't find anything this time */ + construct_hash_table(&results, n_tests, 0); + r = proc_foreach(&collect_procs_cb, &results); + CU_ASSERT_EQUAL(r, 0); + CU_ASSERT_EQUAL(hash_numrecords(&results), 0); + + /* and we're finished */ + free_hash_table(&results, &free_procdata_fields); +} + +/* vim: set ft=c: */ diff --git a/cunit/procinfo.testc b/cunit/procinfo.testc new file mode 100644 index 0000000000..ddaad5a7a1 --- /dev/null +++ b/cunit/procinfo.testc @@ -0,0 +1,167 @@ +/* unit test for lib/procinfo.c */ +#include /* for getpid() */ +#include "config.h" +#include "cunit/cyrunit.h" +#include "lib/procinfo.h" + +static void test_init_piarray(void) +{ + piarray_t piarray; + + init_piarray(&piarray); + + CU_ASSERT_EQUAL(piarray.count, 0); + CU_ASSERT_EQUAL(piarray.alloc, 0); + CU_ASSERT_PTR_NULL(piarray.data); +} + +#if 0 +static void not_test_add_procinfo_generic(void) +{ + piarray_t piarray; + struct proc_info *pinfo; + + init_piarray(&piarray); + + pinfo = add_procinfo_generic(&piarray, 2342, "service", "host", + "user", "mailbox", "command"); + + CU_ASSERT_PTR_NOT_NULL(pinfo); + + CU_ASSERT_EQUAL(piarray.count, 1); + CU_ASSERT_TRUE((piarray.alloc >= piarray.count)); + CU_ASSERT_PTR_NOT_NULL(piarray.data); + + CU_ASSERT_EQUAL(pinfo->pid, 2342); + CU_ASSERT_STRING_EQUAL(pinfo->servicename, "service"); + CU_ASSERT_STRING_EQUAL(pinfo->user, "user"); + CU_ASSERT_STRING_EQUAL(pinfo->host, "host"); + CU_ASSERT_STRING_EQUAL(pinfo->mailbox, "mailbox"); + CU_ASSERT_STRING_EQUAL(pinfo->cmdname, "command"); +} +#endif + +static void test_add_procinfo(void) +{ + piarray_t piarray; + struct proc_info *pinfo; + int res; + + init_piarray(&piarray); + + res = add_procinfo(getpid(), "service", "host", "user", + "mailbox", "command", &piarray); + + CU_ASSERT_EQUAL(res, 0); + + CU_ASSERT_EQUAL(piarray.count, 1); + CU_ASSERT_TRUE((piarray.alloc >= piarray.count)); + CU_ASSERT_PTR_NOT_NULL(piarray.data); + + pinfo = piarray.data[0]; + + CU_ASSERT_PTR_NOT_NULL(pinfo); + CU_ASSERT_EQUAL(pinfo->pid, getpid()); + CU_ASSERT_STRING_EQUAL(pinfo->servicename, "service"); + CU_ASSERT_STRING_EQUAL(pinfo->user, "user"); + CU_ASSERT_STRING_EQUAL(pinfo->host, "host"); + CU_ASSERT_STRING_EQUAL(pinfo->mailbox, "mailbox"); + CU_ASSERT_STRING_EQUAL(pinfo->cmdname, "command"); + CU_ASSERT_STRING_NOT_EQUAL(pinfo->state, ""); + CU_ASSERT_NOT_EQUAL(pinfo->start, 0); + CU_ASSERT_TRUE((pinfo->start <= time(NULL))); + CU_ASSERT_TRUE((pinfo->vmsize > 0)); + + deinit_piarray(&piarray); +} + +static void test_sort_procinfo(void) +{ + piarray_t piarray; + struct proc_info *pinfo1, *pinfo2, *pinfo3, *pinfo4; + int res; + + init_piarray(&piarray); + + res = add_procinfo(getpid(), "service1", "host1", "user1", + "mailbox1", "command1", &piarray); + CU_ASSERT_EQUAL(res, 0); + CU_ASSERT_EQUAL(piarray.count, 1); + + res = add_procinfo(1, "service2", "host2", "user2", + "mailbox2", "command2", &piarray); + CU_ASSERT_EQUAL(res, 0); + CU_ASSERT_EQUAL(piarray.count, 2); + + res = add_procinfo(getpid(), "service1", "host1", "user1", + "mailbox1", "command1", &piarray); + CU_ASSERT_EQUAL(res, 0); + CU_ASSERT_EQUAL(piarray.count, 3); + + res = add_procinfo(1, "service2", "host2", "user2", + "mailbox2", "command2", &piarray); + CU_ASSERT_EQUAL(res, 0); + CU_ASSERT_EQUAL(piarray.count, 4); + + pinfo1 = piarray.data[0]; + pinfo2 = piarray.data[1]; + + snprintf(pinfo1->state, sizeof(pinfo1->state), "%s", "running1"); + snprintf(pinfo2->state, sizeof(pinfo2->state), "%s", "running2"); + pinfo1->start = 1; + pinfo2->start = 2; + pinfo1->vmsize = 1; + pinfo2->vmsize = 2; + + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "p") < 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "s") > 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "q") > 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "t") > 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "v") > 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "h") > 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "u") > 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "r") > 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "c") > 0)); + + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "P") > 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "S") < 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "Q") < 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "T") < 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "V") < 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "H") < 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "U") < 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "R") < 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo2, "C") < 0)); + + pinfo3 = piarray.data[2]; + pinfo4 = piarray.data[3]; + + snprintf(pinfo3->state, sizeof(pinfo3->state), "%s", "running1"); + snprintf(pinfo4->state, sizeof(pinfo4->state), "%s", "running2"); + pinfo3->start = 1; + pinfo4->start = 2; + pinfo3->vmsize = 1; + pinfo4->vmsize = 2; + + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "p") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "s") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "q") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "t") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "v") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "h") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "u") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "r") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo1, &pinfo3, "c") == 0)); + + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "P") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "S") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "Q") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "T") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "V") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "H") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "U") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "R") == 0)); + CU_ASSERT_TRUE((sort_procinfo QSORT_R_COMPAR_ARGS(&pinfo2, &pinfo4, "C") == 0)); + + deinit_piarray(&piarray); +} diff --git a/cunit/prot.testc b/cunit/prot.testc index 45d05011db..9ec5803af6 100644 --- a/cunit/prot.testc +++ b/cunit/prot.testc @@ -4,6 +4,7 @@ #include "xmalloc.h" #include "prot.h" #include "imap/global.h" +#include "xunlink.h" #define PROLOG \ @@ -25,7 +26,7 @@ if ((n) >= 0) (b)[(n)] = '\0'; \ } #define EPILOG \ - unlink(_fname); \ + xunlink(_fname); \ free(_fname); \ close(_fd) diff --git a/cunit/quota.testc b/cunit/quota.testc index b680b8d570..b25a51bd67 100644 --- a/cunit/quota.testc +++ b/cunit/quota.testc @@ -32,12 +32,12 @@ static void test_names(void) CU_ASSERT_EQUAL(r, QUOTA_STORAGE); CU_ASSERT_STRING_EQUAL(quota_names[QUOTA_ANNOTSTORAGE], - "X-ANNOTATION-STORAGE"); - r = quota_name_to_resource("X-ANNOTATION-STORAGE"); + "ANNOTATION-STORAGE"); + r = quota_name_to_resource("ANNOTATION-STORAGE"); CU_ASSERT_EQUAL(r, QUOTA_ANNOTSTORAGE); - r = quota_name_to_resource("x-annotation-storage"); + r = quota_name_to_resource("annotation-storage"); CU_ASSERT_EQUAL(r, QUOTA_ANNOTSTORAGE); - r = quota_name_to_resource("X-AnNotAtiOn-StOragE"); + r = quota_name_to_resource("AnNotAtiOn-StOragE"); CU_ASSERT_EQUAL(r, QUOTA_ANNOTSTORAGE); r = quota_name_to_resource("nonesuch"); @@ -432,13 +432,13 @@ static void test_update_useds(void) quota_diff[QUOTA_STORAGE] = 10*1024; quota_diff[QUOTA_MESSAGE] = 2; quota_diff[QUOTA_ANNOTSTORAGE] = 1*1024; - r = quota_update_useds(NULL, quota_diff, 0); + r = quota_update_useds(NULL, quota_diff, NULL, 0); CU_ASSERT_EQUAL(r, IMAP_QUOTAROOT_NONEXISTENT); - r = quota_update_useds("", quota_diff, 0); + r = quota_update_useds("", quota_diff, NULL, 0); CU_ASSERT_EQUAL(r, IMAP_QUOTAROOT_NONEXISTENT); - r = quota_update_useds(QUOTAROOT_NONEXISTENT, quota_diff, 0); + r = quota_update_useds(QUOTAROOT_NONEXISTENT, quota_diff, NULL, 0); CU_ASSERT_EQUAL(r, IMAP_QUOTAROOT_NONEXISTENT); /* set limits */ @@ -454,7 +454,7 @@ static void test_update_useds(void) #define TESTCASE(d0, d1, d2, e0, e1, e2) { \ static const quota_t diff[QUOTA_NUMRESOURCES] = { d0, d1, d2 }; \ static const quota_t expused[QUOTA_NUMRESOURCES] = { e0, e1, e2 }; \ - r = quota_update_useds(QUOTAROOT, diff, 0); \ + r = quota_update_useds(QUOTAROOT, diff, NULL, 0); \ CU_ASSERT_EQUAL(r, 0); \ memset(&q2, 0, sizeof(q2)); \ q2.root = QUOTAROOT; \ @@ -462,7 +462,7 @@ static void test_update_useds(void) CU_ASSERT_EQUAL(r, 0); \ for (res = 0; res < QUOTA_NUMRESOURCES; res++) { \ CU_ASSERT_EQUAL(q2.useds[res], expused[res]); \ - CU_ASSERT_EQUAL(q2.limits[res], q.limits[res]) \ + CU_ASSERT_EQUAL(q2.limits[res], q.limits[res]); \ } \ } @@ -492,6 +492,13 @@ static void test_update_useds(void) TESTCASE(-50*1024, -10, -5*1024, 0, 0, 0); + /* XXX we call quota_update_useds() with a NULL mailbox, which + * XXX will crash in some circumstances (see comment inline), but + * XXX our tests don't crash... which means we're missing tests + * XXX for the codepath that depends on mboxname! + * XXX https://github.com/cyrusimap/cyrus-imapd/issues/2808 + */ + #undef TESTCASE } @@ -996,18 +1003,6 @@ static void test_findroot_virtdomains(void) } #undef TESTCASE -static void config_read_string(const char *s) -{ - char *fname = xstrdup("/tmp/cyrus-cunit-configXXXXXX"); - int fd = mkstemp(fname); - retry_write(fd, s, strlen(s)); - config_reset(); - config_read(fname, 0); - unlink(fname); - free(fname); - close(fd); -} - static int set_up(void) { int r; diff --git a/cunit/search_expr.testc b/cunit/search_expr.testc index 9979b8253a..c9239af811 100644 --- a/cunit/search_expr.testc +++ b/cunit/search_expr.testc @@ -1,4 +1,8 @@ #include "config.h" + +#include "lib/libconfig.h" +#include "lib/libcyr_cfg.h" + #include "cunit/cyrunit.h" #include "prot.h" #include "xmalloc.h" @@ -14,12 +18,16 @@ static const char *userid = "fred"; static struct auth_state *authstate = NULL; static int isadmin = 0; +extern hash_table attrs_by_name; /* expose some search_expr internals */ + #define DATE1_RFC3501 "8-Dec-2012" #define DATE1_BEGIN "1354885200" #define DATE1_MID "1354928400" #define DATE1_END "1354971600" #define DATE1_MID_TIME 1354928400 +#define DBDIR "test-dbdir" + static void test_get_search_program(void) { /*TODO: test initial state */ @@ -44,7 +52,7 @@ static void test_get_search_program(void) buf_cstring(&actual_errs); \ CU_ASSERT_STRING_EQUAL(actual_errs.s, expected_errs); \ if (c != EOF) { \ - CU_ASSERT_STRING_EQUAL(charset_name(searchargs->charset), "us-ascii"); \ + CU_ASSERT_STRING_EQUAL(charset_canon_name(searchargs->charset), "us-ascii"); \ CU_ASSERT_PTR_NOT_NULL(searchargs->root); \ actual_out = search_expr_serialise(searchargs->root); \ CU_ASSERT_STRING_EQUAL(actual_out, expected_out); \ @@ -83,7 +91,7 @@ static void test_get_search_program(void) /* BCC */ TESTCASE("bcc \"shoreditch\"\r\n", - "(and (match bcc \"SHOREDITCH\"))"); + "(and (match bcc \"shoreditch\"))"); /* BEFORE */ TESTCASE("before "DATE1_RFC3501"\r\n", @@ -91,11 +99,11 @@ static void test_get_search_program(void) /* BODY */ TESTCASE("body \"williamsburg\"\r\n", - "(and (match body \"WILLIAMSBURG\"))"); + "(and (match body \"williamsburg\"))"); /* CC */ TESTCASE("cc \"brooklyn\"\r\n", - "(and (match cc \"BROOKLYN\"))"); + "(and (match cc \"brooklyn\"))"); /* DELETED */ TESTCASE("deleted\r\n", @@ -111,11 +119,11 @@ static void test_get_search_program(void) /* FROM */ TESTCASE("from \"dreamcatcher\"\r\n", - "(and (match from \"DREAMCATCHER\"))"); + "(and (match from \"dreamcatcher\"))"); /* HEADER */ TESTCASE("header \"Cosby\" \"Sweater\"\r\n", - "(and (match header:cosby \"SWEATER\"))"); + "(and (match header:cosby \"Sweater\"))"); /* KEYWORD */ TESTCASE("keyword $Mustache\r\n", @@ -139,7 +147,7 @@ static void test_get_search_program(void) /* NOT */ TESTCASE("not subject \"tumblr\"\r\n", - "(and (not (match subject \"TUMBLR\")))"); + "(and (not (match subject \"tumblr\")))"); /* OLD */ /* inexplicably, the RFC specifies OLD != NOT NEW */ @@ -160,9 +168,9 @@ static void test_get_search_program(void) TESTCASE("or subject \"black\" subject \"white\"\r\n", "(and " "(or " - "(match subject \"BLACK\")" + "(match subject \"black\")" " " - "(match subject \"WHITE\")" + "(match subject \"white\")" ")" ")"); @@ -205,15 +213,15 @@ static void test_get_search_program(void) /* SUBJECT */ TESTCASE("subject \"selvage\"\r\n", - "(and (match subject \"SELVAGE\"))"); + "(and (match subject \"selvage\"))"); /* TEXT */ TESTCASE("text \"letterpress\"\r\n", - "(and (match text \"LETTERPRESS\"))"); + "(and (match text \"letterpress\"))"); /* TO */ TESTCASE("to \"readymade\"\r\n", - "(and (match to \"READYMADE\"))"); + "(and (match to \"readymade\"))"); /* UID */ TESTCASE("uid 1\r\n", @@ -255,18 +263,18 @@ static void test_get_search_program(void) TESTCASE("(subject \"bicycle\" to \"rights\")\r\n", "(and " "(and " - "(match subject \"BICYCLE\")" + "(match subject \"bicycle\")" " " - "(match to \"RIGHTS\")" + "(match to \"rights\")" ")" ")"); TESTCASE("not (subject \"bicycle\" to \"rights\")\r\n", "(and " "(not " "(and " - "(match subject \"BICYCLE\")" + "(match subject \"bicycle\")" " " - "(match to \"RIGHTS\")" + "(match to \"rights\")" ")" ")" ")"); @@ -274,12 +282,12 @@ static void test_get_search_program(void) "(and " "(or " "(and " - "(match subject \"BICYCLE\")" + "(match subject \"bicycle\")" " " - "(match to \"RIGHTS\")" + "(match to \"rights\")" ")" " " - "(match from \"QUINOA\")" + "(match from \"quinoa\")" ")" ")"); @@ -324,16 +332,16 @@ static void test_get_search_program(void) /* YOUNGER in seconds ago */ /* Inexplicably, the RFC defines that *both* YOUNGER and OLDER * include the specified second */ - //time_push_fixed(DATE1_MID_TIME); - //TESTCASE("older 0\r\n", - //"(and (le internaldate "DATE1_MID"))"); - //TESTCASE("older 43200\r\n", - //"(and (le internaldate "DATE1_BEGIN"))"); - //TESTCASE("younger 0\r\n", - //"(and (ge internaldate "DATE1_MID"))"); - //TESTCASE("younger 43200\r\n", - //"(and (ge internaldate "DATE1_BEGIN"))"); - //time_pop(); + time_push_fixed(DATE1_MID_TIME); + TESTCASE("older 0\r\n", + "(and (le internaldate "DATE1_MID"))"); + TESTCASE("older 43200\r\n", + "(and (le internaldate "DATE1_BEGIN"))"); + TESTCASE("younger 0\r\n", + "(and (ge internaldate "DATE1_MID"))"); + TESTCASE("younger 43200\r\n", + "(and (ge internaldate "DATE1_BEGIN"))"); + time_pop(); /* * Search criteria from RFC 5257 @@ -357,17 +365,17 @@ static void test_get_search_program(void) /* XLISTID */ TESTCASE("xlistid \"semiotics\"\r\n", - "(and (match listid \"SEMIOTICS\"))"); + "(and (match listid \"semiotics\"))"); /* XCONTENTTYPE */ TESTCASE("xcontenttype text\r\n", - "(and (match contenttype \"TEXT\"))"); + "(and (match contenttype \"text\"))"); TESTCASE("xcontenttype \"text\"\r\n", - "(and (match contenttype \"TEXT\"))"); + "(and (match contenttype \"text\"))"); TESTCASE("xcontenttype \"plain\"\r\n", - "(and (match contenttype \"PLAIN\"))"); + "(and (match contenttype \"plain\"))"); TESTCASE("xcontenttype \"text_plain\"\r\n", - "(and (match contenttype \"TEXT_PLAIN\"))"); + "(and (match contenttype \"text_plain\"))"); /* TODO: test that conv* are rejected if conversations:off */ /* CONVFLAG */ @@ -646,6 +654,7 @@ static void test_normalise(void) s = search_expr_serialise(e); \ CU_ASSERT_STRING_EQUAL(s, expected_out); \ search_expr_free(e); \ + free(s); \ } /* @@ -672,9 +681,9 @@ static void test_normalise(void) * under a NOT node is already in DNF. */ TESTCASE("(not (false))", - "(not (false))"); + "(true)"); TESTCASE("(not (true))", - "(not (true))"); + "(false)"); TESTCASE("(not (match subject \"ETSY\"))", "(not (match subject \"ETSY\"))"); TESTCASE("(not (le size 123))", @@ -740,7 +749,7 @@ static void test_normalise(void) TESTCASE("(and (le size 123) (match subject \"ETSY\"))", "(and (le size 123) (match subject \"ETSY\"))"); TESTCASE("(and (not (le size 123)) (match subject \"ETSY\"))", - "(and (match subject \"ETSY\") (not (le size 123)))"); + "(and (not (le size 123)) (match subject \"ETSY\"))"); TESTCASE("(and (le size 123) (not (match subject \"ETSY\")))", "(and (le size 123) (not (match subject \"ETSY\")))"); @@ -752,7 +761,7 @@ static void test_normalise(void) TESTCASE("(or (le size 123) (match subject \"ETSY\"))", "(or (le size 123) (match subject \"ETSY\"))"); TESTCASE("(or (not (le size 123)) (match subject \"ETSY\"))", - "(or (match subject \"ETSY\") (not (le size 123)))"); + "(or (not (le size 123)) (match subject \"ETSY\"))"); TESTCASE("(or (le size 123) (not (match subject \"ETSY\")))", "(or (le size 123) (not (match subject \"ETSY\")))"); @@ -788,11 +797,11 @@ static void test_normalise(void) ")" " " "(and " - "(match subject \"TUMBLR\")" - " " "(not " "(le size 456)" ")" + " " + "(match subject \"TUMBLR\")" ")" ")"); @@ -1047,6 +1056,115 @@ static void test_normalise(void) "(match subject \"ETSY\")" ")"); + // Detrivialisation. + + TESTCASE("(or " + "(not (true))" + " " + "(not (le size 123))" + ")", + "(not (le size 123))"); + + TESTCASE("(or " + "(and " + "(true)" + " " + "(match subject \"ETSY\")" + " " + "(not (le size 789))" + ")" + " " + "(and " + "(true)" + " " + "(not (true))" + " " + "(match subject \"ETSY\")" + ")" + ")", + "(and " + "(not (le size 789))" + " " + "(match subject \"ETSY\")" + ")"); + + TESTCASE("(or " + "(and " + "(true)" + " " + "(not (le size 123))" + " " + "(match body \"COSBY\")" + ")" + " " + "(and " + "(true)" + " " + "(not (true))" + " " + "(match body \"COSBY\")" + ")" + ")", + "(and " + "(not (le size 123))" + " " + "(match body \"COSBY\")" + ")"); + TESTCASE("(or " + "(and " + "(true)" + " " + "(true)" + " " + "(match subject \"ETSY\")" + " " + "(not (le size 789))" + " " + "(match body \"COSBY\")" + ")" + " " + "(and " + "(true)" + " " + "(true)" + " " + "(not (true))" + " " + "(match body \"COSBY\")" + " " + "(match subject \"ETSY\")" + ")" + ")", + "(and " + "(not (le size 789))" + " " + "(match subject \"ETSY\")" + " " + "(match body \"COSBY\")" + ")"); + + TESTCASE("(or " + "(and " + "(true)" + " " + "(ge size 5)" + ")" + " " + "(and " + "(true)" + " " + "(le size 10)" + ")" + ")", + "(or " + "(le size 10)" + " " + "(ge size 5)" + ")"); + + + + #undef TESTCASE } @@ -1153,14 +1271,14 @@ static void test_split_by_folder_and_index(void) TESTCASE("(not (match folder \"INBOX.Cosby.Sweater\"))", "(not (match folder \"INBOX.Cosby.Sweater\"))\n"); TESTCASE("(and (match message-id \"ETSY\") (not (match folder \"INBOX.Cosby.Sweater\")))", - "(and (match message-id \"ETSY\") (not (match folder \"INBOX.Cosby.Sweater\")))\n"); + "(and (not (match folder \"INBOX.Cosby.Sweater\")) (match message-id \"ETSY\"))\n"); /* a conjuctive node which doesn't match folders * gets a NULL mboxname */ TESTCASE("(and (match message-id \"ETSY\") (le size 123))", "(and (le size 123) (match message-id \"ETSY\"))\n"); TESTCASE("(and (match message-id \"ETSY\") (not (le size 123)))", - "(and (match message-id \"ETSY\") (not (le size 123)))\n"); + "(and (not (le size 123)) (match message-id \"ETSY\"))\n"); TESTCASE("(and (le size 123) (not (match message-id \"ETSY\")))", "(and (le size 123) (not (match message-id \"ETSY\")))\n"); @@ -1180,7 +1298,7 @@ static void test_split_by_folder_and_index(void) TESTCASE("(or (match message-id \"ETSY\") (le size 123))", "(or (le size 123) (match message-id \"ETSY\"))\n"); TESTCASE("(or (match message-id \"ETSY\") (not (le size 123)))", - "(or (match message-id \"ETSY\") (not (le size 123)))\n"); + "(or (not (le size 123)) (match message-id \"ETSY\"))\n"); TESTCASE("(or (le size 123) (not (match message-id \"ETSY\")))", "(or (le size 123) (not (match message-id \"ETSY\")))\n"); @@ -1249,24 +1367,43 @@ static int set_up(void) { int r; + /* n.b. initalising like this skips the on-shutdown cleanup... */ search_attr_init(); + libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + "conversations: yes\n" + ); + r = mboxname_init_namespace(&ns, isadmin); if (r) return r; push_tz(TZ_MELBOURNE); - imapopts[IMAPOPT_CONVERSATIONS].val.b = 1; - return 0; } static int tear_down(void) { - imapopts[IMAPOPT_CONVERSATIONS].val.b = 0; + int r; + + /* ... so we need to reach in deep and clean up ourselves */ + hash_iter *iter = hash_table_iter(&attrs_by_name); + while (hash_iter_next(iter)) { + struct search_attr *attr = hash_iter_val(iter); + if (attr->freeattr) attr->freeattr(&attr); + } + hash_iter_free(&iter); + free_hash_table(&attrs_by_name, NULL); + + config_reset(); restore_tz(); time_restore(); - return 0; + + r = system("rm -rf " DBDIR); + + return r; } /* vim: set ft=c: */ diff --git a/cunit/seqset.testc b/cunit/seqset.testc index 679fd67511..803330dd09 100644 --- a/cunit/seqset.testc +++ b/cunit/seqset.testc @@ -2,17 +2,16 @@ #include "cunit/cyrunit.h" #include "xmalloc.h" #include "util.h" -#include "imap/sequence.h" +#include "seqset.h" static void test_empty(void) { - struct seqset *seq; + seqset_t *seq; char *s; seq = seqset_init(/*maxval*/0, SEQ_SPARSE); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 0); CU_ASSERT_EQUAL(seqset_first(seq), 0); CU_ASSERT_EQUAL(seqset_last(seq), 0); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -21,18 +20,17 @@ static void test_empty(void) CU_ASSERT_STRING_EQUAL(s, ""); free(s); - seqset_free(seq); + seqset_free(&seq); } static void test_add_contiguous(void) { - struct seqset *seq; + seqset_t *seq; char *s; seq = seqset_init(/*maxval*/0, SEQ_SPARSE); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 0); CU_ASSERT_EQUAL(seqset_first(seq), 0); CU_ASSERT_EQUAL(seqset_last(seq), 0); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -43,7 +41,6 @@ static void test_add_contiguous(void) seqset_add(seq, 1, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 1); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -55,7 +52,6 @@ static void test_add_contiguous(void) seqset_add(seq, 2, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 2); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -68,7 +64,6 @@ static void test_add_contiguous(void) seqset_add(seq, 3, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 3); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -82,7 +77,6 @@ static void test_add_contiguous(void) seqset_add(seq, 4, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 4); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -95,18 +89,17 @@ static void test_add_contiguous(void) CU_ASSERT_STRING_EQUAL(s, "1:4"); free(s); - seqset_free(seq); + seqset_free(&seq); } static void test_add_noncontiguous(void) { - struct seqset *seq; + seqset_t *seq; char *s; seq = seqset_init(/*maxval*/0, SEQ_SPARSE); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 0); CU_ASSERT_EQUAL(seqset_first(seq), 0); CU_ASSERT_EQUAL(seqset_last(seq), 0); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -117,7 +110,6 @@ static void test_add_noncontiguous(void) seqset_add(seq, 1, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 1); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -133,7 +125,6 @@ static void test_add_noncontiguous(void) * for the next member number. Duh. */ seqset_add(seq, 2, /*ismember*/0); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 1); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -146,7 +137,6 @@ static void test_add_noncontiguous(void) seqset_add(seq, 3, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 2); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 3); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -160,7 +150,6 @@ static void test_add_noncontiguous(void) seqset_add(seq, 4, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 2); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 4); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -175,7 +164,6 @@ static void test_add_noncontiguous(void) seqset_add(seq, 5, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 2); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 5); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -189,13 +177,13 @@ static void test_add_noncontiguous(void) CU_ASSERT_STRING_EQUAL(s, "1,3:5"); free(s); - seqset_free(seq); + seqset_free(&seq); } static void test_dup(void) { - struct seqset *seq; - struct seqset *seq2; + seqset_t *seq; + seqset_t *seq2; char *s; seq = seqset_init(/*maxval*/0, SEQ_SPARSE); @@ -207,7 +195,6 @@ static void test_dup(void) seqset_add(seq, 4, /*ismember*/1); seqset_add(seq, 5, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 2); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 5); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -225,7 +212,6 @@ static void test_dup(void) CU_ASSERT_PTR_NOT_NULL(seq2); CU_ASSERT_PTR_NOT_EQUAL(seq, seq2); - CU_ASSERT_EQUAL(seq->len, 2); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 5); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -239,7 +225,6 @@ static void test_dup(void) CU_ASSERT_STRING_EQUAL(s, "1,3:5"); free(s); - CU_ASSERT_EQUAL(seq2->len, 2); CU_ASSERT_EQUAL(seqset_first(seq2), 1); CU_ASSERT_EQUAL(seqset_last(seq2), 5); CU_ASSERT_EQUAL(seqset_ismember(seq2, 0), 0); @@ -253,13 +238,13 @@ static void test_dup(void) CU_ASSERT_STRING_EQUAL(s, "1,3:5"); free(s); - seqset_free(seq); - seqset_free(seq2); + seqset_free(&seq); + seqset_free(&seq2); } static void test_iteration(void) { - struct seqset *seq; + seqset_t *seq; unsigned i; char *s; @@ -272,7 +257,6 @@ static void test_iteration(void) seqset_add(seq, 4, /*ismember*/1); seqset_add(seq, 5, /*ismember*/1); - CU_ASSERT_EQUAL(seq->len, 2); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 5); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -286,9 +270,7 @@ static void test_iteration(void) CU_ASSERT_STRING_EQUAL(s, "1,3:5"); free(s); - /* HACK */ - seq->prev = 0; - seq->current = 0; + seqset_reset(seq); i = seqset_getnext(NULL); CU_ASSERT_EQUAL(i, 0); @@ -306,18 +288,17 @@ static void test_iteration(void) i = seqset_getnext(seq); CU_ASSERT_EQUAL(i, 0); - seqset_free(seq); + seqset_free(&seq); } static void test_parse(void) { - struct seqset *seq; + seqset_t *seq; char *s; seq = seqset_parse("1", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 1); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -327,14 +308,13 @@ static void test_parse(void) CU_ASSERT_STRING_EQUAL(s, "1"); free(s); - seqset_free(seq); + seqset_free(&seq); /* ----- */ seq = seqset_parse("1:3", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 3); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -346,14 +326,13 @@ static void test_parse(void) CU_ASSERT_STRING_EQUAL(s, "1:3"); free(s); - seqset_free(seq); + seqset_free(&seq); /* ----- */ seq = seqset_parse("1:3,5", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 2); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 5); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -367,14 +346,13 @@ static void test_parse(void) CU_ASSERT_STRING_EQUAL(s, "1:3,5"); free(s); - seqset_free(seq); + seqset_free(&seq); /* ----- */ seq = seqset_parse("1:3,5,8:11", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 3); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), 11); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -394,14 +372,13 @@ static void test_parse(void) CU_ASSERT_STRING_EQUAL(s, "1:3,5,8:11"); free(s); - seqset_free(seq); + seqset_free(&seq); /* ----- */ seq = seqset_parse("1:*", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), UINT_MAX); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -413,14 +390,13 @@ static void test_parse(void) CU_ASSERT_STRING_EQUAL(s, "1:*"); free(s); - seqset_free(seq); + seqset_free(&seq); /* ----- */ seq = seqset_parse("1:3,5:*", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 2); CU_ASSERT_EQUAL(seqset_first(seq), 1); CU_ASSERT_EQUAL(seqset_last(seq), UINT_MAX); CU_ASSERT_EQUAL(seqset_ismember(seq, 0), 0); @@ -436,7 +412,7 @@ static void test_parse(void) CU_ASSERT_STRING_EQUAL(s, "1:3,5:*"); free(s); - seqset_free(seq); + seqset_free(&seq); } #if 0 @@ -445,7 +421,7 @@ static void test_parse(void) // subtly buggy corner case in Cyrus. So just ignore. static void XXX_test_star(void) { - struct seqset *seq; + seqset_t *seq; char *s; /* The "*" character is specified in RFC 3501 with what might be @@ -456,7 +432,6 @@ static void XXX_test_star(void) seq = seqset_parse("*", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); - CU_ASSERT_EQUAL(seq->len, 1); CU_ASSERT_EQUAL(seqset_first(seq), UINT_MAX); CU_ASSERT_EQUAL(seqset_last(seq), UINT_MAX); s = seqset_cstring(seq); @@ -498,20 +473,20 @@ static void XXX_test_star(void) CU_ASSERT_EQUAL(seqset_ismember(seq, 7), 0); CU_ASSERT_EQUAL(seqset_ismember(seq, 8), 0); - seqset_free(seq); + seqset_free(&seq); } #endif static void test_join(void) { - struct seqset *res = seqset_init(0, SEQ_MERGE); - struct seqset *seq; + seqset_t *res = seqset_init(0, SEQ_MERGE); + seqset_t *seq; char *s; seq = seqset_parse("1:100", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); seqset_join(res, seq); - seqset_free(seq); + seqset_free(&seq); s = seqset_cstring(res); CU_ASSERT_STRING_EQUAL(s, "1:100"); @@ -520,18 +495,18 @@ static void test_join(void) seq = seqset_parse("1:5,7:30,40:99", NULL, 0); CU_ASSERT_PTR_NOT_NULL_FATAL(seq); seqset_join(res, seq); - seqset_free(seq); + seqset_free(&seq); s = seqset_cstring(res); CU_ASSERT_STRING_EQUAL(s, "1:100"); free(s); - seqset_free(res); + seqset_free(&res); } static void test_simplify(void) { - struct seqset *seq; + seqset_t *seq; char *s; seq = seqset_parse("1:3,4,5:8,10,12:12,13:14,16:20", NULL, 0); @@ -541,7 +516,38 @@ static void test_simplify(void) CU_ASSERT_STRING_EQUAL(s, "1:8,10,12:14,16:20"); free(s); - seqset_free(seq); + seqset_free(&seq); +} + +static void test_remove(void) +{ + seqset_t *seq; + char *s; + + seq = seqset_parse("1:8,10,12:14,16:20", NULL, 0); + CU_ASSERT_PTR_NOT_NULL_FATAL(seq); + + seqset_remove(seq, 18); + s = seqset_cstring(seq); + CU_ASSERT_STRING_EQUAL(s, "1:8,10,12:14,16:17,19:20"); + free(s); + + seqset_remove(seq, 1); + s = seqset_cstring(seq); + CU_ASSERT_STRING_EQUAL(s, "2:8,10,12:14,16:17,19:20"); + free(s); + + seqset_remove(seq, 14); + s = seqset_cstring(seq); + CU_ASSERT_STRING_EQUAL(s, "2:8,10,12:13,16:17,19:20"); + free(s); + + seqset_remove(seq, 10); + s = seqset_cstring(seq); + CU_ASSERT_STRING_EQUAL(s, "2:8,12:13,16:17,19:20"); + free(s); + + seqset_free(&seq); } /* vim: set ft=c: */ diff --git a/cunit/sieve.testc b/cunit/sieve.testc index a459a2d0a9..ff230b4ef6 100644 --- a/cunit/sieve.testc +++ b/cunit/sieve.testc @@ -24,6 +24,7 @@ #include "xstrlcat.h" #include "xstrlcpy.h" #include "xmalloc.h" +#include "xunlink.h" #define DBDIR "test-sieve-dbdir" #define PARTITION "default" @@ -327,10 +328,10 @@ static int getbody(void *mc, const char **content_types, sieve_bodypart_t ***par /* parse the message body if we haven't already */ FILE *fp = fopen(msg->filename, "r"); CU_ASSERT_PTR_NOT_NULL(fp); - r = message_parse_file(fp, - &msg->content.base, - &msg->content.len, - &msg->content.body); + r = message_parse_file_buf(fp, + &msg->content.map, + &msg->content.body, + msg->filename); CU_ASSERT_EQUAL(r, 0); fclose(fp); } @@ -403,12 +404,17 @@ static int reject(void *ac, void *ic, void *sc __attribute__((unused)), } static int fileinto(void *ac, void *ic, void *sc __attribute__((unused)), - void *mc __attribute__((unused)), + void *mc, const char **errmsg __attribute__((unused))) { sieve_fileinto_context_t *fc = (sieve_fileinto_context_t *)ac; sieve_test_context_t *ctx = (sieve_test_context_t *)ic; + if (!mc) { + /* just doing destination mailbox resolution */ + return SIEVE_OK; + } + ctx->stats.actions++; ctx->stats.fileintos++; free(ctx->filed_mailbox); @@ -423,12 +429,17 @@ static int fileinto(void *ac, void *ic, void *sc __attribute__((unused)), } static int keep(void *ac, void *ic, void *sc __attribute__((unused)), - void *mc __attribute__((unused)), + void *mc, const char **errmsg __attribute__((unused))) { sieve_keep_context_t *kc = (sieve_keep_context_t *)ac; sieve_test_context_t *ctx = (sieve_test_context_t *)ic; + if (!mc) { + /* just doing destination mailbox resolution */ + return SIEVE_OK; + } + ctx->stats.actions++; ctx->stats.keeps++; free(ctx->filed_flags); @@ -447,12 +458,12 @@ static int notify(void *ac, void *ic, void *sc __attribute__((unused)), sieve_notify_context_t *nc = (sieve_notify_context_t *)ac; sieve_test_context_t *ctx = (sieve_test_context_t *)ic; struct buf opts = BUF_INITIALIZER; - const char **p; + int i; - for (p = nc->options ; *p ; p++) { + for (i = 0; i < strarray_size(nc->options); i++) { if (opts.len) buf_putc(&opts, ' '); - buf_appendcstr(&opts, *p); + buf_appendcstr(&opts, strarray_nth(nc->options, i)); } ctx->stats.actions++; @@ -539,18 +550,6 @@ static FILE *fmemopen(const void *buf, size_t len, const char *mode) } #endif -static void config_read_string(const char *s) -{ - char *fname = xstrdup("/tmp/cyrus-cunit-configXXXXXX"); - int fd = mkstemp(fname); - retry_write(fd, s, strlen(s)); - config_reset(); - config_read(fname, 0); - unlink(fname); - free(fname); - close(fd); -} - static int set_up(void) { libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); @@ -569,31 +568,33 @@ static int set_up(void) static int tear_down(void) { + int r; + libcyrus_done(); - return 0; + config_reset(); + + r = system("rm -rf " DBDIR); + + return r; } static void context_setup(sieve_test_context_t *ctx, const char *script) { int r; - static strarray_t mark = STRARRAY_INITIALIZER; static sieve_vacation_t vacation = { 0, /* min response */ 0, /* max response */ &autorespond, /* autorespond() */ &send_response /* send_response() */ }; - int len = strlen(script); + char *errors = NULL; int fd; - FILE *fp; sieve_script_t *scr = NULL; bytecode_info_t *bytecode = NULL; char tempfile[32]; memset(ctx, 0, sizeof(*ctx)); - if (!mark.count) - strarray_append(&mark, "\\flagged"); ctx->compile_errors = strarray_new(); ctx->run_errors = strarray_new(); @@ -610,17 +611,13 @@ static void context_setup(sieve_test_context_t *ctx, sieve_register_body(ctx->interp, getbody); sieve_register_include(ctx->interp, getinclude); sieve_register_vacation(ctx->interp, &vacation); - sieve_register_imapflags(ctx->interp, &mark); sieve_register_notify(ctx->interp, notify, NULL); sieve_register_parse_error(ctx->interp, mysieve_error); sieve_register_execute_error(ctx->interp, mysieve_execute_error); - /* Here we pretend to be the sieve compiler, and generate - * a file of compiled bytecode from the script string */ - fp = fmemopen((void *)script, len, "r"); - r = sieve_script_parse(ctx->interp, fp, ctx, &scr); + r = sieve_script_parse_string(ctx->interp, script, &errors, &scr); CU_ASSERT_EQUAL(r, SIEVE_OK); - fclose(fp); + free(errors); r = sieve_generate_bytecode(&bytecode, scr); CU_ASSERT(r > 0); @@ -635,7 +632,7 @@ static void context_setup(sieve_test_context_t *ctx, /* Now load the compiled bytecode */ r = sieve_script_load(tempfile, &ctx->exe); CU_ASSERT_EQUAL(r, SIEVE_OK); - unlink(tempfile); + xunlink(tempfile); } static void context_cleanup(sieve_test_context_t *ctx) @@ -756,9 +753,8 @@ static void message_free(sieve_test_message_t *msg) spool_free_hdrcache(msg->headers); if (msg->content.body) message_free_body(msg->content.body); - if (msg->content.base) - map_free(&msg->content.base, &msg->content.len); - unlink(msg->filename); + buf_free(&msg->content.map); + xunlink(msg->filename); free(msg->filename); free(msg); } diff --git a/cunit/slowio.testc b/cunit/slowio.testc new file mode 100644 index 0000000000..38d4207376 --- /dev/null +++ b/cunit/slowio.testc @@ -0,0 +1,132 @@ +#include "cunit/cyrunit.h" +#include "lib/slowio.h" +#include "lib/util.h" + +#include + +static void test_initialize(void) +{ + const struct slowio slowio_zero = {0}; + struct slowio slowio = {0}; + + /* negative n_bytes is never valid, not even to initialize */ + slowio_maybe_delay_impl(&slowio, -1); + CU_ASSERT_EQUAL(0, memcmp(&slowio, &slowio_zero, sizeof(slowio))); + + /* zero n_bytes should initialize */ + slowio_maybe_delay_impl(&slowio, 0); + CU_ASSERT_NOT_EQUAL(0, memcmp(&slowio, &slowio_zero, sizeof(slowio))); + + /* positive n_bytes should initialize */ + memset(&slowio, 0, sizeof(slowio)); + slowio_maybe_delay_impl(&slowio, 1); + CU_ASSERT_NOT_EQUAL(0, memcmp(&slowio, &slowio_zero, sizeof(slowio))); +} + +static void test_limit1(void) +{ + struct slowio slowio = {0}, saved_slowio; + int64_t start, end, diff; + int i; + + /* first call to initialize */ + slowio_maybe_delay_impl(&slowio, 0); + + /* long individual delays */ + for (i = 1; i <= 4; i++) { + memcpy(&saved_slowio, &slowio, sizeof(slowio)); + start = now_ms(); + slowio_maybe_delay_impl(&slowio, i * SLOWIO_MAX_BYTES_SEC); + end = now_ms(); + CU_ASSERT_NOT_EQUAL(0, memcmp(&slowio, &saved_slowio, sizeof(slowio))); + CU_ASSERT_EQUAL(0, slowio.bytes_since_last_delay); + + diff = end - start; + CU_ASSERT(diff >= 0.8 * (i * 1000)); + CU_ASSERT(diff <= 1.2 * (i * 1000)); + } +} + +static void test_limit2(void) +{ + struct slowio slowio = {0}, saved_slowio; + int64_t start, end, diff; + int i; + + /* first call to initialize */ + slowio_maybe_delay_impl(&slowio, 0); + + /* many small calls, collectively delayed */ + for (i = 1; i <= 4; i++) { + const unsigned steps_per_sec = 20; + const unsigned bytes_per_step = SLOWIO_MAX_BYTES_SEC / steps_per_sec; + const unsigned ms_per_step = 1000 / steps_per_sec; + unsigned total_bytes = i * SLOWIO_MAX_BYTES_SEC; + unsigned j; + + start = now_ms(); + for (j = 0; j < total_bytes; j += bytes_per_step) { + int64_t inner_start, inner_end, inner_diff; + + memcpy(&saved_slowio, &slowio, sizeof(slowio)); + inner_start = now_ms(); + slowio_maybe_delay_impl(&slowio, bytes_per_step); + inner_end = now_ms(); + + inner_diff = inner_end - inner_start; + + /* we're not doing any actual I/O between calls to + * slowio_maybe_delay_impl, so it'll look like we're doing our I/O + * extremely fast, and so every call will add some delay + */ + CU_ASSERT_EQUAL(0, slowio.bytes_since_last_delay); + + /* looser tolerances -- timings are swingier at this granularity */ + if (verbose && (inner_diff <= 0.25 * ms_per_step + || inner_diff >= 4.0 * ms_per_step)) + { + fprintf(stderr, "%s: inner_diff(%" PRIi64 ") too large/small" + " vs ms_per_step(%u..%u)\n", + __func__, inner_diff, + (unsigned)(0.25 * ms_per_step), + (unsigned)(4.0 * ms_per_step)); + } + CU_ASSERT(inner_diff > 0.25 * ms_per_step); + CU_ASSERT(inner_diff < 4.0 * ms_per_step); + } + end = now_ms(); + + diff = end - start; + CU_ASSERT(diff >= 0.8 * (i * 1000)); + CU_ASSERT(diff <= 1.2 * (i * 1000)); + } +} + +static void test_slower_than_rate_limit(void) +{ + struct slowio slowio = {0}, saved_slowio; + int64_t start, end, diff; + int i; + + /* first call to initialize */ + slowio_maybe_delay_impl(&slowio, 0); + + /* pretend we're reading/writing slightly slower than the rate limit */ + for (i = 1; i <= 4; i++) { + struct timespec io_delay = { i, 1000000 }; /* 1 ms over */ + nanosleep(&io_delay, NULL); + + memcpy(&saved_slowio, &slowio, sizeof(slowio)); + start = now_ms(); + slowio_maybe_delay_impl(&slowio, i * SLOWIO_MAX_BYTES_SEC); + end = now_ms(); + CU_ASSERT_NOT_EQUAL(0, memcmp(&slowio, &saved_slowio, sizeof(slowio))); + CU_ASSERT_NOT_EQUAL(0, slowio.bytes_since_last_delay); + + diff = end - start; + /* should have taken basically no time at all */ + CU_ASSERT(diff < 2 /* ms! */); + } +} + +/* vim: set ft=c: */ diff --git a/cunit/smallarrayu64.testc b/cunit/smallarrayu64.testc new file mode 100644 index 0000000000..6a6bab68a8 --- /dev/null +++ b/cunit/smallarrayu64.testc @@ -0,0 +1,82 @@ +#include "cunit/cyrunit.h" +#include "xmalloc.h" +#include "smallarrayu64.h" + +static void test_fini_null(void) +{ + /* _fini(NULL) is harmless */ + smallarrayu64_fini(NULL); + /* _free(NULL) is harmless */ + smallarrayu64_free(NULL); +} + +static void test_append(void) +{ + smallarrayu64_t sa = SMALLARRAYU64_INITIALIZER; + + /* Append small integers until prealloc buffer is full */ + int i; + for (i = 0; i < SMALLARRAYU64_ALLOC; i++) { + smallarrayu64_append(&sa, i); + CU_ASSERT_EQUAL(smallarrayu64_size(&sa), i + 1); + CU_ASSERT_EQUAL(sa.use_spillover, i == SMALLARRAYU64_ALLOC - 1); + CU_ASSERT_EQUAL(sa.spillover.count, 0); + } + + /* Append next integer */ + smallarrayu64_append(&sa, SMALLARRAYU64_ALLOC); + CU_ASSERT_EQUAL(smallarrayu64_size(&sa), SMALLARRAYU64_ALLOC + 1); + CU_ASSERT_EQUAL(sa.count, SMALLARRAYU64_ALLOC); + CU_ASSERT_EQUAL(sa.use_spillover, 1); + CU_ASSERT_EQUAL(sa.spillover.count, 1); + + smallarrayu64_fini(&sa); +} + +static void test_append_largenum(void) +{ + smallarrayu64_t sa = SMALLARRAYU64_INITIALIZER; + + smallarrayu64_append(&sa, 12); + smallarrayu64_append(&sa, 24); + smallarrayu64_append(&sa, 36); + + CU_ASSERT_EQUAL(sa.count, 3); + CU_ASSERT_EQUAL(sa.use_spillover, 0); + CU_ASSERT_EQUAL(sa.spillover.count, 0); + + smallarrayu64_append(&sa, 2222222L); + + CU_ASSERT_EQUAL(sa.count, 3); + CU_ASSERT_EQUAL(sa.use_spillover, 1); + CU_ASSERT_EQUAL(sa.spillover.count, 1); + + smallarrayu64_fini(&sa); +} + +static void test_nth(void) +{ + smallarrayu64_t sa = SMALLARRAYU64_INITIALIZER; + uint64_t vals[] = { 12L, 24L, 36L, 2222222L, 48L }; + ssize_t nvals = sizeof(vals) / sizeof(vals[0]); + + ssize_t i; + for (i = 0; i < nvals; i++) { + smallarrayu64_append(&sa, vals[i]); + } + for (i = 0; i < nvals; i++) { + CU_ASSERT_EQUAL(smallarrayu64_nth(&sa, i), vals[i]); + } + + /* negative index */ + CU_ASSERT_EQUAL(smallarrayu64_nth(&sa, -nvals), vals[0]); + CU_ASSERT_EQUAL(smallarrayu64_nth(&sa, -1), vals[nvals-1]); + + /* out of index */ + CU_ASSERT_EQUAL(smallarrayu64_nth(&sa, nvals), 0); + CU_ASSERT_EQUAL(smallarrayu64_nth(&sa, -nvals-1), 0); + + smallarrayu64_fini(&sa); +} + +/* vim: set ft=c: */ diff --git a/cunit/spool.testc b/cunit/spool.testc index d0967c999c..c9726bb1fc 100644 --- a/cunit/spool.testc +++ b/cunit/spool.testc @@ -4,14 +4,19 @@ #include "prot.h" #include "retry.h" #include "xmalloc.h" +#include "lib/libconfig.h" +#include "lib/libcyr_cfg.h" #include "imap/spool.h" +#include "xunlink.h" +#define DBDIR "test-dbdir" #define DELIVERED "Fri, 29 Oct 2010 13:07:07 +1100" #define FIRST_RX "Fri, 29 Oct 2010 13:05:01 +1100" #define SECOND_RX "Fri, 29 Oct 2010 13:03:03 +1100" #define THIRD_RX "Fri, 29 Oct 2010 13:01:01 +1100" #define SENT "Thu, 28 Oct 2010 18:37:26 +1100" #define HFROM "Fred Bloggs " +#define HFROMFOLD "Fred Bloggs\r\n " #define HFROM2 "Antoine Lavoisier " #define HTO "Sarah Jane Smith " #define HDATE SENT @@ -21,6 +26,28 @@ #define HRECEIVED2 "from mail.bar.com (mail.bar.com [10.0.0.1]) by mail.quux.com (Software); " SECOND_RX #define HRECEIVED3 "from mail.fastmail.fm (mail.fastmail.fm [10.0.0.1]) by mail.bar.com (Software); " THIRD_RX +static int set_up(void) +{ + /* need basic configuration for parseheader */ + libcyrus_config_setstring(CYRUSOPT_CONFIG_DIR, DBDIR); + config_read_string( + "configdirectory: "DBDIR"/conf\n" + ); + + return 0; +} + +static int tear_down(void) +{ + int r; + + config_reset(); + + r = system("rm -rf " DBDIR); + + return r; +} + static void test_simple(void) { hdrcache_t cache; @@ -186,7 +213,189 @@ static void test_fill(void) spool_free_hdrcache(cache); fclose(fout); prot_free(pin); - unlink(tempfile); + xunlink(tempfile); +} + +static void test_folded_headers(void) +{ + static const char MSG[] = +"From: " HFROMFOLD "\r\n" /* mid-value folding */ +"Message-ID:\r\n " HMESSAGEID "\r\n" /* leading whitespace is folded */ +"\r\n" +"Hello, World\r\n"; + + hdrcache_t cache; + const char **val; + int fd; + char tempfile[32]; + int r; + struct protstream *pin; + FILE *fout; + + /* Setup @pin to point to the start of a file open for (at least) + * reading containing the message. */ + strcpy(tempfile, "/tmp/spooltestAXXXXXX"); + fd = mkstemp(tempfile); + CU_ASSERT(fd >= 0); + r = retry_write(fd, MSG, sizeof(MSG)-1); + CU_ASSERT_EQUAL(r, sizeof(MSG)-1); + lseek(fd, SEEK_SET, 0); + pin = prot_new(fd, /*read*/0); + CU_ASSERT_PTR_NOT_NULL(pin); + + /* Setup @fout to ignore data written to it */ + fout = fopen("/dev/null", "w"); + CU_ASSERT_PTR_NOT_NULL(fout); + + cache = spool_new_hdrcache(); + CU_ASSERT_PTR_NOT_NULL(cache); + + /* TODO: test non-NULL skipheaders */ + r = spool_fill_hdrcache(pin, fout, cache, NULL); + CU_ASSERT_EQUAL(r, 0); + + val = spool_getheader(cache, "Nonesuch"); + CU_ASSERT_PTR_NULL(val); + val = spool_getheader(cache, "From"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], HFROM); + CU_ASSERT_PTR_NULL(val[1]); + val = spool_getheader(cache, "fRoM"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], HFROM); + CU_ASSERT_PTR_NULL(val[1]); + val = spool_getheader(cache, "from"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], HFROM); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "message-id"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], HMESSAGEID); + CU_ASSERT_PTR_NULL(val[1]); + + spool_free_hdrcache(cache); + fclose(fout); + prot_free(pin); + xunlink(tempfile); +} + +static void test_empty_headers(void) +{ + static const char MSG[] = +"From: " HFROM "\r\n" +"Message-ID: " HMESSAGEID "\r\n" +"Empty1:\r\n" /* not even a leading space */ +"Empty2: \r\n" /* leading space */ +"Empty3:\r\n \r\n" /* folded leading space */ +"Empty4:\r" /* same, with bare CR */ +"Empty5: \r" +"Empty6:\r \r" +"Empty7:\n" /* same, with bare LF */ +"Empty8: \n" +"Empty9:\n \n" +"\r\n" +"Hello, World\r\n"; + + hdrcache_t cache; + const char **val; + int fd; + char tempfile[32]; + int r; + struct protstream *pin; + FILE *fout; + + /* Setup @pin to point to the start of a file open for (at least) + * reading containing the message. */ + strcpy(tempfile, "/tmp/spooltestAXXXXXX"); + fd = mkstemp(tempfile); + CU_ASSERT(fd >= 0); + r = retry_write(fd, MSG, sizeof(MSG)-1); + CU_ASSERT_EQUAL(r, sizeof(MSG)-1); + lseek(fd, SEEK_SET, 0); + pin = prot_new(fd, /*read*/0); + CU_ASSERT_PTR_NOT_NULL(pin); + + /* Setup @fout to ignore data written to it */ + fout = fopen("/dev/null", "w"); + CU_ASSERT_PTR_NOT_NULL(fout); + + cache = spool_new_hdrcache(); + CU_ASSERT_PTR_NOT_NULL(cache); + + /* TODO: test non-NULL skipheaders */ + r = spool_fill_hdrcache(pin, fout, cache, NULL); + CU_ASSERT_EQUAL(r, 0); + + val = spool_getheader(cache, "Nonesuch"); + CU_ASSERT_PTR_NULL(val); + val = spool_getheader(cache, "From"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], HFROM); + CU_ASSERT_PTR_NULL(val[1]); + val = spool_getheader(cache, "fRoM"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], HFROM); + CU_ASSERT_PTR_NULL(val[1]); + val = spool_getheader(cache, "from"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], HFROM); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "message-id"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], HMESSAGEID); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty1"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty2"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty3"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty4"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty5"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty6"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty7"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty8"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + val = spool_getheader(cache, "empty9"); + CU_ASSERT_PTR_NOT_NULL(val); + CU_ASSERT_STRING_EQUAL(val[0], ""); + CU_ASSERT_PTR_NULL(val[1]); + + spool_free_hdrcache(cache); + fclose(fout); + prot_free(pin); + xunlink(tempfile); } /* BZ3640: headers with NULL bytes shall be rejected. */ @@ -235,7 +444,7 @@ static void test_fill_null(void) spool_free_hdrcache(cache); fclose(fout); prot_free(pin); - unlink(tempfile); + xunlink(tempfile); } /* BZ3386: insert more unique headers than the internal limit of 4009 diff --git a/cunit/strarray.testc b/cunit/strarray.testc index 376fac4270..243f62af7f 100644 --- a/cunit/strarray.testc +++ b/cunit/strarray.testc @@ -598,7 +598,8 @@ static void test_split(void) strarray_free(sa); /* splitm - takes ownership of a strdup()d argument */ - sa = strarray_splitm(xstrdup(WORD0" "WORD1" "WORD2" "WORD3" "WORD4), " ", 0); + sa = strarray_new(); + strarray_splitm(sa, xstrdup(WORD0" "WORD1" "WORD2" "WORD3" "WORD4), " ", 0); CU_ASSERT_PTR_NOT_NULL(sa); CU_ASSERT_EQUAL(sa->count, 5); CU_ASSERT(sa->alloc >= sa->count); @@ -656,6 +657,133 @@ static void test_split(void) #undef WORD4 } +static void test_split_lcase(void) +{ + strarray_t *sa; +#define WORD0 "LORem" +#define WORD1 "ipSUM" +#define WORD2 "DoLoR" +#define WORD3 "siT" +#define WORD4 "Amet" + +#define WORD0lc "lorem" +#define WORD1lc "ipsum" +#define WORD2lc "dolor" +#define WORD3lc "sit" +#define WORD4lc "amet" + + /* 5 words, space separator */ + sa = strarray_split(WORD0" "WORD1" "WORD2" "WORD3" "WORD4, + " ", STRARRAY_LCASE); + CU_ASSERT_PTR_NOT_NULL(sa); + CU_ASSERT_EQUAL(sa->count, 5); + CU_ASSERT(sa->alloc >= sa->count); + CU_ASSERT_PTR_NOT_NULL(sa->data); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 0), WORD0lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 1), WORD1lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 2), WORD2lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 3), WORD3lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 4), WORD4lc); + strarray_free(sa); + + /* 5 words, NULL separator (whitespace) */ + sa = strarray_split(WORD0" "WORD1"\t"WORD2"\r"WORD3"\n"WORD4, + NULL, STRARRAY_LCASE); + CU_ASSERT_PTR_NOT_NULL(sa); + CU_ASSERT_EQUAL(sa->count, 5); + CU_ASSERT(sa->alloc >= sa->count); + CU_ASSERT_PTR_NOT_NULL(sa->data); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 0), WORD0lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 1), WORD1lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 2), WORD2lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 3), WORD3lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 4), WORD4lc); + strarray_free(sa); + + /* 5 words, several separators */ + sa = strarray_split(WORD0"("WORD1")"WORD2"["WORD3"]"WORD4, + "[]()", STRARRAY_LCASE); + CU_ASSERT_PTR_NOT_NULL(sa); + CU_ASSERT_EQUAL(sa->count, 5); + CU_ASSERT(sa->alloc >= sa->count); + CU_ASSERT_PTR_NOT_NULL(sa->data); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 0), WORD0lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 1), WORD1lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 2), WORD2lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 3), WORD3lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 4), WORD4lc); + strarray_free(sa); + + /* splitm - takes ownership of a strdup()d argument */ + sa = strarray_new(); + strarray_splitm(sa, xstrdup(WORD0" "WORD1" "WORD2" "WORD3" "WORD4), + " ", STRARRAY_LCASE); + CU_ASSERT_PTR_NOT_NULL(sa); + CU_ASSERT_EQUAL(sa->count, 5); + CU_ASSERT(sa->alloc >= sa->count); + CU_ASSERT_PTR_NOT_NULL(sa->data); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 0), WORD0lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 1), WORD1lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 2), WORD2lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 3), WORD3lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 4), WORD4lc); + strarray_free(sa); + + /* nsplit - specify a byte range to copy and split */ + sa = strarray_nsplit(WORD0" "WORD1" "WORD2" "WORD3" "WORD4, + sizeof(WORD0)+sizeof(WORD1)+sizeof(WORD2), + " ", STRARRAY_LCASE); + CU_ASSERT_PTR_NOT_NULL(sa); + CU_ASSERT_EQUAL(sa->count, 3); + CU_ASSERT(sa->alloc >= sa->count); + CU_ASSERT_PTR_NOT_NULL(sa->data); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 0), WORD0lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 1), WORD1lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 2), WORD2lc); + strarray_free(sa); + + /* split with surrounding whitespace */ + sa = strarray_split(WORD0"| "WORD1" | "WORD2" |"WORD3" | "WORD4"| ", + "|", STRARRAY_LCASE); + CU_ASSERT_PTR_NOT_NULL(sa); + CU_ASSERT_EQUAL(sa->count, 6); + CU_ASSERT(sa->alloc >= sa->count); + CU_ASSERT_PTR_NOT_NULL(sa->data); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 0), WORD0lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 1), " "WORD1lc" "); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 2), " "WORD2lc" "); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 3), WORD3lc" "); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 4), " "WORD4lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 5), " "); + strarray_free(sa); + + /* trim surrounding whitespace */ + sa = strarray_split(WORD0"| "WORD1" | "WORD2" |"WORD3" | "WORD4"| ", + "|", STRARRAY_TRIM | STRARRAY_LCASE); + CU_ASSERT_PTR_NOT_NULL(sa); + CU_ASSERT_EQUAL(sa->count, 5); + CU_ASSERT(sa->alloc >= sa->count); + CU_ASSERT_PTR_NOT_NULL(sa->data); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 0), WORD0lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 1), WORD1lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 2), WORD2lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 3), WORD3lc); + CU_ASSERT_STRING_EQUAL(strarray_nth(sa, 4), WORD4lc); + strarray_free(sa); + +#undef WORD0 +#undef WORD1 +#undef WORD2 +#undef WORD3 +#undef WORD4 + +#undef WORD0lc +#undef WORD1lc +#undef WORD2lc +#undef WORD3lc +#undef WORD4lc +} + static void test_remove(void) { strarray_t sa = STRARRAY_INITIALIZER; @@ -1352,6 +1480,7 @@ static void test_sortrawuniq(void) CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 3), WORD3); CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 4), WORD2); + strarray_fini(&sa); #undef WORD0 #undef WORD1 #undef WORD2 @@ -1403,6 +1532,7 @@ static void test_sortmboxuniq(void) CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 3), WORD2); CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 4), WORD1); + strarray_fini(&sa); #undef WORD0 #undef WORD1 #undef WORD2 @@ -1554,4 +1684,90 @@ static void test_add_case(void) #undef WORD1 } +static void test_swap(void) +{ + strarray_t sa = STRARRAY_INITIALIZER; +#define WORD0 "lorem" +#define WORD1 "ipsum" + + /* swap is safe for bad index */ + strarray_swap(&sa, 0, 1); + + /* swap is safe for same index */ + strarray_insert(&sa, 0, WORD0); + CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 0), WORD0); + strarray_swap(&sa, 0, 0); + CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 0), WORD0); + CU_ASSERT_EQUAL(strarray_size(&sa), 1); + + /* swap */ + strarray_insert(&sa, 1, WORD1); + CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 0), WORD0); + CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 1), WORD1); + strarray_swap(&sa, 0, 1); + CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 0), WORD1); + CU_ASSERT_STRING_EQUAL(strarray_nth(&sa, 1), WORD0); + CU_ASSERT_EQUAL(strarray_size(&sa), 2); + + strarray_fini(&sa); +#undef WORD0 +#undef WORD1 +} + +static void test_appendv(void) +{ + strarray_t sa = STRARRAY_INITIALIZER; + const char *s; + + s = strarray_appendv(&sa, "lorem"); + CU_ASSERT_PTR_EQUAL(s, strarray_nth(&sa, 0)); + CU_ASSERT_EQUAL(strarray_size(&sa), 1); + + s = strarray_appendv(&sa, "ipsum"); + CU_ASSERT_PTR_EQUAL(s, strarray_nth(&sa, 1)); + CU_ASSERT_EQUAL(strarray_size(&sa), 2); + + strarray_fini(&sa); +} + +static void test_addv(void) +{ + strarray_t sa = STRARRAY_INITIALIZER; + const char *s; + + s = strarray_addv(&sa, "lorem"); + CU_ASSERT_PTR_EQUAL(s, strarray_nth(&sa, 0)); + CU_ASSERT_EQUAL(strarray_size(&sa), 1); + + s = strarray_addv(&sa, "ipsum"); + CU_ASSERT_PTR_EQUAL(s, strarray_nth(&sa, 1)); + CU_ASSERT_EQUAL(strarray_size(&sa), 2); + + s = strarray_addv(&sa, "lorem"); + CU_ASSERT_PTR_EQUAL(s, strarray_nth(&sa, 0)); + CU_ASSERT_EQUAL(strarray_size(&sa), 2); + + strarray_fini(&sa); +} + +static void test_add_casev(void) +{ + strarray_t sa = STRARRAY_INITIALIZER; + const char *s; + + s = strarray_add_casev(&sa, "lorem"); + CU_ASSERT_PTR_EQUAL(s, strarray_nth(&sa, 0)); + CU_ASSERT_EQUAL(strarray_size(&sa), 1); + + s = strarray_add_casev(&sa, "ipsum"); + CU_ASSERT_PTR_EQUAL(s, strarray_nth(&sa, 1)); + CU_ASSERT_EQUAL(strarray_size(&sa), 2); + + s = strarray_add_casev(&sa, "lOREm"); + CU_ASSERT_PTR_EQUAL(s, strarray_nth(&sa, 0)); + CU_ASSERT_EQUAL(strarray_size(&sa), 2); + + strarray_fini(&sa); +} + /* vim: set ft=c: */ diff --git a/cunit/strconcat.testc b/cunit/strconcat.testc index 77c606acf7..cee8ea1929 100644 --- a/cunit/strconcat.testc +++ b/cunit/strconcat.testc @@ -1,4 +1,4 @@ -#include +#include #include "cunit/cyrunit.h" #include "util.h" diff --git a/cunit/strhash.testc b/cunit/strhash.testc new file mode 100644 index 0000000000..bd06df5ff5 --- /dev/null +++ b/cunit/strhash.testc @@ -0,0 +1,231 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include "cunit/cyrunit.h" +#include "lib/util.h" +#include "lib/strhash.h" + +extern int verbose; + +static const char *repeat(int c, unsigned n) +{ + static char buf[1024]; + char *p = buf; + + /* always leave room for a \0 */ + if (n >= sizeof(buf)) + n = sizeof(buf) - 1; + + memset(buf, 0, sizeof(buf)); + while (n--) { + *p++ = c; + } + + return buf; +} + +static void test_repeated(void) +{ + /* repeated chars on the end should not obliterate earlier input */ + unsigned suffix_lengths[] = { 15, 31, 63, 127, 255, 511, 1023 }; + unsigned i; + + for (i = 0; i < sizeof(suffix_lengths) / sizeof(suffix_lengths[0]); i++) { + char *cat = strconcat("cat", repeat('a', suffix_lengths[i]), NULL); + char *dog = strconcat("dog", repeat('a', suffix_lengths[i]), NULL); + char *mouse = strconcat("mouse", repeat('a', suffix_lengths[i]), NULL); + + unsigned xcat = strhash(cat); + unsigned xdog = strhash(dog); + unsigned xmouse = strhash(mouse); + + CU_ASSERT_NOT_EQUAL(xcat, xdog); + CU_ASSERT_NOT_EQUAL(xdog, xmouse); + CU_ASSERT_NOT_EQUAL(xmouse, xcat); + + free(cat); + free(dog); + free(mouse); + } +} + +static void test_seeded(void) +{ + const char *const words[] = { "lorem", "ipsum", "dolor", "sit", "amet" }; + const size_t n_words = sizeof(words) / sizeof(words[0]); + unsigned hashes[n_words]; + unsigned i, j; + + memset(hashes, 0, sizeof(hashes)); + + /* with no seed, same input should produce same hash */ + for (i = 0; i < n_words; i++) { + unsigned h1 = strhash(words[i]); + unsigned h2 = strhash(words[i]); + CU_ASSERT_EQUAL(h1, h2); + } + + /* with explicit zero seed, same input should produce same hash */ + for (i = 0; i < n_words; i++) { + unsigned h1 = strhash(words[i]); + unsigned h2 = strhash_seeded(0, words[i]); + unsigned h3 = strhash_seeded(0, words[i]); + CU_ASSERT_EQUAL(h1, h2); + CU_ASSERT_EQUAL(h2, h3); + CU_ASSERT_EQUAL(h3, h1); + } + + /* with some seed, same input should produce same hash */ + for (j = 0; j < 5; j++) { + uint32_t seed; + do { + seed = rand(); + } while (seed == 0); + + for (i = 0; i < n_words; i++) { + unsigned h1 = strhash_seeded(seed, words[i]); + unsigned h2 = strhash_seeded(seed, words[i]); + CU_ASSERT_EQUAL(h1, h2); + } + } + + /* with different seed, same input should produce different hash */ + for (i = 0; i < n_words; i++) { + uint32_t seed1, seed2; + do { + seed1 = rand(); + seed2 = rand(); + } while (seed1 == 0 || seed2 == 0 || seed1 == seed2); + + unsigned h1 = strhash_seeded(seed1, words[i]); + unsigned h2 = strhash_seeded(seed2, words[i]); + + CU_ASSERT_NOT_EQUAL(h1, h2); + } +} + +/* We can't define-out an entire test function when a feature is missing + * (in this case getline), because it confuses cunit.pl. So instead we + * make sure it will at least compile, but then return early without doing + * anything if the feature we wanted was missing. + */ +#ifndef HAVE_GETLINE +#define getline(a,b,c) (((void)(b)), -1) +#endif + +#define NBUCKETS (0x10000) +static void test_quality(void) +{ + const char *wordsfile = "/usr/share/dict/words"; + unsigned buckets[NBUCKETS] = {0}; + FILE *stream; + char *line = NULL; + size_t len = 0; + ssize_t nread; + unsigned i; + unsigned inputs = 0; + unsigned contains_none = 0; + unsigned contains_one = 0; + unsigned contains_many = 0; + unsigned contains_many_sum = 0; + unsigned highest_count = 0; + unsigned highest_count_freq = 0; + unsigned max_acceptable_count; + double load; + +#ifndef HAVE_GETLINE + /* can't do anything anyway */ + return; +#endif + + stream = fopen(wordsfile, "r"); + if (!stream) { + if (verbose) + fprintf(stderr, "%s: %s (skipping) ", wordsfile, strerror(errno)); + return; + } + + while ((nread = getline(&line, &len, stream)) != -1) { + /* chomp */ + if (line[nread - 1] == '\n') + line[nread - 1] = '\0'; + + unsigned hash = strhash_seeded_djb2(0, line) % NBUCKETS; +// unsigned hash = strhash_legacy(line) % NBUCKETS; + + buckets[hash]++; + inputs++; + } + free(line); + + /* arbitrary declaration of quality: no buckets should have more + * than ten times the expected load + */ + load = inputs * 1.0 / NBUCKETS; + max_acceptable_count = load * 10; + + unsigned bucket_counts[max_acceptable_count + 2]; + memset(bucket_counts, 0, sizeof(bucket_counts)); + + for (i = 0; i < NBUCKETS; i++) { + switch (buckets[i]) { + case 0: + contains_none++; + break; + case 1: + contains_one++; + break; + default: + contains_many++; + contains_many_sum += buckets[i]; + break; + } + + if (buckets[i] > max_acceptable_count) { + bucket_counts[max_acceptable_count+1]++; + } + else { + bucket_counts[buckets[i]]++; + } + + if (buckets[i] > highest_count) { + highest_count = buckets[i]; + highest_count_freq = 1; + } + else if (buckets[i] == highest_count) { + highest_count_freq++; + } + } + + if (verbose) { + putc('\n', stderr); + fprintf(stderr, "buckets: %u inputs: %u load: %g\n", + NBUCKETS, inputs, load); + fprintf(stderr, "empty: %u unique: %u busy: %u\n", + contains_none, contains_one, contains_many); + fprintf(stderr, "avg count in busy buckets: %g\n", + contains_many_sum * 1.0 / contains_many); + fprintf(stderr, "busiest %u buckets contain %u each\n", + highest_count_freq, highest_count); + fprintf(stderr, "max acceptable count: %u\n", max_acceptable_count); + fprintf(stderr, "\nbucket count histogram:\ncount frequency\n"); + for (i = 0; i <= max_acceptable_count; i++) { + fprintf(stderr, "%4u %u\n", i, bucket_counts[i]); + } + fprintf(stderr, "%4u+ %u\n", max_acceptable_count + 1, + bucket_counts[max_acceptable_count + 1]); + } + + CU_ASSERT_EQUAL(bucket_counts[max_acceptable_count + 1], 0); +} +#undef NBUCKETS + +/* vim: set ft=c: */ diff --git a/cunit/stristr.testc b/cunit/stristr.testc new file mode 100644 index 0000000000..68f2f3af3c --- /dev/null +++ b/cunit/stristr.testc @@ -0,0 +1,39 @@ +#include +#include +#include "cunit/cyrunit.h" +#include "util.h" + +#include "stristr.h" + +static void test_strstr(void) +{ + struct test { + const char *string; + const char *pattern; + } tests[] = {{ + "foo", "bar" + }, { + "foobarfoo", "bar" + }, { + "foo", "" + }, { + "", "bar" + }, { + "", "" + }}; + + for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { + char *s = xstrdup(tests[i].string); + char *p = xstrdup(tests[i].pattern); + + char *want = strstr(s, p); + char *have = stristr(s, p); + + CU_ASSERT_PTR_EQUAL(want, have); + + free(s); + free(p); + } +} + +/* vim: set ft=c: */ diff --git a/cunit/syslog.c b/cunit/syslog.c index a7128e96c3..80322a6030 100644 --- a/cunit/syslog.c +++ b/cunit/syslog.c @@ -86,7 +86,9 @@ static char *match_error(struct slmatch *sl, int r) return buf; } -static void vlog(int prio, const char *fmt, va_list args) +static void +__attribute__((format(printf, 2, 0))) +vlog(int prio, const char *fmt, va_list args) { if (nslmatches) { int e = errno; /* save errno Just In Case */ @@ -144,8 +146,10 @@ static void vlog(int prio, const char *fmt, va_list args) #if defined(__GLIBC__) /* Under some but not all combinations of options, glibc * defines syslog() as an inline that calls this function */ -void __syslog_chk(int prio, int whatever __attribute__((unused)), - const char *fmt, ...) +EXPORTED void +__attribute__((format(printf, 3, 4))) +__syslog_chk(int prio, int whatever __attribute__((unused)), + const char *fmt, ...) { va_list args; @@ -155,7 +159,7 @@ void __syslog_chk(int prio, int whatever __attribute__((unused)), } #endif -void syslog(int prio, const char *fmt, ...) +EXPORTED void syslog(int prio, const char *fmt, ...) { va_list args; diff --git a/cunit/timeofday.c b/cunit/timeofday.c index d23772f483..78b68b2cc2 100644 --- a/cunit/timeofday.c +++ b/cunit/timeofday.c @@ -188,21 +188,59 @@ void time_restore(void) #if defined(__GLIBC__) +/* Must not include in this file, because doing so will bring in + * the libc gettimeofday(), which we don't want because we're trying to + * replace it. So we need to define EXPORTED ourselves rather than rely on + * config.h to figure it out. Just assume __attribute__ is supported. + */ +#define EXPORTED __attribute__((__visibility__("default"))) + /* call the real libc function */ static int real_gettimeofday(struct timeval *tv, ...) { + /* On 32- or 64-bit systems where time_t size is the word size, + * we just want __gettimeofday(). __gettimeofday64() does not exist. + * + * On 32-bit systems with 64-bit time_t, __gettimeofday() is 32-bits. + * We want __gettimeofday64() instead, so we need to detect this case. + * + * With glibc < 2.39, + * __USE_TIME_BITS64 is set in this case specifically + * + * With glibc >= 2.39, + * __USE_TIME64_REDIRECTS is set in this case specifically + * __USE_TIME_BITS64 is always set when time_t is 64 bits (not useful) + * + * So we need to check the glibc version to figure out which macro to base + * our feature check on. + */ +#if __GLIBC__ > 2 || ( __GLIBC__ == 2 && __GLIBC_MINOR__ >= 39 ) +# if defined(__USE_TIME64_REDIRECTS) + extern int __gettimeofday64(struct timeval *, ...); + return __gettimeofday64(tv, NULL); +# else extern int __gettimeofday(struct timeval *, ...); return __gettimeofday(tv, NULL); +# endif +#else +# if defined(__USE_TIME_BITS64) + extern int __gettimeofday64(struct timeval *, ...); + return __gettimeofday64(tv, NULL); +# else + extern int __gettimeofday(struct timeval *, ...); + return __gettimeofday(tv, NULL); +# endif +#endif } /* provide a function to hide the libc weak alias */ -int gettimeofday(struct timeval *tv, ...) +EXPORTED int gettimeofday(struct timeval *tv, ...) { to_timeval(transform(now()), tv); return 0; } -time_t time(time_t *tp) +EXPORTED time_t time(time_t *tp) { time_t tt = to_time_t(transform(now())); if (tp) *tp = tt; diff --git a/cunit/timeout.c b/cunit/timeout.c index f02c9ac777..438da390c0 100644 --- a/cunit/timeout.c +++ b/cunit/timeout.c @@ -104,7 +104,7 @@ static void timeout_mainloop(int fd, pid_t pid) exit(1); } - r = read(fd, &c, 1); + r = read(fd, &c, sizeof(c)); if (r < 0) { perror("timeout: read"); exit(1); @@ -141,7 +141,8 @@ static void timeout_mainloop(int fd, pid_t pid) break; default: - fprintf(stderr, "timeout: Unknown command '%c'\n", c); + fprintf(stderr, "timeout: Unknown command '%c' (%#x)\n", + c, (unsigned) c); exit(1); } } @@ -197,7 +198,7 @@ int timeout_init(void (*cb)(void)) int timeout_begin(int millisec) { - int c; + char c; int r; // fprintf(stderr, "timeout_begin\n"); @@ -205,7 +206,7 @@ int timeout_begin(int millisec) return -1; c = CMD_BEGIN; - r = write(timeout_fd, &c, 1); + r = write(timeout_fd, &c, sizeof(c)); if (r < 0) { perror("timeout: write"); return -1; @@ -220,7 +221,7 @@ int timeout_begin(int millisec) int timeout_end(void) { - int c; + char c; int r; // fprintf(stderr, "timeout_end\n"); @@ -228,7 +229,7 @@ int timeout_end(void) return -1; c = CMD_END; - r = write(timeout_fd, &c, 1); + r = write(timeout_fd, &c, sizeof(c)); if (r < 0) { perror("timeout: write"); return -1; diff --git a/cunit/times.testc b/cunit/times.testc index 8ac2e3e898..10843c8760 100644 --- a/cunit/times.testc +++ b/cunit/times.testc @@ -103,72 +103,72 @@ test_military_timezones(void) time_t zulu = 813727192; /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = UTC, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = UTC, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-Z", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 Z", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu); /* Well-formed legacy format with 2-digit day, 2-digit year, - * lowercase 1-char timezone = UTC, "dd-mmm-yy HH:MM:SS-z" */ + * lowercase 1-char timezone = UTC, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-z", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 z", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = +0100, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = +0100, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-A", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 A", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-1*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = +0200, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = +0200, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-B", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 B", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-2*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = +0900, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = +0900, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-I", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 I", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-9*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * erroneous uppercase 1-char timezone, "dd-mmm-yy HH:MM:SS-z" */ + * erroneous uppercase 1-char timezone, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-J", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 J", &t); CU_ASSERT_EQUAL(r, -1); CU_ASSERT_EQUAL(t, UNINIT_TIMET); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = +1000, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = +1000, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-K", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 K", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-10*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * 1-char timezone = +1200, "dd-mmm-yy HH:MM:SS-z" */ + * 1-char timezone = +1200, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-M", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 M", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-12*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = -0100, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = -0100, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-N", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 N", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu+1*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * 1-char timezone = -1200, "dd-mmm-yy HH:MM:SS-z" */ + * 1-char timezone = -1200, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc3501("15-Oct-95 03:19:52-Y", &t); + r = time_from_rfc3501("15-Oct-95 03:19:52 Y", &t); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu+12*3600); } @@ -330,6 +330,13 @@ test_parse_iso8601(void) CU_ASSERT_EQUAL(r, 20); struct tm *tm = gmtime(&t); CU_ASSERT_EQUAL(tm->tm_year, 69); + + static const char DATETIME_NEG2[] = "1965-01-02T03:04:05Z"; + t = UNINIT_TIMET; + r = time_from_iso8601(DATETIME_NEG2, &t); + CU_ASSERT_EQUAL(r, 20); + tm = gmtime(&t); + CU_ASSERT_EQUAL(tm->tm_year, 65); } static void @@ -467,22 +474,20 @@ test_rfc5322(void) CU_ASSERT_EQUAL(r, 31); CU_ASSERT_EQUAL(t, 0); - /* Zero Hour */ + /* Zero Hour - we don't allow it any more, because the date returns negative */ t = UNINIT_TIMET; r = time_from_rfc5322("WED, 31 DEC 1969 19:36:29 -0500", &t, DATETIME_FULL); /* NYC */ - CU_ASSERT_EQUAL(r, 31); - CU_ASSERT_EQUAL(t, 2189); + CU_ASSERT_EQUAL(r, -1); t = UNINIT_TIMET; r = time_from_rfc5322(" 1-JAN-1970 11:36:29 +1100", &t, DATETIME_FULL); /* MEL */ CU_ASSERT_EQUAL(r, 26); CU_ASSERT_EQUAL(t, 2189); - /* Pre Jan 1 1970 */ + /* Pre Jan 1 1970 - we don't allow */ t = UNINIT_TIMET; r = time_from_rfc5322("WED, 31 DEC 1969 19:36:29", &t, DATETIME_FULL); - CU_ASSERT_EQUAL(r, 25); - CU_ASSERT_EQUAL(t, -15811); + CU_ASSERT_EQUAL(r, -1); /* Well-formed full RFC5322 format with 2-digit day * "dd-mmm-yyyy HH:MM:SS zzzzz" */ @@ -538,6 +543,11 @@ test_rfc5322(void) CU_ASSERT_EQUAL(r, 31); CU_ASSERT_EQUAL(t, 1290741722); + t = UNINIT_TIMET; + r = time_from_rfc5322("THU, 25 NOV 2010 22:22:02 EST", &t, DATETIME_FULL); /* NYC */ + CU_ASSERT_EQUAL(r, 29); + CU_ASSERT_EQUAL(t, 1290741722); + /* Time with period as separator */ t = UNINIT_TIMET; @@ -545,22 +555,26 @@ test_rfc5322(void) CU_ASSERT_EQUAL(r, 26); CU_ASSERT_EQUAL(t, 1230969900); +#if SIZEOF_TIME_T >= 8 /* Year 9999 */ t = UNINIT_TIMET; r = time_from_rfc5322("Fri, 31-Dec-9999 23:59:59 +0000", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 31); CU_ASSERT_EQUAL(t, 253402300799); +#endif /* Year 1 - This will fail*/ t = UNINIT_TIMET; r = time_from_rfc5322("1 Jan 1 00:00:00 +0000", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, -1); +#if SIZEOF_TIME_T >= 8 /* 5 digit year */ t = UNINIT_TIMET; r = time_from_rfc5322("Sat, 1 Jan 10000 00:00:00", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 25); CU_ASSERT_EQUAL(t, 253402300800); +#endif /* Invalid date */ t = UNINIT_TIMET; @@ -596,72 +610,72 @@ test_military_timezones_using_rfc5322(void) time_t zulu = 813727192; /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = UTC, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = UTC, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-Z", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 Z", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu); /* Well-formed legacy format with 2-digit day, 2-digit year, - * lowercase 1-char timezone = UTC, "dd-mmm-yy HH:MM:SS-z" */ + * lowercase 1-char timezone = UTC, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-z", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 z", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = +0100, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = +0100, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-A", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 A", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-1*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = +0200, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = +0200, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-B", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 B", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-2*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = +0900, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = +0900, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-I", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 I", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-9*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * erroneous uppercase 1-char timezone, "dd-mmm-yy HH:MM:SS-z" */ + * erroneous uppercase 1-char timezone, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-J", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 J", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, 813727192); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = +1000, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = +1000, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-K", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 K", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-10*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * 1-char timezone = +1200, "dd-mmm-yy HH:MM:SS-z" */ + * 1-char timezone = +1200, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-M", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 M", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu-12*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * uppercase 1-char timezone = -0100, "dd-mmm-yy HH:MM:SS-z" */ + * uppercase 1-char timezone = -0100, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-N", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 N", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu+1*3600); /* Well-formed legacy format with 2-digit day, 2-digit year, - * 1-char timezone = -1200, "dd-mmm-yy HH:MM:SS-z" */ + * 1-char timezone = -1200, "dd-mmm-yy HH:MM:SS z" */ t = UNINIT_TIMET; - r = time_from_rfc5322("15-Oct-95 03:19:52-Y", &t, DATETIME_FULL); + r = time_from_rfc5322("15-Oct-95 03:19:52 Y", &t, DATETIME_FULL); CU_ASSERT_EQUAL(r, 20); CU_ASSERT_EQUAL(t, zulu+12*3600); } @@ -697,5 +711,84 @@ test_leapyear_rfc5322(void) CU_ASSERT_EQUAL(t, FEB2004_TIMET); } +static void +test_offsettime_iso8601(void) +{ +#define TESTCASE(ts, want_n, want_wday, want_yday) \ + { \ + const char *_ts = (ts); \ + int _want_n = (want_n); \ + int _want_wday = (want_wday); \ + int _want_yday = (want_yday); \ + struct offsettime _ot; \ + char buf[30]; \ + int r = offsettime_from_iso8601(_ts, &_ot); \ + CU_ASSERT_EQUAL(_want_n, r); \ + CU_ASSERT_EQUAL(_want_wday, _ot.tm.tm_wday); \ + CU_ASSERT_EQUAL(_want_yday, _ot.tm.tm_yday); \ + r = offsettime_to_iso8601(&_ot, buf, sizeof(buf), 1); \ + CU_ASSERT_EQUAL(_want_n, r); \ + CU_ASSERT_STRING_EQUAL(_ts, buf); \ + } + + TESTCASE("2019-05-02T03:15:00+07:00", 25, 4, 122); + TESTCASE("2010-11-26T14:22:02+11:00", 25, 5, 330); + TESTCASE("2010-11-26T03:22:02Z", 20, 5, 330); + TESTCASE("2010-11-25T22:22:02-05:00", 25, 4, 329); + TESTCASE("1969-12-31T23:59:59Z", 20, 3, 365); + +#undef TESTCASE +} + +static void +test_offsettime_rfc5322(void) +{ +#define TESTCASE(ts, want_n, want_wday, want_yday) \ + { \ + const char *_ts = (ts); \ + int _want_n = (want_n); \ + int _want_wday = (want_wday); \ + int _want_yday = (want_yday); \ + struct offsettime _ot; \ + char buf[RFC822_DATETIME_MAX+1]; \ + int r = offsettime_from_rfc5322(_ts, &_ot, DATETIME_FULL); \ + CU_ASSERT_EQUAL(_want_n, r); \ + CU_ASSERT_EQUAL(_want_wday, _ot.tm.tm_wday); \ + CU_ASSERT_EQUAL(_want_yday, _ot.tm.tm_yday); \ + r = offsettime_to_rfc5322(&_ot, buf, sizeof(buf)); \ + CU_ASSERT_EQUAL(_want_n, r); \ + CU_ASSERT_STRING_EQUAL(_ts, buf); \ + } + + TESTCASE("Thu, 02 May 2019 03:15:00 +0700", 31, 4, 122); + TESTCASE("Fri, 26 Nov 2010 14:22:02 +1100", 31, 5, 330); + TESTCASE("Fri, 26 Nov 2010 03:22:02 +0000", 31, 5, 330); + TESTCASE("Thu, 25 Nov 2010 22:22:02 -0500", 31, 4, 329); + TESTCASE("Wed, 31 Dec 1969 23:59:59 +0000", 31, 3, 365); + +#undef TESTCASE +} + +static void +test_offsettime_rfc5322_ignore_wday(void) +{ +#define TESTCASE(ts, want_wday, want_yday) \ + { \ + const char *_ts = (ts); \ + int _want_wday = (want_wday); \ + int _want_yday = (want_yday); \ + struct offsettime _ot; \ + int r = offsettime_from_rfc5322(_ts, &_ot, DATETIME_FULL); \ + CU_ASSERT(r > 0); \ + CU_ASSERT_EQUAL(_want_wday, _ot.tm.tm_wday); \ + CU_ASSERT_EQUAL(_want_yday, _ot.tm.tm_yday); \ + } + + /* Bogus week days are ignored */ + TESTCASE("Sat, 02 May 2019 03:15:00 +0700", 4, 122); // Should be "Thu" + + +#undef TESTCASE +} /* vim: set ft=c: */ diff --git a/cunit/unit.c b/cunit/unit.c index 6727b9e562..5ab38f7939 100644 --- a/cunit/unit.c +++ b/cunit/unit.c @@ -56,6 +56,10 @@ #include "util.h" #include "xmalloc.h" +#include "lib/retry.h" +#include "lib/libconfig.h" +#include "lib/xunlink.h" + /* generated headers are not necessarily in current directory */ #include "cunit/registers.h" @@ -67,32 +71,41 @@ int xml_flag = 0; int timeouts_flag = 1; #if HAVE_VALGRIND_VALGRIND_H -#define log1(fmt, a1) \ - VALGRIND_PRINTF_BACKTRACE(fmt"\n", (a1)) -#define log2(fmt, a1, a2) \ - VALGRIND_PRINTF_BACKTRACE(fmt"\n", (a1), (a2)) +#define log1(fmt, a1) do { \ + if (RUNNING_ON_VALGRIND) VALGRIND_PRINTF_BACKTRACE(fmt"\n", (a1)); \ + else fprintf(stderr, "\nunit: "fmt"\n", (a1)); \ +} while (0) +#define log2(fmt, a1, a2) do { \ + if (RUNNING_ON_VALGRIND) VALGRIND_PRINTF_BACKTRACE(fmt"\n", (a1), (a2));\ + else fprintf(stderr, "\nunit: "fmt"\n", (a1), (a2)); \ +} while (0) #else -#define log1(fmt, a1) \ - fprintf(stderr, "\nunit: "fmt"\n", (a1)) -#define log2(fmt, a1, a2) \ - fprintf(stderr, "\nunit: "fmt"\n", (a1), (a2)) +#define log1(fmt, a1) do { \ + fprintf(stderr, "\nunit: "fmt"\n", (a1)); \ +} while (0) +#define log2(fmt, a1, a2) do { \ + fprintf(stderr, "\nunit: "fmt"\n", (a1), (a2)); \ +} while (0) #endif jmp_buf fatal_jbuf; int fatal_expected; -const char *fatal_string; +char *fatal_string = NULL; int fatal_code; EXPORTED void fatal(const char *s, int code) { - log1("fatal(%s)", s); if (fatal_expected) { + if (verbose) { + log1("fatal(%s)", s); + } fatal_expected = 0; - fatal_string = s; + fatal_string = xstrdupnull(s); fatal_code = code; longjmp(fatal_jbuf, code); } else { + log1("fatal(%s)", s); exit(1); } } @@ -296,6 +309,18 @@ CU_BOOL CU_assertFormatImplementation( return CU_assertImplementation(bValue, uiLine, buf, strFile, strFunction, bFatal); } +EXPORTED void config_read_string(const char *s) +{ + char *fname = xstrdup("/tmp/cyrus-cunit-configXXXXXX"); + int fd = mkstemp(fname); + retry_write(fd, s, strlen(s)); + config_reset(); + config_read(fname, 0); + xunlink(fname); + free(fname); + close(fd); +} + static void run_tests(void) { int i; diff --git a/cunit/vg.supp b/cunit/vg.supp index 33889cfcd2..85aef7d6b9 100644 --- a/cunit/vg.supp +++ b/cunit/vg.supp @@ -40,143 +40,50 @@ # OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. # { - bdb_environment_leak - Memcheck:Leak - fun:malloc - ... - fun:__env_config - fun:__env_open - ... - fun:init - fun:cyrusdb_init - ... -} -{ - getpwnam_leak - Memcheck:Leak - fun:malloc - ... - fun:__nss_database_lookup - ... - fun:getpwnam - ... -} -{ - getgrouplist_leak - Memcheck:Leak - fun:malloc - ... - fun:__nss_database_lookup - ... - fun:getgrouplist - ... -} -{ - getpwuid_leak - Memcheck:Leak - fun:malloc - ... - fun:__nss_database_lookup - ... - fun:getpwuid - ... -} -{ - zlib_inflateInit2_cond - Memcheck:Cond - fun:inflateReset2 - fun:inflateInit2_ - fun:prot_setcompress - ... -} -{ - zlib_inflateInit2_cond - Memcheck:Cond - fun:inflateReset2 - fun:inflateInit2_ - fun:buf_inflate - ... -} -{ - zlib_deflate_cond - Memcheck:Cond - fun:memcpy - ... - fun:deflate - fun:prot_flush_encode - ... -} -{ - zlib_deflate_data4 - Memcheck:Value4 - fun:memcpy - ... - fun:deflate - fun:prot_flush_encode - ... -} -{ - zlib_deflate_data8 - Memcheck:Value8 - fun:memcpy - ... - fun:deflate - fun:prot_flush_encode - ... -} -{ - libcrypto_bugs_1 - Memcheck:Cond - ... - fun:X509_verify - ... -} -{ - libcrypto_bugs_2 - Memcheck:Cond - ... - fun:RSA_sign - ... -} -{ - libcrypto_bugs_3 - Memcheck:Cond - ... - fun:DH_generate_key - ... -} -{ - libcrypto_bugs_4 - Memcheck:Cond + # The libconfig:*_invalid tests all fatal out from in libconfig, which + # is fine in reality, but under CUnit we're trapping the fatal and not + # actually exiting. Which means we detect some leaks that aren't real. + config_read_string_fname_leak + Memcheck:Leak + match-leak-kinds: definite + fun:malloc + fun:xmalloc + fun:xstrdup + fun:config_read_string + fun:test_*_invalid ... - fun:DH_compute_key + obj:*/libcunit* ... } { - libcrypto_bugs_5 - Memcheck:Cond + # The libconfig:*_invalid tests all fatal out from in libconfig, which + # is fine in reality, but under CUnit we're trapping the fatal and not + # actually exiting. Which means we detect some leaks that aren't real. + config_read_file_buf_leak + Memcheck:Leak + match-leak-kinds: definite + fun:malloc + fun:xmalloc + fun:config_read_file + fun:config_read + fun:config_read_string + fun:test_*_invalid ... - fun:RSA_setup_blinding + obj:*/libcunit* ... } { - libcrypto_bugs_6 + # The parse:getint tests each check for fatal parse errors, but when + # this occurs the protstream structure is leaked. + parse_getint_protstream_leak Memcheck:Leak + match-leak-kinds: definite fun:malloc + fun:xmalloc + fun:xzmalloc + fun:prot_readmap + fun:test_get*int* ... - fun:get_dh1024 - ... - fun:tls_init_serverengine - ... -} -{ - # This function is part of the unit tests and deliberately - # passes bogus mappings to msync() in the expectation of - # generating an error from the kernel. - is_valid_mapping - Memcheck:Param - msync(start) - fun:__msync_nocancel - fun:is_valid_mapping + obj:*/libcunit* ... } diff --git a/cunit/vparse.testc b/cunit/vparse.testc index 2d208d727f..acb0d7c1bb 100644 --- a/cunit/vparse.testc +++ b/cunit/vparse.testc @@ -1,3 +1,5 @@ +#include + #include "cunit/cyrunit.h" #include "xmalloc.h" #include "vparse.h" @@ -10,6 +12,7 @@ static void test_double_end(void) vparser.base = card; int vr = vparse_parse(&vparser, 0); CU_ASSERT_EQUAL(vr, PE_MISMATCHED_CARD); + vparse_free(&vparser); } static void test_wrap_onechar(void) @@ -28,297 +31,311 @@ static void test_wrap_onechar(void) vparser.base = card; int vr = vparse_parse(&vparser, 0); CU_ASSERT_EQUAL(vr, 0); - struct buf *buf = buf_new(); \ - vparse_tobuf(vparser.card, buf); \ - CU_ASSERT_STRING_EQUAL(wantbuf, buf_cstring(buf)); \ + struct buf *buf = buf_new(); + vparse_tobuf(vparser.card, buf); + CU_ASSERT_STRING_EQUAL(wantbuf, buf_cstring(buf)); vparse_free(&vparser); // XXX test value + buf_destroy(buf); +} + +static void test_repair_version(void) +{ + char card[] = "BEGIN:VCARD\n" + "VERSION: 3.0 \r\n" + "UID:abc\n" + "END:VCARD\r\n"; + + char wantbuf[] = "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "UID:abc\r\n" + "END:VCARD\r\n"; + struct vparse_state vparser; + memset(&vparser, 0, sizeof(struct vparse_state)); + vparser.base = card; + int vr = vparse_parse(&vparser, 0); + CU_ASSERT_EQUAL(vr, 0); + struct buf *buf = buf_new(); + vparse_tobuf(vparser.card, buf); + CU_ASSERT_STRING_EQUAL(wantbuf, buf_cstring(buf)); + vparse_free(&vparser); + buf_destroy(buf); } -static void test_control_chars(void) +static void test_repair_control_chars(void) { -#define TESTCASE(in, flag, wanterr) \ +#define TESTCASE(in, wanterr, wantout) \ { \ struct vparse_state vparser; \ memset(&vparser, 0, sizeof(struct vparse_state)); \ vparse_set_multival(&vparser, "adr", ';'); \ vparser.base = (in); \ - vparser.ctrl = flag; \ int vr = vparse_parse(&vparser, 0); \ CU_ASSERT_EQUAL(vr, (wanterr)); \ + if (wantout != NULL) { \ + struct buf *buf = buf_new(); \ + vparse_tobuf(vparser.card, buf); \ + CU_ASSERT_STRING_EQUAL(wantout, buf_cstring(buf)); \ + buf_destroy(buf); \ + } \ vparse_free(&vparser); \ } - char ctl_in_propname[] = + struct testcase { + const char *in; + int wanterr; + const char *out; + } tests[] = {{ + /* Control in property name */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "N\bOTE:Weird control chars\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - - char ctl_in_propval1[] = + "END:VCARD\r\n", + PE_OK, + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "NOTE:Weird control chars\r\n" + "REV:2008-04-24T19:52:43Z\r\n" + "END:VCARD\r\n", + }, { + /* Control in property value */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:Weird control\b chars\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - - char ctl_in_propval2[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" + "NOTE:Weird control chars\r\n" + "REV:2008-04-24T19:52:43Z\r\n" + "END:VCARD\r\n" + }, { /* Newline forces parser to switch to property name state */ + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" "NOTE:Weird control\n\bchars\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - - char ctl_in_propval3[] = + "END:VCARD\r\n", + PE_NAME_EOL, + NULL + }, { + /* Multivalue field, separated by semicolon */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" - /* Multivalue field, separated by semicolon */ "ADR:;;123 Main Street;Any Town;CA;91921-1234;U.S.\x01\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - - char ctl_in_paraname[] = + "END:VCARD\r\n", + PE_OK, + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "ADR:;;123 Main Street;Any Town;CA;91921-1234;U.S.\r\n" + "REV:2008-04-24T19:52:43Z\r\n" + "END:VCARD\r\n" + }, { + /* Control in parameter name */ "BEGIN:VCARD\r\n" "VERSION:4.0\r\n" "EMAIL;\x01TYPE\x03=work:foo@local\r\n" - "END:VCARD\r\n"; - - char ctl_in_paraval1[] = + "END:VCARD\r\n", + PE_OK, + "BEGIN:VCARD\r\n" + "VERSION:4.0\r\n" + "EMAIL;TYPE=work:foo@local\r\n" + "END:VCARD\r\n" + }, { + /* Control in parameter value */ "BEGIN:VCARD\r\n" "VERSION:4.0\r\n" "EMAIL;TYPE=w\x1bork:foo@local\r\n" - "END:VCARD\r\n"; - - char ctl_in_paraval2[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:4.0\r\n" - /* control char in quoted param value */ - "EMAIL;TYPE=\"\x1bork\":foo@local\r\n" - "END:VCARD\r\n"; - - TESTCASE(ctl_in_propname, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR); - TESTCASE(ctl_in_propval1, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR); - TESTCASE(ctl_in_propval2, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR); - TESTCASE(ctl_in_propval3, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR); - TESTCASE(ctl_in_paraname, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR); - TESTCASE(ctl_in_paraval1, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR); - TESTCASE(ctl_in_paraval2, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR); - - TESTCASE(ctl_in_propname, VPARSE_CTRL_SKIP, PE_OK); - TESTCASE(ctl_in_propval1, VPARSE_CTRL_SKIP, PE_OK); - TESTCASE(ctl_in_propval2, VPARSE_CTRL_SKIP, PE_NAME_EOL); - TESTCASE(ctl_in_propval3, VPARSE_CTRL_SKIP, PE_OK); - TESTCASE(ctl_in_paraname, VPARSE_CTRL_SKIP, PE_OK); - TESTCASE(ctl_in_paraval1, VPARSE_CTRL_SKIP, PE_OK); - TESTCASE(ctl_in_paraval2, VPARSE_CTRL_SKIP, PE_OK); - -#undef TESTCASE -} - -static void test_crlf(void) -{ -#define TESTCASE(in, wantctrl, wanterr, wantbuf) \ - { \ - struct vparse_state vparser; \ - memset(&vparser, 0, sizeof(struct vparse_state)); \ - vparse_set_multival(&vparser, "adr", ';'); \ - vparser.base = (in); \ - vparser.ctrl = (wantctrl); \ - int vr = vparse_parse(&vparser, 0); \ - CU_ASSERT_EQUAL(vr, (wanterr)); \ - if (wantbuf != NULL) { \ - struct buf *buf = buf_new(); \ - vparse_tobuf(vparser.card, buf); \ - CU_ASSERT_STRING_EQUAL(wantbuf, buf_cstring(buf)); \ - buf_destroy(buf); \ - } \ - vparse_free(&vparser); \ - } - - /* All lines end on CRLF */ - char crlf[] = + "EMAIL;TYPE=work:foo@local\r\n" + "END:VCARD\r\n" + }, { + /* Control char in quoted parameter value */ + "BEGIN:VCARD\r\n" + "VERSION:4.0\r\n" + "FOO;BAR=\"x\x1b,y\":0\r\n" + "END:VCARD\r\n", + PE_OK, + "BEGIN:VCARD\r\n" + "VERSION:4.0\r\n" + "FOO;BAR=\"x,y\":0\r\n" + "END:VCARD\r\n", + }, { + /* End with CRLF */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:All lines end on CRLF\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - TESTCASE(crlf, VPARSE_CTRL_IGNORE, PE_OK, crlf); - TESTCASE(crlf, VPARSE_CTRL_SKIP, PE_OK, crlf); - TESTCASE(crlf, VPARSE_CTRL_REJECT, PE_OK, crlf); - - /* All lines end on LF */ - char lf_in[] = + "END:VCARD\r\n", + PE_OK, + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "NOTE:All lines end on CRLF\r\n" + "REV:2008-04-24T19:52:43Z\r\n" + "END:VCARD\r\n" + }, { + /* End with LF */ "BEGIN:VCARD\n" "VERSION:3.0\n" "NOTE:All lines end on LF\n" "REV:2008-04-24T19:52:43Z\n" - "END:VCARD\n"; - char lf_out[] = + "END:VCARD\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:All lines end on LF\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - TESTCASE(lf_in, VPARSE_CTRL_IGNORE, PE_OK, lf_out); - TESTCASE(lf_in, VPARSE_CTRL_SKIP, PE_OK, lf_out); - TESTCASE(lf_in, VPARSE_CTRL_REJECT, PE_OK, lf_out); - - - /* All lines end on CR */ - /* This is all actually just one line */ - char cr[] = + "END:VCARD\r\n" + }, { + /* End with CR */ "BEGIN:VCARD\r" "VERSION:3.0\r" "NOTE:All lines end on CR\r" "REV:2008-04-24T19:52:43Z\r" - "END:VCARD\r"; - TESTCASE(cr, VPARSE_CTRL_IGNORE, PE_FINISHED_EARLY, NULL); - TESTCASE(cr, VPARSE_CTRL_SKIP, PE_FINISHED_EARLY, NULL); - TESTCASE(cr, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR, NULL); - - /* Some lines end on CRLF and some on LF */ - char mixed_in[] = + "END:VCARD\r", + PE_FINISHED_EARLY, + NULL + }, { + /* End with either CR or CRLF */ "BEGIN:VCARD\n" "VERSION:3.0\n" "NOTE:Some lines end on LF and some on CRLF\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\n"; - char mixed_out[] = + "END:VCARD\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:Some lines end on LF and some on CRLF\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - TESTCASE(mixed_in, VPARSE_CTRL_IGNORE, PE_OK, mixed_out); - TESTCASE(mixed_in, VPARSE_CTRL_SKIP, PE_OK, mixed_out); - TESTCASE(mixed_in, VPARSE_CTRL_REJECT, PE_OK, mixed_out); - - /* An extra CR before CRLF */ - char extra_cr_in[] = + "END:VCARD\r\n" + }, { + /* Extra CR before CRLF */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:An extra CR before CRLF\r\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - char extra_cr_out[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:An extra CR before CRLF\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - TESTCASE(extra_cr_in, VPARSE_CTRL_IGNORE, PE_OK, extra_cr_out); - TESTCASE(extra_cr_in, VPARSE_CTRL_SKIP, PE_OK, extra_cr_out); - TESTCASE(extra_cr_in, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR, NULL); - - /* Two LF make one empty line */ - char lflf_in[] = + "END:VCARD\r\n" + }, { + /* Two LF make one empty line */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:Two LF make one empty line\n\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - char lflf_out[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:Two LF make one empty line\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - TESTCASE(lflf_in, VPARSE_CTRL_IGNORE, PE_OK, lflf_out); - TESTCASE(lflf_in, VPARSE_CTRL_SKIP, PE_OK, lflf_out); - TESTCASE(lflf_in, VPARSE_CTRL_REJECT, PE_OK, lflf_out); - - /* One lonely CR in the middle of text */ - /* Single-value */ - char cr_val1[] = + "END:VCARD\r\n" + }, { + /* One lonely CR in the middle of text */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:One lonely \r in the middle of text\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - char cr_val1_skip[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:One lonely in the middle of text\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - /* Multi-value */ - char cr_val2[] = + "END:VCARD\r\n" + }, { + /* One lonely CR in a multi-value property */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" - "NOTE:One lonely CR in the middle of text\r\n" "ADR:;;123\r\\nMain Street;Any Town;CA;91921-1234;U.S.A\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - char cr_val2_skip[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" - "NOTE:One lonely CR in the middle of text\r\n" "ADR:;;123\\nMain Street;Any Town;CA;91921-1234;U.S.A\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - /* CR in key */ - char cr_key[] = + "END:VCARD\r\n" + }, { + /* CR in key */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE\r:CR in key\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - char cr_key_skip[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "NOTE:CR in key\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - /* CR in param name */ - char cr_para1[] = + "END:VCARD\r\n" + }, { + /* CR in param name */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "EMAIL;TYPE\r=work:foo@local\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - char cr_para1_skip[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "EMAIL;TYPE=work:foo@local\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - /* CR in param name */ - char cr_para2[] = + "END:VCARD\r\n" + }, { + /* CR in param name */ "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "EMAIL;TYPE=work\r:foo@local\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - char cr_para2_skip[] = + "END:VCARD\r\n", + PE_OK, "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "EMAIL;TYPE=work:foo@local\r\n" "REV:2008-04-24T19:52:43Z\r\n" - "END:VCARD\r\n"; - - TESTCASE(cr_val1, VPARSE_CTRL_SKIP, PE_OK, cr_val1_skip); - TESTCASE(cr_val2, VPARSE_CTRL_SKIP, PE_OK, cr_val2_skip); - TESTCASE(cr_key, VPARSE_CTRL_SKIP, PE_OK, cr_key_skip); - TESTCASE(cr_para1, VPARSE_CTRL_SKIP, PE_OK, cr_para1_skip); - TESTCASE(cr_para2, VPARSE_CTRL_SKIP, PE_OK, cr_para2_skip); + "END:VCARD\r\n" + }, { + /* End of tests */ + NULL, 0, NULL + }}; - TESTCASE(cr_val1, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR, NULL); - TESTCASE(cr_val2, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR, NULL); - TESTCASE(cr_key, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR, NULL); - TESTCASE(cr_para1, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR, NULL); - TESTCASE(cr_para2, VPARSE_CTRL_REJECT, PE_ILLEGAL_CHAR, NULL); + struct testcase *t; + for (t = tests; t->in; t++) { + TESTCASE(t->in, t->wanterr, t->out); + } #undef TESTCASE } +#ifdef USE_HTTPD #include "imap/vcard_support.h" static void test_multiparam_type(void) { - char card[] = "" +#define TESTCASE(card, wantbuf) \ + { \ + struct vparse_card *vcard = vcard_parse_string(card); \ + CU_ASSERT_PTR_NOT_NULL(vcard); \ + struct buf *buf = vcard_as_buf(vcard); \ + CU_ASSERT_STRING_EQUAL(wantbuf, buf_cstring(buf)); \ + vparse_free_card(vcard); \ + buf_destroy(buf); \ + } + + TESTCASE( + // card "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "UID:0dc2973f-5f46-49b9-8ba9-12a4cee6eeac\r\n" @@ -329,9 +346,9 @@ static void test_multiparam_type(void) "TEL;TYPE=HOME,VOICE:040-xx\r\n" "TEL;TYPE=CELL:06-xx\r\n" "PRODID:-//MailClient.Contact/7.0.30068.0\r\n" - "END:VCARD\r\n"; + "END:VCARD\r\n", + // wantbuf - char wantbuf[] = "" "BEGIN:VCARD\r\n" "VERSION:3.0\r\n" "UID:0dc2973f-5f46-49b9-8ba9-12a4cee6eeac\r\n" @@ -342,12 +359,32 @@ static void test_multiparam_type(void) "TEL;TYPE=HOME;TYPE=VOICE:040-xx\r\n" "TEL;TYPE=CELL:06-xx\r\n" "PRODID:-//MailClient.Contact/7.0.30068.0\r\n" - "END:VCARD\r\n"; + "END:VCARD\r\n" + ); - struct vparse_card *vcard = vcard_parse_string(card, 0); - CU_ASSERT_PTR_NOT_NULL(vcard); - struct buf *buf = vcard_as_buf(vcard); - CU_ASSERT_STRING_EQUAL(wantbuf, buf_cstring(buf)); - vparse_free_card(vcard); - buf_free(buf); + TESTCASE( + // card + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "UID:0dc2973f-5f46-49b9-8ba9-12a4cee6eeac\r\n" + "N:Test;John;;;\r\n" + "FN:John Test\r\n" + "X-SOCIAL-PROFILE;TYPE=Github,PREF;X-USER=\"foo,bar\":\r\n" + "PRODID:-//MailClient.Contact/7.0.30068.0\r\n" + "END:VCARD\r\n", + // wantbuf + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "UID:0dc2973f-5f46-49b9-8ba9-12a4cee6eeac\r\n" + "N:Test;John;;;\r\n" + "FN:John Test\r\n" + "X-SOCIAL-PROFILE;TYPE=Github;TYPE=PREF;X-USER=\"foo,bar\":\r\n" + "PRODID:-//MailClient.Contact/7.0.30068.0\r\n" + "END:VCARD\r\n" + ); + +#undef TESTCASE } +#else +static void test_multiparam_type(void) { } +#endif /* USE_HTTPD */ diff --git a/cunit/xsha1.testc b/cunit/xsha1.testc new file mode 100644 index 0000000000..27f1d3c6f2 --- /dev/null +++ b/cunit/xsha1.testc @@ -0,0 +1,4178 @@ +#include +# +#include "cunit/cyrunit.h" +#include "charset.h" +#include "util.h" +#include "xsha1.h" + +static void _checksha(const char *text, size_t len, const char *expect) +{ + uint8_t dest[SHA1_DIGEST_LENGTH]; + char hexstr[2*SHA1_DIGEST_LENGTH+1]; + + // test xsha1 + memset(dest, 0, sizeof(dest)); + memset(hexstr, 0, sizeof(hexstr)); + xsha1((unsigned char *)text, len, dest); + bin_to_hex(dest, SHA1_DIGEST_LENGTH, hexstr, BH_LOWER); + CU_ASSERT_STRING_EQUAL(expect, hexstr); + + // test charset version - no encoding + memset(dest, 0, sizeof(dest)); + memset(hexstr, 0, sizeof(hexstr)); + charset_decode_sha1(dest, NULL, text, len, ENCODING_NONE); + bin_to_hex(dest, SHA1_DIGEST_LENGTH, hexstr, BH_LOWER); + CU_ASSERT_STRING_EQUAL(expect, hexstr); + + // test charset version - encoded + size_t len64 = 0; + charset_b64encode_mimebody(NULL, len, NULL, &len64, NULL, 1 /* wrap */); + if (len64) { + char *encbuf = xmalloc(len64); + size_t outlen = 0; + memset(dest, 0, sizeof(dest)); + memset(hexstr, 0, sizeof(hexstr)); + charset_b64encode_mimebody(text, len, encbuf, &len64, NULL, 1 /* wrap */); + charset_decode_sha1(dest, &outlen, encbuf, len64, ENCODING_BASE64); + bin_to_hex(dest, SHA1_DIGEST_LENGTH, hexstr, BH_LOWER); + CU_ASSERT_STRING_EQUAL(expect, hexstr); + CU_ASSERT_EQUAL(len, outlen); + free(encbuf); + } +} + +static void test_wiki(void) +{ + // https://en.wikipedia.org/wiki/SHA-1#Example_hashes + const char *str1 = "The quick brown fox jumps over the lazy dog"; + const char *res1 = "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12"; + const char *str2 = "The quick brown fox jumps over the lazy cog"; + const char *res2 = "de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3"; + const char *str3 = ""; + const char *res3 = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; + + _checksha(str1, strlen(str1), res1); + _checksha(str2, strlen(str2), res2); + _checksha(str3, strlen(str3), res3); +} + +/* + use Digest::SHA qw(sha1_hex); + print " \"" . sha1_hex('a' x $_) . "\", // $_\n" for 0..4097; +*/ +const char *known_sha1s[] = { + "da39a3ee5e6b4b0d3255bfef95601890afd80709", // 0 + "86f7e437faa5a7fce15d1ddcb9eaeaea377667b8", // 1 + "e0c9035898dd52fc65c41454cec9c4d2611bfb37", // 2 + "7e240de74fb1ed08fa08d38063f6a6a91462a815", // 3 + "70c881d4a26984ddce795f6f71817c9cf4480e79", // 4 + "df51e37c269aa94d38f93e537bf6e2020b21406c", // 5 + "f7a9e24777ec23212c54d7a350bc5bea5477fdbb", // 6 + "e93b4e3c464ffd51732fbd6ded717e9efda28aad", // 7 + "b480c074d6b75947c02681f31c90c668c46bf6b8", // 8 + "2882f38e575101ba615f725af5e59bf2333a9a68", // 9 + "3495ff69d34671d1e15b33a63c1379fdedd3a32a", // 10 + "755c001f4ae3c8843e5a50dd6aa2fa23893dd3ad", // 11 + "384fcd160ab3b33174ea279ad26052eee191508a", // 12 + "897b99631295d204db13e863b296a09e70ab1d65", // 13 + "128c484ff69fcdc1f82cd3781595cac5185e688f", // 14 + "7e13c003a8256cd421055563c5da6571d50713c9", // 15 + "3499c60eea227453c779de50fc84e217e9a53a18", // 16 + "321a618ba6830de900738b0814d0c9f28ff2fece", // 17 + "0478095c8ece0bbc11f94663ac2c4f10b29666de", // 18 + "1335bfa62671b0015c6e20766c07035868edb8f4", // 19 + "38666b8ba500faa5c2406f4575d42a92379844c2", // 20 + "035a4ee5d60816878caec161d6cb8e00e9cc539b", // 21 + "8c2a4e5c8f210b6aaa6c95e1c8e21351959f4541", // 22 + "85e3737bb8ab36e2866501e517c46fffc085313e", // 23 + "b1c76aec7674865d5346b3b0d1cb2c223c53e73e", // 24 + "44f4647e1542a79d7d68ceb7f75d1dbf77fdebfc", // 25 + "de8280c3a1c7db377f1ec7107c7fb62d374cc09c", // 26 + "52a00b8461593ce33409d7c5d0411699cbf9cda3", // 27 + "06587751ce11a8703abc64cab55b0b96d88341aa", // 28 + "498a75f314a645671bc79a118df385d0d9948484", // 29 + "cd762363c1c11ecb48611583520bba111f0034d4", // 30 + "65cf771ad2cc0b1e8f89361e3b7bec2365b3ad24", // 31 + "68f84a59a3ca2d0e5cb1646fbb164da409b5d8f2", // 32 + "2368a1ac71c68c4b47b4fb2806508e0eb447aa64", // 33 + "82d5343f4b2f0fcf6e28672d1f1a10c434f213d5", // 34 + "f4d3e057abac5109b7e953578fa97968ea34f43a", // 35 + "6c783ce5cc13ea5ce572eddfaba02f9d1bb90905", // 36 + "9e55bf6ab8f14b37cc6f69eb7374be6c5cbd2d07", // 37 + "1290c28910a6c12c9a131f0ecb523114f20f14c2", // 38 + "4f5fc75bd3c93bccc09fc2de9c95442456053faf", // 39 + "a56559418dc7908ce5f0b24b05c78e055cb863dc", // 40 + "52cedd6b110e4330b5186478736afa5203c4f9ea", // 41 + "32e067e0414932c3edd95fc4176a54bff1ddfe29", // 42 + "7bd258f2f4cc4b02fca4ea157f55f6d88d26d954", // 43 + "df51a19b291586bf46450aec1d775f3e02799b55", // 44 + "4642fe68c57cd01fc68fc11b7f22b940328a7cc4", // 45 + "03a4de84c189a836eaee643041b34ad2386db70d", // 46 + "25883f7a0e732e9ab10e594ea59425dfe4d90359", // 47 + "3e3d6e12b933133de2caa248ea12bd193a67f206", // 48 + "1e666934c5a35f509aa31bbd9af8a37a1ed13ba6", // 49 + "6c177354157989a2c6cd7bac80465b13bea25832", // 50 + "aca32b501c231ef8e2d8703e71415bfbe4ccbc64", // 51 + "e6479c70bbac662e4cc134cb8bdaade59ff55b66", // 52 + "d9b66a0801459c8094398ef8f04700a8569c9906", // 53 + "b05d71c64979cb95fa74a33cdb31a40d258ae02e", // 54 + "c1c8bbdc22796e28c0e15163d20899b65621d65a", // 55 + "c2db330f6083854c99d4b5bfb6e8f29f201be699", // 56 + "f08f24908d682555111be7ff6f004e78283d989a", // 57 + "5ee0f8895f4e1aae6a6661de5c432e34188a5a2d", // 58 + "dbc8b8f59ff85a2b1448ed873484b14bf0507246", // 59 + "13d956033d9af449bfe2c4ef78c17c20469c4bf1", // 60 + "aeab141db28af3353283b5ccb2a322df0b9b5f56", // 61 + "67b4b3923fa178d788a9611b76446c96431071f2", // 62 + "03f09f5b158a7a8cdad920bddc29b81c18a551f5", // 63 + "0098ba824b5c16427bd7a1122a5a442a25ec644d", // 64 + "11655326c708d70319be2610e8a57d9a5b959d3b", // 65 + "a4e77d9c0c9344921a0ac998b442ad572afdc5fb", // 66 + "c70cc62a2ecb15f4bf1b70904dd621373d79f311", // 67 + "dca1c821b7970a33a9a0524892ce7e49581591fd", // 68 + "165f932eb7f82c26d8169abfe3b665d92ea8cf4a", // 69 + "ed6c69d9e8b4373af86303dfaa3528dfbc129902", // 70 + "0dfc17ce9eaca1570de957219f0c65c0c1f13654", // 71 + "227c150957bf386497eb4f8eeabbaf9fe5ff5b96", // 72 + "39c1b19d6b81461cf01a28952cc1e19c70a93851", // 73 + "f0a70d70d40fe1f35eae75f00f4b93f70758615e", // 74 + "0b42031e70ce2d87ef6ce621cd4b0e03cab45f70", // 75 + "70c0661629f61a1d0c4f46d955e9bb2364077196", // 76 + "20521047e0cb556c8107dd69ef64cf50c7ab87c8", // 77 + "6590bb6183e647994a444556d4b62eb94cb6cfe6", // 78 + "d6ee025dd9e8ddcb7dfcc18cbdff413101ceaa9f", // 79 + "86f33652fcffd7fa1443e246dd34fe5d00e25ffd", // 80 + "5832a02d8a00c665b4ec18f9dfcbe54979caa05b", // 81 + "f4c6ed88a75ff148080b34df7f9c856018a9b754", // 82 + "88e81577c4f9448c16f0025b53004838f7859b08", // 83 + "14528f3adbb74803273e81411387c054d55fdbc0", // 84 + "bddb10b89d2d10b2b96cde0b97b409348aecb8b6", // 85 + "3082bb46204c789e26fde69e94820b28a456e623", // 86 + "9fd7015b929e57eeace8574de6df9e901af5bd70", // 87 + "81391dedcb14d639f798b0b7fd962dd7f8e94134", // 88 + "9e8212145ce950d3148e92a0736639d78ffae165", // 89 + "ec2706428417e71c758791805a187ec0075370d4", // 90 + "ef479c1c217d542575528081b581e8ca6413ad9e", // 91 + "a4415f768ed239e027dddedcf71f55cdedabc7b4", // 92 + "1ae7029e40bac38a8260394b5cf2bc92b4b78573", // 93 + "4d68135d91f016c1c12e5bf75136e23821f4db50", // 94 + "8090cbba60f76408b23adc3c2a9889ab29fc3809", // 95 + "01cd6c098788bf78c0d55b318fbebf5f19b31ca0", // 96 + "7d236487f7c5c0ebb56b4bf0832a21f71fdc9f0d", // 97 + "463a1d8c83a26ae37c83e1cc39969909a03098ab", // 98 + "8cd96af217b5198655e73780f35d522eba762244", // 99 + "7f9000257a4918d7072655ea468540cdcbd42e0c", // 100 + "b48cdcf7f5d6fa3bb58f5ee6c0005678ad1e608b", // 101 + "678b974507c4bd1f1cb519b3a825cc07e23847fa", // 102 + "eea095c2000092abf6a500976863fc3fd9a413e8", // 103 + "3ba90dc6eebcf1340c60682a38a41dff9046d307", // 104 + "00aa4640b077f4dcb76aee8c18ca017493f25ea0", // 105 + "9e22e3218c3ffd4737bf34437ee69e42aa5f8473", // 106 + "acb445923229e517f69e007b9709d88b90fed46d", // 107 + "dc52fb285f02dafb98a651b2c51745859a206da7", // 108 + "1b0417cefcba7144a2bce78154312abdb5f6d8bc", // 109 + "c74ca1df6f8dc7b6d19ac5ca15510b43dc2f1354", // 110 + "ac877859d427d9192054eea8feb3b8a403ef83a5", // 111 + "689993727ba37386bb032495e9dbdfb4dd1ba744", // 112 + "3bcfff44cf3237b9b63c661a530077f794872efc", // 113 + "387030ce32d7e3c760d4996f30cc5f96690e05a7", // 114 + "482c2b6d0089026a36845a8ff6a63757790f9906", // 115 + "b5bfd558d5f701656335e1b00db6fe98ffe2aa9e", // 116 + "95f8de0eea68497781d53a368ad9cde035e8c651", // 117 + "d0572de8e494e0b7b8e50302003fc4ae0596ef4d", // 118 + "ee971065aaa017e0632a8ca6c77bb3bf8b1dfc56", // 119 + "f34c1488385346a55709ba056ddd08280dd4c6d6", // 120 + "fa6b5a6f8ac27182f838fe7841ec6d2aef3ade29", // 121 + "05f805d3faea526f0d347b023b22042c89f63bf5", // 122 + "c78e6ef1050c8626772a175c11d0acc5ebc33326", // 123 + "29d2b14f43c797d078249ea7968fd19ea2a3608c", // 124 + "3ec5ca1d740852128d4ef51e3f881f7af5c233f2", // 125 + "1af933b8607e22788537e7350785c1a44c075512", // 126 + "89d95fa32ed44a7c610b7ee38517ddf57e0bb975", // 127 + "ad5b3fdbcb526778c2839d2f151ea753995e26a0", // 128 + "d96debf1bdcbc896e6c134ea76e8141f40d78536", // 129 + "e1cd437ec3e8a60db34e1d150a4fc73882d83b41", // 130 + "a6a380b8230741b0ea02cfddc0d56228caddf7cd", // 131 + "416714cfc2d392ba7df0d4b3de554d0ae5f9a7c7", // 132 + "7fc53563e0a1c85f8d9e81f48e1eeb78edd7a14b", // 133 + "a662bb8c73d996a25dd7c77a9a24797e5b080f7d", // 134 + "71f9ed42b368201e6e0facebedefc3f46575b67b", // 135 + "d8db8554295cc90abae5ce8b7595171041f2a051", // 136 + "a59ae586b7704ca9cb92d8fc7ba95e4fc57f52d1", // 137 + "7eebda1e4ef50254d5e806ccd18c6ac75066bbba", // 138 + "645a899721c3f9752817bf86aed75d4383f293f7", // 139 + "c8b41d9511bd47ebc032a5ba5eea8455e4429483", // 140 + "569bf6b86407355db4f3ed70b0dff9000bad2454", // 141 + "9ca3ad1419792eeeae95677c799e86f3de0e7bb7", // 142 + "901fde599e9a5ce6b811058f074bfafbbf33614d", // 143 + "02eb7614e4c4cfe9ed6e865bdfe1585f876b90b7", // 144 + "cc03ade0cf56707d85330dcecde9bfa084026989", // 145 + "0a12db11e3a5cdac18391f41cbe4623849990d30", // 146 + "aca588b6d445bada282e8ab307b15eca46cf8f71", // 147 + "1d03edb2f80169e9d3af120da97a5b30768cb614", // 148 + "a5abd6b9702af8f3da86dc3124882be9a6d01b11", // 149 + "e43157aa2bd6f7e9c797ea49441eb9ef39b5a422", // 150 + "0b6def5d5b9d8901d6c613f18ab90181bc7f1467", // 151 + "809a0379e7236ba53b1b50ff281a59ce7371560f", // 152 + "634294df52c780bd7c0151264ec11c3f65fb4d71", // 153 + "ffb7d2b87e9eb43378b68fc026d710ff69b25a1d", // 154 + "56062f906678cc58783e3e55c8fa97adf31c6492", // 155 + "0000945118d1301504ad395a40d4ea12667e14b3", // 156 + "9a76ee560804191520946ef03ad6e0154bface2d", // 157 + "e44f20c15bb05ca3da09ac53350b9ecae160ba77", // 158 + "5e296337a3eb14c7ef4d495bd4495dc00167311c", // 159 + "6a64fcc1fb970f7339ce886601775d2efea5cd4b", // 160 + "6ac571c0f3103a21c783d7f135524a0487ce4d54", // 161 + "a2d336bcd32e8ea755d7db9ab2faa9b7fdb717fb", // 162 + "df41eb4ae30ca4ec303d2ec1fd46298fb36191ca", // 163 + "dd4d0428c2b8ea31087a03dab1f43c0a89009075", // 164 + "5b042dc9f655d16b8f4682178e75c974165f6cea", // 165 + "1516b787d2d48897eee3a77d3cfee52946751838", // 166 + "c174261a8504a876e42d63d94312a25c9330967c", // 167 + "987ff8ef3e1725fc6ab2f471aec20b87b08008c0", // 168 + "e7b41d4fb4cc6c5eb5afda9416c2718e273034bf", // 169 + "8e555ee9e580c46479d5a2f6c3d4bdd5cc9fa5e4", // 170 + "75680c173cedab4b81d544a4dacaa55da964ca41", // 171 + "7daecf5b854aeaa0b34e40d5fb3bd7e2342d469e", // 172 + "18db5093a476179652c91dedc3cb1478478076a0", // 173 + "0a9ede0e79ad6e23074581ffd9ad71691691c10d", // 174 + "0fb93da53fcb7b640ad89fdb6efa14507cb3d363", // 175 + "accd3c08412449a2d77da65e5317399f8d969115", // 176 + "7195585494d3dfb53054f27aaf7f224584e7a3e4", // 177 + "8393b71c9f09718274a5d60fd9b41523cf24c51f", // 178 + "acc431419b18ac2cdc25f23f88c0246b735d113b", // 179 + "6707349170bff8fa01c57bbea05e1c6a6edc3773", // 180 + "9bb51971a165372df625e90d943c071e65063872", // 181 + "aa304715587e5ac9e6dd5e9878bf460378068963", // 182 + "021fedbd6b884e3cdd89d7477ffe852155c37456", // 183 + "2a4c545628c4875631e342e101f8af11cf48d252", // 184 + "80167ddc34b5d32a8d53afa396b169ed00cc33e8", // 185 + "30ae5873ea266205e99f6e9db75b75cbbb3394a5", // 186 + "3579811779728998960b7faeac4858dcf922f1c9", // 187 + "298eb68cb3b669f20340ec0db9ccc9ccb95f86da", // 188 + "d66eced1f408c6d835abf0c90526a4b2b8a37cd3", // 189 + "f077c7d36a72f6ecab2df866a57023199c6138a9", // 190 + "f0d0429532d8c279879349ef6d15ec39a1f337c7", // 191 + "9b1a580cb91c62712ce65498ebad252a1d83051d", // 192 + "8a3a12a43de5d50c9b65809e21f11912fd66a237", // 193 + "ccc95f6a3f3f4077b7adace983f8803414567918", // 194 + "1d238a5ed0f185847eba49a9c0085ca183667302", // 195 + "1a4ac3bde5a535fb07619c831c2e1e462d10f0df", // 196 + "c6c1f2ad66da2016f73016e4d02d8a0a372c7418", // 197 + "d6b03a3247428162d1ebc839fc34ddee3413b22c", // 198 + "4c5da22d429fedb135ae85f65d22ae31cc4fe97e", // 199 + "e61cfffe0d9195a525fc6cf06ca2d77119c24a40", // 200 + "6f82e951f58a5d922ecae46ab7fcbfccecbc6849", // 201 + "85b69533895f9c08184f1f163c9151fd47b1e49e", // 202 + "c3fe988cdfefea20dda191468b7aacfe3ecfb345", // 203 + "f65ceae8b9aaed19fe7c55e303c4a5d8af82e6b7", // 204 + "87f6bec8bee30f571a968a5a8da3afb8a4170126", // 205 + "37e13cdf37c83cdd8c578008a3644b60de65ff45", // 206 + "5edd60ba1e20379e386025845915abd6a17b2704", // 207 + "8474bd5d30db0c5c0309ad76d545628d5090ea95", // 208 + "e576955fc78af54612a8d7d670dbcd0d7bef096b", // 209 + "63dc59f7d8d17051675a44a4058ad0e1302ddecd", // 210 + "51311dbd7c15fe4b0af22d644c9f896a48d261d9", // 211 + "398f59dd148f98193f57a41c80b891726f46ebf7", // 212 + "fd2c2c4219eb99a756dd53dd27a0cca87a8a9be6", // 213 + "12151fb1044493cced54a6b87e93377bb21339db", // 214 + "5df84a6b00c7be4a3b340c88c9f7233d894d8912", // 215 + "c1adec5a164e04ed60056f8b996f448c05cfd8b8", // 216 + "dc997eeb9fff6127a739784a9966ff568dded967", // 217 + "4570cc3f369a794767f4ecdd20f420f7f81404b7", // 218 + "91fd1a7362a88ac86cdcaf0c12196cd4ac54478d", // 219 + "dbbc783210eaa6c7a02095c3a9914e99b7d5be05", // 220 + "7febe42b510d93255c760521c9a1a7447f95d165", // 221 + "2d2500b555ea06c686311dfccd68088b5c1ac4b2", // 222 + "27ba26204d48db28360ec5f7d6aa52a1ac336a83", // 223 + "023e576bf939e98a657997f787035e9892b1d70a", // 224 + "6b793ffc7e56b66e733cef1ef334b859beb4a903", // 225 + "dbf5795bac04f99f4d87231f5ee071e3e7560b88", // 226 + "fd4b65781d6c691787b94b705318139965cff3f8", // 227 + "75b6e6b5b384ee78096127e266bacebfdb926010", // 228 + "06f05c4f697e324cdae3baabf44ec593d62f1bcf", // 229 + "ce7f34fc1d00cea3c2291033001f34148ec93ef8", // 230 + "78102ecd1085bdecc2015cbc4409266a589c0e51", // 231 + "6679a4670829915f4c3e5aa62c9076ad905d1873", // 232 + "9f53db04778bdc39a29a290ba8e224a3f1a76aa4", // 233 + "847deb2731d629a2a77bd8d4d8f40cc06389fd66", // 234 + "6eb2a0dddd1f33c727b4edec1b604e03982eabd1", // 235 + "718b9e157887c80a219f5d394861a3c30a0c8d9f", // 236 + "601a6c7fc730dad404ae2bdd68be69d678990473", // 237 + "0c0dea40c49ca7d5cb20b7fef8a2884c9db420e9", // 238 + "fdc30857cf7b957f47ebd8288d5e5d7426f44394", // 239 + "c056f00fb97abb4c09424e3473562ff19d6e80dc", // 240 + "90ff589d3c5c0fac624a0fee3d3b14318ba360e9", // 241 + "6dca42bd8695b214c265b41d1d7efcd52743147c", // 242 + "06ac3d2e7736d99e2992a099032600625f1ae29a", // 243 + "cf1f038f744e05b321654bbfec9740a859a63106", // 244 + "7e74fa3607830cb941af3a7fda08c5805dc6b831", // 245 + "89331885ad350a6805940d0983567d8d2afc6b85", // 246 + "4f29a70ace594ef0853c89c7c7522cdf8c156b92", // 247 + "d1d8b02edb3538460a99c23d618365b0d6d79ce3", // 248 + "f0102b8dc84689e1193c5e705f0567a504f08c0c", // 249 + "b5d5e3e0fcccfb49d704a1e10bc97ce9761a14fe", // 250 + "bec71b27a6b2710dedf1d7135c47f4506051f089", // 251 + "7cb80dc70d8c28e273a8565397e98ceaf4f435ef", // 252 + "9c49077db81495a563dad18a5c5342236089f045", // 253 + "2ca6ff06b753061d68872bf86dfefe48d6c90031", // 254 + "5afd9729928ad946eee5610434e66b5f95accbaf", // 255 + "9c78512ad150c8b5d8918395ad0e5169397d2b62", // 256 + "0c1038883670f8a0203e053eaf67dc2dec280b42", // 257 + "57f53ed5598524493ec576770aaf3bb10063e3ce", // 258 + "b39d82bf2b963970701bb36ce4e5fd7ddff600a6", // 259 + "c64163f0d0ce11965bbd5b3025f9e79d2cd1fe2a", // 260 + "09ed130c28208ec8f840867e9cc16390da0143f8", // 261 + "8e69241129011c4a619b74f0f56dd20e41459b24", // 262 + "597e7a62be80cf009bc8c60f3258dc5dba970ccf", // 263 + "97050b6bca4f374cf727723059abfdd623bdb758", // 264 + "58b4afbea4163cabb1e40836f0bc80e86ae79e01", // 265 + "9f05a2711775443893c0c6c029f70df68bf98fd8", // 266 + "aade5b0ab22a27e082713fc2b30dad3173552df3", // 267 + "65bafaae0b0fc6aa8ef7b03ee5dbb2a2954929da", // 268 + "332f74f6b214a1bc1bf073fa0eef0341d068d5a3", // 269 + "f45754d4178c15b55d80b24c6827f9d902cb39de", // 270 + "34256514589258f13865f80f187b57ec4d9c75c1", // 271 + "2b76c273b38eea12693fa61c5dee3d4ddccb032f", // 272 + "237a55ae9be83de0d4e03dff33b60c47ab597e8c", // 273 + "6e5e1f483667286d2249e1ec99af494dcb68552d", // 274 + "eb501eb848a443f219e1d5e7a61c90f191bda35d", // 275 + "b0b3e2bd89c7dccea16c44859cc08648360f8e81", // 276 + "2793fad73fb189f74bb5db4d6885e0bb8d223a41", // 277 + "abbccee6433159cb7dc96db49564cb0c4c06e0f7", // 278 + "144452f74b272fa949bf4e103a27399058ba3aec", // 279 + "291e60a44514c5836f1c9d3a9a6cb0f3ca3aa0ee", // 280 + "24a44dcc98ab6ee81d48ebe84f8f129d6962d4be", // 281 + "076463bf7fda7ef36ff08d54a989b4e701c88b73", // 282 + "59404688ade8da321e81f2cb2a2f2efba590ecda", // 283 + "bd75d862e9724299389c7b7ce3fe045eaff4605a", // 284 + "ae5fc3456b5fc09fa10dac0ea7f7f74d70015c0c", // 285 + "2290a86dd82c0a0ede09be8a9c6004611be3243f", // 286 + "6f68682e1d16033132e5870b0baf5d805588db30", // 287 + "c5af5bac8f3e5449d0b4d806efbc461174bda0c1", // 288 + "6009286f94cf32b06f16fcf6cf2f2217eac0eb2a", // 289 + "765f211ed3401bcbcb3c78b5c7b06052dd8aa29d", // 290 + "511ea304c94d16334daa750e56437bd7067d4e71", // 291 + "8943565b1ed9315f36ac57b1fd58bde18f3b3dda", // 292 + "004e64460897e6aa4aa25def70d58db2aadf689f", // 293 + "8feec92ec687cf673daefd0eaa0cd9e20a4cbc09", // 294 + "13856256c3aa19828f3853870d13c65d673cc4ae", // 295 + "49f03586a0318f306206b08c02e9f76b5426f39a", // 296 + "04db3118f54fb2683064cb0c93506890034ce975", // 297 + "4ceefc9242051aaf26895f075497494101b3e14a", // 298 + "5587ccfeeb667b829b6bba19747d9472076c6ec0", // 299 + "003ef1ba9ea9f2f4dab3a2004e505ca8a4e7e5a3", // 300 + "26addccc6004b7102053e509668a0104d6feaf86", // 301 + "43920af08233acafc3043097b5623eb1739d6cbc", // 302 + "aa00f60906a3507884ea5b31597b4373c503dae4", // 303 + "55600b1d4632ed97be5bfc11638a52966368fcfb", // 304 + "25afb3114f22d2358df1bf1ac1119afc6ea65d44", // 305 + "c752791be82d8e9c1b5eca7d12444acc95a2220e", // 306 + "88ecacb80a79a45bc3a84b0cc9d8c05e07823280", // 307 + "710acd9ee07000eafe976db2b9c220fc164e46a0", // 308 + "84d91fae5dc9dcc2e22ca4754e420eab8fb43430", // 309 + "e91bb8ff4d463a0398b8d0207ff94ef78d11260b", // 310 + "0dd214b19236810ce759ef3717765cc0319d624a", // 311 + "d6ddefc68d4f7bd79a032a6f98d23d45948d444f", // 312 + "1843bec940f625dc26265392e28086a940c2fc6a", // 313 + "ff0ad2b82486645ebf945a5858de7f9224d1bbeb", // 314 + "f202bc0aa200f934ffadf60a934e5d0874a55bed", // 315 + "ef5f13e0e68037d3b7e20841453fec67e1b06238", // 316 + "f1d9aaefe3fa73f147cdd67c0b44020a517a4344", // 317 + "c8b6bbc39a6b648d58f1f5ce3cf2533cab9b3a42", // 318 + "cc6cd7d148780363b075a4552cb83df6cabc66e5", // 319 + "c673529ce3a278578ef05d2b80ba3ba364e32190", // 320 + "7b916dff009488fe247220cae3b9e39e90c70c48", // 321 + "2de2b6e6b22fc73375cf310ab9788a220c709103", // 322 + "69b142dc63b248faefd7901a37dd0ca0c416b536", // 323 + "8cf4925dbbbbcdff27dc95a2bd734d92384f5a00", // 324 + "b199c0a34d150b2b15b16d83ad580c6f0b562725", // 325 + "f83f6873779170ea59effc6435581c9730485d96", // 326 + "9cbd7d216de81b6b0c73e492e12e686883f6bcfd", // 327 + "ff4a2ad98be347239d88275e3a39a0058e59e9cc", // 328 + "dc269315237e106e9b90d66b9c04a45704d5cd9f", // 329 + "e27da815bea4d4a9b236c92094bae22059116bcd", // 330 + "20ba206b4d62ab60288e9411ac94600de3dee50e", // 331 + "6e1b83d9f149bdc79fd032755d3598749cbf99f2", // 332 + "42363711e9981efb0ecccbfab3c47a79dba1e507", // 333 + "30c78faaa4b66f6b2cef4651e5e313a5b06782b9", // 334 + "1c9e29a057c416d8da48096aad1ee4240d08e71a", // 335 + "b4bcd9ec0a7de3c43ce2b7d85dc3fe05dcd6f796", // 336 + "05c5f964c71b5aa2db4cb511a7830ecd05ef8106", // 337 + "5583d27b19c9446d370cc248c5cc7be0f06e8206", // 338 + "9f4726cd8239b5fc370a5c2c565034fc0646c99f", // 339 + "4c20e7b2d0595b7b7aa8b820eb96ca268fb58e3e", // 340 + "141b06daa8e0c459b21f3bb0d80d4f41921fa3ff", // 341 + "f45cef7ba7925cd0e683399aebb5694c82bae044", // 342 + "1421d21136ed84cb41c170556f24d5262e3deea1", // 343 + "604a28a60446c955b38d9d0a3190194a0c320fc4", // 344 + "6e014a0da4d277fee1ea7e315ccbbd117bd2ad66", // 345 + "5f0ffe82917d218658eb933b833c733e4f93146e", // 346 + "cf0cc202e3061e3647690db8a9a30f394bd48614", // 347 + "0d12d64aca89c5e9b931676860af1d2d1fe8464f", // 348 + "82eb22ae190fa8449d3f5083ea59b53e496a27c7", // 349 + "6770724815f9a08aadc6fc14332b8d6a65f4dbdc", // 350 + "4a72662e38ca5a9989ad7adc30dfa6852167f52c", // 351 + "5ec9697e4f020c095e2017d76ea6a07937e89392", // 352 + "24577d500076da68f3bcc9f1171b3100abc6e18e", // 353 + "f12a3951b585f84f59b252282b9ea64b8cb4eeb8", // 354 + "7807e4134c0b0203b8f726b7c4a4fb42f93ccd11", // 355 + "dd6a0404e694d7184d0b51bc7dbebdb883c4e3fd", // 356 + "2630486b981c233b99641093d7b10e9410a0b77c", // 357 + "c82c81cbeebed466c088cb88464f290ee6c3ebd0", // 358 + "8ad0240f04277b618adebdc619e9764985463a34", // 359 + "10c15e406c8fa8313bda7f456481275c369afafe", // 360 + "4af77d7f84c54937a5a3f724b128a53933c5b8b2", // 361 + "29606b8576823bca9e1a148785bd4ebb183e4c82", // 362 + "bc755fd0205496180e63930a30a1b5a57ed43391", // 363 + "ee16840e0f3355e9ba3dd8c8aec1ed934d9302bf", // 364 + "1abb10c1e5806f53719d292ba7286fe8ce38da96", // 365 + "df8ec8b161efc9261b75ebfec7642a2289f54fe7", // 366 + "517f6b71d7779ec2fed04e3cf6bafbb2e4fe27e3", // 367 + "5bf95ae2ef67abc720941d4d34c5b839a45f8d77", // 368 + "2294f84c240b77811195401084f0adec4bf8cddb", // 369 + "78baa0f49517a0f48ba47345a9cfcfa1dbf8f120", // 370 + "89dbdd186a34aaad6865f0ccc925d93b1a02672d", // 371 + "9b35dd371a22f78cf3d158af08e974181906ebbb", // 372 + "4a3a8e22558ddac18f8678499566760412de7797", // 373 + "5dc9f99c628d2acca4f5557d69916bfb491c79a0", // 374 + "f947e05bacaf9db437a371c825042de0f0b30053", // 375 + "75ab089cc063183262cb05f7672f8ef81da61583", // 376 + "372c46ffe0cdb26b0cd29193d41a0f500273333d", // 377 + "b14c46327f8330386697df0c5554b0aca34c18c1", // 378 + "0540e6238083313d8fd95e0591bd7a138b1e574f", // 379 + "35968a8c675f635207efec7a1f7cb739422400cd", // 380 + "059ac3098e77f025c9533375d05bd623819fb0b4", // 381 + "351251e4ba697bbf7b874139f398488ae5dd675e", // 382 + "33e6b2eb430978f09378f15f7ea495db21022b52", // 383 + "45a5c851bf12d1e914e2753354aa545fa532a41d", // 384 + "178ee26649a58cbff573cee54cae92c053f10e7c", // 385 + "9393b6bcc9e8cbb313fc955995488b9d2cf2c085", // 386 + "a2a31eb4a5dfd3826d5f6896db662dfd88c619d0", // 387 + "cf4e422bc2177e70989736562c554f0054dd9a05", // 388 + "e867c031c1859decac313464032b0646620a816d", // 389 + "a6bd82634882fe04b809bf775710ef51e5c5ffda", // 390 + "6fc12571d57b28abb741d23ce38fc6b92e79710a", // 391 + "3d2f59f39bfd91c72a2d109a42f0a3e9d88aa5c2", // 392 + "f1d5ea525d940be9429acf6eb8ac942136e59ad0", // 393 + "75add857fca7399564bd535f807a43d493fe1923", // 394 + "2968a578d2c1adcc202a2b62675e6791d7eb305d", // 395 + "b19e05b3bd77129f7069eae0d64df0e9c5d086ac", // 396 + "28899e614008afc44fdf051ce6f34445e345051d", // 397 + "3f49d39816b8058b367be53bbe2faf5292f1c8b9", // 398 + "f4ff3881855b66d1137f6a3753ce23d2c2d383a5", // 399 + "f475597b627a4d580ec1619a94c7afb9cc75abe4", // 400 + "23388b524afe97c0f7e797ad4224912460176a51", // 401 + "546c03b1d040b5780abae31b0338fbf09de34415", // 402 + "b7c571acbf2c4c2897a07f648857a46681171efe", // 403 + "ba1f47c890e6fed71c920b7fdaad8d8439ff7ef0", // 404 + "38e8ae104e9f4e10ac417d3c1e30113472261cf8", // 405 + "f855ec09daafcd7b5691be99c00462ce28a067b4", // 406 + "c345d5ecf4bee4fbcac90dc152d2e26ad6f041fc", // 407 + "d9b78ed62a55b2d8f4a3ce26f1b7b5ac12d400b9", // 408 + "6a83302817711745248932712afe5cf44d58896f", // 409 + "8bcd835810fe4a2ab88d58bf8619c28ec5fd9cf1", // 410 + "85c1253550bbb06de7b5cc8da9d557fe2fdccd95", // 411 + "f2dc02c37f977915c53803228241967fbf7e5b36", // 412 + "5e0a2717a3f1f268699f177a2d7965fd56a8e335", // 413 + "16be25bedc509b478b55b8c170d0e662437c7642", // 414 + "45549b8d4c507ecc98ec460e914709811773e2d1", // 415 + "97b9fa5d10d518b485b516276eb28c628d8bea0c", // 416 + "c1540a87f5bd3e7639c2999abe08c9d43c4b47ae", // 417 + "7d09f69ff6717a0404718b1b8b163cdbf0cf5ef7", // 418 + "7bfb5ca80b057598bb94521e1be6c8f4b5180054", // 419 + "b23d4711c1d0722a444a5cd50c49f72313de481c", // 420 + "57a0fd42667700c40a8f06e613c8af8e641735b0", // 421 + "fa742187981e459b687b25ca214c738bb2f26bfd", // 422 + "6938234afc07e4764add1f7644e9fb1cc871245a", // 423 + "270f1f62465c98a4b389fcda9fc460d08ce425a9", // 424 + "897912e2a8ea6060a483efecc80f0bffd858083f", // 425 + "91b652fef840044e9abd7f52b5010b8be760364f", // 426 + "6b83fa13d0289b5ff09ebadfa22275af94d6a368", // 427 + "9a470a35fa7c6617404d4d6bde3cdbc38ae7bae2", // 428 + "12e8f3c3a7e32ecf255dc718444c10e5594872fc", // 429 + "eed015232b38b0034a58c4c6c77fae24a0d9ee03", // 430 + "f6564cb5b86b0cb68c7f63531e3339513935498a", // 431 + "d489185ee1292226b86f42a833a91fed3eca1be4", // 432 + "ede9db17fc35ff699be8324b2f7eaf3ba357d748", // 433 + "27cccb87379e6deef4e1f97d7ed2e6b5ef89a341", // 434 + "58b4de5037a316a5aad1d085506406651079b171", // 435 + "6171dc410ed9959a727d436f4ef9afc18820c898", // 436 + "70b80cbc7d9d8664e3b62dc29d02f1784f0666b5", // 437 + "a16faa8149ce0eaa88c6b6274f583330170ec5fd", // 438 + "bd34c090f3ab7a68f7bcac891c52823612cf49f9", // 439 + "81716c15a7544d8423609b78ff61851026f93288", // 440 + "de1f9f166632e6f853b8a24fd6183279b194c1b6", // 441 + "9bb2ef4dccedf8dbe9955e0c5b85fa83b832c8b8", // 442 + "f6e56ea638f19166eacca1db3303187e8be2d6c0", // 443 + "25b68136f57ae3f6b7743317ef78afc63dc8c1c3", // 444 + "37b61b901d3b927f507b2ab5fdce9e7c5042b5e1", // 445 + "d12ceded9d526a0f88a9d065f58a880c9f0a9fa2", // 446 + "9e7e751a3ba7161eccd05b011e764c30e588c8c8", // 447 + "7c6d6ed601becc0a68dbca91800a634cd1e5e5df", // 448 + "764175f1ea86f8908cc27026c86ccfd8718b4e76", // 449 + "1730ac0fb7469465fc528643cf08df1ca43d87cd", // 450 + "72f84a3623ff10857796e1a94a340f8ffb7e50b1", // 451 + "0a7648e81add9535f40c154e50196aeb93b747f3", // 452 + "e875016086a37ad3660d91c7924eb3b5e5e228cb", // 453 + "d197af6f4d97d9f24ecf9b6a77939a77ee2d1fec", // 454 + "e4afb433559f2910fb8136de69b3891a364599b7", // 455 + "7d799aa3897a8f87ae0ee8eb57bd5f32bf6ac352", // 456 + "2b267e893f70b9b88490bb6583f808599a8d9cd3", // 457 + "0a87a82c4ec495bf12e63ad8f6fdcff294eb6770", // 458 + "6164f24ea75fb9774bf85047f4f069c69871c7fc", // 459 + "e6baa1b0a26bc8c0be242c1f4bb2d8d880253c5b", // 460 + "a20e83c61128dd9ee770d741fce490887da0ebc3", // 461 + "eda214169916565e939ef38ba1c21f843d5915d9", // 462 + "4910778762635554d6a3b6352fba292f6cd8823f", // 463 + "305f1565f5e6a6b0ef814705192ab4c2f443c760", // 464 + "592e861e00c8cfa8d57641322ba2a60a302fbeb1", // 465 + "4991b27f09a8eda5de4811da55e0d38e3f491f08", // 466 + "9429362d6951f4a166e0a5a9d9e15fa63ccf7487", // 467 + "506dc822dbdc192e18d52e01bce5f8d3e5c8d3ed", // 468 + "e1d539cde4cd3e9de50c2ffbe4df94fdcdaea2c1", // 469 + "c5f2816f508d9fe7611acba4f72be76b920ef6ed", // 470 + "48d174c0c7f41d7449e72621144ad012205552cc", // 471 + "89629b6d5122748955afcd3aefdebb839203496a", // 472 + "7af65b0b82ef6725a82039e32783d58ad695229f", // 473 + "fa69329c85e88b12435a32950653ece16f2c1854", // 474 + "5835602d65cabd48d5efc94626bb317828c8247b", // 475 + "f1e05aa27ec99ccb4e81a45000285e0f4a11fbcb", // 476 + "b70dfe8e73bcc562386420ef9b9d9c7c8e478291", // 477 + "ed8e5566900ff98db39c0454c33b665490ccdaba", // 478 + "fcc319e50d9b8cd7a34606f1ae1b40e0f17a04a1", // 479 + "72dffd519ab4013b16c5fac49cb5832c33ddb34a", // 480 + "922e0e809e714d1e02530a71aa182a0a2d9e10fb", // 481 + "ebaf7f2855c2012bcb80b0ca5bc72b1437a927ce", // 482 + "efb2142e87e003b11e82ac2c2a5a0051a0b704f3", // 483 + "edf5f6b3343700135215e16fd5ac4da13a72a4e0", // 484 + "6b774675a386faa429be79ef3c8b90f574150732", // 485 + "d9f33d01fa6b16d3a830ddcfdef93aab32027d02", // 486 + "e61864f12a38df8fa0b855c5db3988220d7102ec", // 487 + "c6136185cac8107350ff46d20340ec9c0ed65b0b", // 488 + "cf514eb122dda7aa435a463ecb35c91e3cde9527", // 489 + "5145a9b30db2dd2fe5163f224263db5edf0724d6", // 490 + "5ad10383424fd57587ba2c877e192c56cb0e70e8", // 491 + "361b94069e9bf1a267d0d20e96d70303bbcf4042", // 492 + "9d422f4106662cc1cdf3b248eae98052e4bdfa6b", // 493 + "2ea42be60116f6bdbb6bf4e53dc3c9e7e504e7b9", // 494 + "bdffc8126c60b536c633fb93e86668a22777a6cf", // 495 + "606b57aaa923524e75cf7eb9c29650b7e0d58815", // 496 + "78161ad5d60ffb9a4e2928a68a566265a1d85906", // 497 + "8b785b3340d8f036137ada1b3e282215c4475973", // 498 + "678289ab34e31e8f03e54dcfc80383ba9399509b", // 499 + "e62ca5609e96073ffdc80ad480510d6de0a13f3e", // 500 + "54cc6110b556affb9421366a9b16c55f6e6173b0", // 501 + "5a613150aa0397eaa80aa8bfd6ecbe12a994d6df", // 502 + "051fd7abd82a54f4140b40d5660764aa571a4e08", // 503 + "f2eebd10e086c5efd9a4cb99db6ee15cd89456b1", // 504 + "37aac0adb39dfb652f2dce9eda2b6bc950286223", // 505 + "3cc86decaef23e3929bcda6a2a312893b975cfe9", // 506 + "1f7800b810e46e4f10b1d5d3a93a756b250ea22a", // 507 + "8d4d7885d8c2a192937d9c7b206ffcc622a2bec4", // 508 + "edff7a135c2e06d4c8084e61b4516c901bd5fcd0", // 509 + "17e37b4fef409dd27ecc85d90f7ecb3bdb6732f7", // 510 + "b9370eafb7ac772c6c1dc6b88ac9ad466b880ea1", // 511 + "164557facb73929875168c1e92caf09bb6064564", // 512 + "87ecd7233dbe9d7543a9a199fc671a90e469873d", // 513 + "f59a85c995b4c6728098b9f4c24ed2cd20a12f3b", // 514 + "6109298e3640afb91292e6e3ef275721a55f1395", // 515 + "05bf7362f085d926fd1366cbb724a8f437d2beb7", // 516 + "1952908129192c6bac72c47c89a682a6be288e6e", // 517 + "d228a269a9d6a1ab083ffa196fb25052556aee76", // 518 + "39923784b4374314c251f67e5119c2f5339c83af", // 519 + "5d86f9f59d148bff394a28146d62578ac72450f4", // 520 + "34a7f289ee393695f26193ca2f213fa4fc203ac7", // 521 + "64c29e7981df9d6fd99b647d3f74fae602c67942", // 522 + "8990062edcc0f5f1209ab1c05d3010c0947d1437", // 523 + "b46586c9c2d2976ee70a1420f916c96f69840b56", // 524 + "a8141437acb5be1fc655829f493c80be955d0544", // 525 + "1acce31a81dfc001446f814baaaf3630b82aa531", // 526 + "fd25f9f3c0a29643e778dc897f093781f0e35324", // 527 + "bc75c0522ef1abb38ca7afdfcd064856b01ff423", // 528 + "eaac34486730874984074a50a9a961f9cdcd6d4d", // 529 + "baaba0cb360c7240923d6d02101592f481ea99e9", // 530 + "0fc02e9af8de592df2a1b8f71d0a7065212749c5", // 531 + "7b428956b2a481eed25ecfc9d7fa4c7c71237abb", // 532 + "46767a0f2bddc74326d7ee75d7fa27ee5e09c456", // 533 + "3a68b76dda38ebfe49affd2cb757cb32e4f4aea1", // 534 + "f9877c664809ada259ee1fb4f49882b331f15bac", // 535 + "645ff0ec7a4a7bac552cd450f313c42d4135493e", // 536 + "05d5d3158783ecfde0fca6ca19dacea483d495a3", // 537 + "4834c9d2da9016b10b29336cf0b95db1cd3f60cd", // 538 + "824f2269e2e8e981d5ba6309a8b7eeb20b6a7ec8", // 539 + "5d0614c5ac2497faa70d56166fb5268aa84147b8", // 540 + "96b1dd79a9f9de5bbf728759233e6d31f7987731", // 541 + "f4fe4897e645c80e5772d7152317d344c1d47983", // 542 + "34eea0d7810cf5b9993d029e4dfbbc1ae8b93954", // 543 + "3de476764c3de6e42097e0d12dfc375d150be947", // 544 + "7b9f307d177a56f58183954ab33d4c562f85fd1b", // 545 + "aaa5ad4e7bfc4a4423ca7206837816155cc429b4", // 546 + "baba1f3332b87494ede49754c3bb81733ad0532d", // 547 + "216b21464e3243b69defad115ea78ff1d83179ee", // 548 + "8cdee9b4a695abc107a02c7dfb75156c9df474f8", // 549 + "fb64c72c214a621723d46d792895d54f84c5d825", // 550 + "b2ccd84fdd2b3cbeb8e2cc27b5165f045a10675d", // 551 + "fc705e7ed86427ff37681e67aafafdfe94144e29", // 552 + "ae8ac2b27459a457f81832ba0fa49896386f0fb0", // 553 + "e84c87beef737dbcc7cda0d2c0d5d8cb08a3496b", // 554 + "b5e8dd8c9c207648635a6c4084e2b6c4c68d1eff", // 555 + "e7c169a02fe12a2d71a080abac70ab0bb52f580b", // 556 + "96543737a83d4a2d79af8a43b8bd18df14e24ddc", // 557 + "2535553f7ce88a60ebe130a04129985be2aa10bf", // 558 + "26150966737d3ec463b3fca44a54e4d8f6d887d3", // 559 + "0046b628e9852ea1c9610ba4e5c43daf9bc06456", // 560 + "033b095f2458cf9509567a2753044e176b8d6a03", // 561 + "831b2d3eb776e3efd972c7ae1b4be822992cfaef", // 562 + "eb537a3859138dc953c365e36f32d320834902f3", // 563 + "436b0b47758c2a88d7bd7e0865cdafd40fad372f", // 564 + "c6e180932bd358dac5afce08a371fe4eb00dbce2", // 565 + "7291484a03365a1ee5fea2d54573348933396356", // 566 + "e34aefa6cb5b7e20c2a47fce640a1065f258f5b4", // 567 + "239acbf131c0bfce811708a0086283546f929da0", // 568 + "fefba888dd722b5382d4495c923a8b6cdbb81c3d", // 569 + "bd3e98ba14f07c4e50b20836b5fb83fcda1c419d", // 570 + "a16472d7c010d2305f0d9f58ebbb306f593cfdc3", // 571 + "c6d0cebf1cf59306ced866e5d1cf787339761b65", // 572 + "ca95f3b135751067cf5a40f22fe1e61319b34d16", // 573 + "b2222d83e1efef6e73744e4316040283df247f4c", // 574 + "a586ea6e41c10f178ff69235306827367689187b", // 575 + "ecb31a71d15949ae6ef0485996e6bc336f8566e0", // 576 + "a2cdc024a935eccd131a7278b45b1190d9a3d974", // 577 + "d3597a99deb924a5db067cf6f55c11057fae7b75", // 578 + "bb315810b3fb9acd807915051e8b020a39d77c11", // 579 + "e0286e65430039f5206a7cf308a7cbb7e6da1309", // 580 + "6e1363b06526bb6c736bca39338307ce77fbacd7", // 581 + "ee6df6b7db030aa4e69d71d928dc1d0242fc1723", // 582 + "1eff5d7e02d74f4eebdfa9a099288020a5de0757", // 583 + "5aa89f4d613f397931e3830aac1a9413a04bbfed", // 584 + "0eb45e04b2491c518efaf14a5735dbf0241ad7d8", // 585 + "01388f81c0dc381f5d4f1baf205c1f95ef7c282d", // 586 + "b01e4cc37780b932925feceea3b54b3b2f15f01a", // 587 + "26e3fc300f5ba5148c65ccf605c2f8c74043f70c", // 588 + "fe657c04714bfcdb6a69180edf8e63daca70fb00", // 589 + "b3512c0e254db35cd7029978356534f7247a6da8", // 590 + "4ffce762bbe514627684755d7df35a950d0ce70e", // 591 + "9dd88613a25c2dd3157910318c6b5c70e6732386", // 592 + "b6206ca7db21c6cb14d2c67c102f3d07cedf278b", // 593 + "31e45f78e73ded24ee58206bdfeb6013c612f5c1", // 594 + "1cc30b4ccddaa4292d14daccbc2461550a42de07", // 595 + "7e6947dc41232e86d4634a2602c440959467051f", // 596 + "e220348a2c4f20a83a58a2594b057beeef4035c1", // 597 + "52a0f2ae5159d5cfd2ec53fa215c6b851b0cb464", // 598 + "683f31dde2fa57fa06c0d3894a9be02440aff783", // 599 + "201b1862ded23937082498eacf68480149c18f56", // 600 + "40336973c0526e0a67f8ca5cc376ef6d572c726e", // 601 + "553d129f1fdaf80735d6f402d281fb9174164fd3", // 602 + "c881af38484a5fccea395e3ea51f4952137233be", // 603 + "a74af8626ea8c776b16733f49e691ccba3cfecf7", // 604 + "481aa594d8aa20c7f5a017ca143b8a04dbc5319c", // 605 + "caa6b8bc10ca43ac83b22f5ffbd5f5cd9dcf849d", // 606 + "28eb8adddaa3d0e3bda59f492092f045c4c02f64", // 607 + "5b88d3ebd7c60821d7a18e16e1abd23ff058648c", // 608 + "32e6900a62243d65d7b6533ce769cb8043d3e3c4", // 609 + "102ed648cb8538dc03e3f562b0769579d96b38bc", // 610 + "42225f3e16332addb0a0c3cec37f507460c189e6", // 611 + "d8a265d1beb95760002879167f855ddccc9b2205", // 612 + "7e351fb151cd8ce81f86863c9bc38801168d2e20", // 613 + "2b141b6bbfbb71e2a5ead0e01dfe58218b49491f", // 614 + "506c4b684806cf6fd7549d75d2036db51b3edc7d", // 615 + "6a3a9908532a5ae00ad97d82e9c69fe7590cb488", // 616 + "1eb84c44a1beb934acb0b55dd82e60fb440edc44", // 617 + "83b2802bae9bfdf651a82cb149d96532e422e26f", // 618 + "03abbeff18ba8826d895bd3766d49bef15d6b4db", // 619 + "3314eb40f1dc0cffbaaaa7f6d51427a56719e8cf", // 620 + "77ae98d0687c0461010a00875d1c9c1c22b177cb", // 621 + "697ef420c423788d2ef3c0e50c2a3e0408517675", // 622 + "122297dd832c277d227f494acfdfd718ba1013c9", // 623 + "217b9ac188325d1164b857e45cfbc5957db38ef7", // 624 + "fb52cce1c0672450572f70af685b9fb6e06b7b0a", // 625 + "c95a98a033e98833d735b6054d56de2c91c261d5", // 626 + "ddd99d8193a0920a76194b4dcfbd3faf64d1b26c", // 627 + "8a3cd8313f6bff625a7bda6cc61633b0df6403bd", // 628 + "453c0a16c6d614ff005753998a6c62dcfdc7dd99", // 629 + "b5a652a043e4da754eaca22297d3c3cd2dbb418d", // 630 + "6ed9b86f9dae1fbf7d711d701e9f89629c3b7fc0", // 631 + "f2c09b24a5fc43a55ff079cc3e9836a4e5056108", // 632 + "18d8e722aca17afa3208cb70a4c65ae728510af4", // 633 + "c667fb75045bb5a90f9893ab306498fe193049e3", // 634 + "23606fede31429630df9a3276882f4eb30f8e930", // 635 + "8104777ef540cb84d1d27ae2c6a5c0c934412be8", // 636 + "5a35a3f23e17f0e8d3df6aecbd38631004dca940", // 637 + "2320cacda2b94eb1488175d172695dd76fcd92cd", // 638 + "b8a4c9c614a4010ab00f2062381c7eb373384996", // 639 + "d709c92e0319340fbf2f725420cc96a51afffd4f", // 640 + "9058180fc0c91ede2a8f4b1d1ceb7372dc318710", // 641 + "9b7cb9957f101989018290c4b09479b4eb147858", // 642 + "346c9bf55d59edca7a44e7ccc02b26a3eafaaed1", // 643 + "7f4511942e7a57624de6af33d322eb30831e78f6", // 644 + "9b7fa313eb762e5b25b71a3bcd48d7472f0cd666", // 645 + "fb5571e2239f34059ca7b5ff61a84fd50303c840", // 646 + "d5ce30462d6f0a7ceb2c0a83b2023b739d453677", // 647 + "14a7f93b73bb91decbb5d5be4a42788751306a22", // 648 + "6085452b3cde584541749e391d4545b1a9bf8215", // 649 + "3a62527c68ce24f3ceac780613edcf390bf5c234", // 650 + "03a009fe602d3135132889496769fc4bf92ecf23", // 651 + "c173b0b7633211c834781908ec8a3f589c034616", // 652 + "640ddcc5c335845075da281601b0c9ed7a54ee91", // 653 + "0df16db7f4f6c2fa3c4a6383571a76b51dbda70b", // 654 + "a4beba8c3b1f702e084537b21fee074428fa26cd", // 655 + "8b473f13557239a24b36717623ad1119c723fb36", // 656 + "d92d52e4d95c46db6cc523df9a9ee8d4294428d3", // 657 + "12912bebba95598cde0c62678d83d55f499d5593", // 658 + "e53bb23695d762392d02f18825a94a53facc4687", // 659 + "7281b734e4f0f267ec40f4be16e1da58b951ea7e", // 660 + "b2e929bb98f05cb13b7467096202112c91079b29", // 661 + "dc3a2c0189c71eb94231635558a0015532a88394", // 662 + "1919ee0aa4aef0948f87e0cf6fce7483e29b99f0", // 663 + "6b8c802d8c28f3ad2fb59f83b64e9045ae31f6be", // 664 + "5d83854f2e740df09d0c8909debc5a1fea7b1b39", // 665 + "fe988e91446e048cdc87c585d35da8dd38d8a9b0", // 666 + "807ef25ff25aa11bb4a5549891c0852fa1f62ad7", // 667 + "20c77ec4ad52fbea06d56c6324f3b0a5a7d4463f", // 668 + "c7c5f83e4ced6b6dad7a558a9b4a52f2c1a0deaa", // 669 + "7ac933c5c159f4b6792f3d2607703a7f41a5d1fe", // 670 + "a9f36d9a536ae13d95ec47c9db2ecadf2197aa23", // 671 + "1e0f40e1460e76436e3be4a85b4f98bd2e59da62", // 672 + "64e12af44cef2252ea3169e836be05914cfa1c8e", // 673 + "681f51f5ae16372e215dfb362c103ec1bd35ed19", // 674 + "f522a099869e5a5eedc857c0fa495be819c33431", // 675 + "5d480a6d7a5761e9e65b222b72d101c49746b9e2", // 676 + "eb0ae5278a75ce9bb2aa40533c4a58a624660eeb", // 677 + "228def011fbd62be470b07e7adca3a57c5f2bd43", // 678 + "991c4b42f30ac1c3071c28e1d390988f6aa61256", // 679 + "b4b0d685d81406c6665c0c506b0d8e49f4ed0af5", // 680 + "ed8d6b57fd7af57e033f88016f47830f0f2a5eef", // 681 + "d6424ee17f48c53419a1d78c88a015fcb8f94e4e", // 682 + "a8aba6f5209448193002018251798964127687fa", // 683 + "b174a70b61c5a039fcf28e0b1b17b76c7b86c6d2", // 684 + "2b66a98d1d3f85cc6f54c946da3865980333bf4d", // 685 + "e49b53e8f699e6b248404ca166483ab1ba6cd175", // 686 + "40f6785db5977f95862af14ca83083357d117ba4", // 687 + "591379bea92677ee6587b44fc938974c90f2da50", // 688 + "7d971058760ff96b4eb494cceaaa9ad81915c946", // 689 + "3b08b699ccb3e392a2554602e1efd0364e0f4547", // 690 + "916f3273a2c151979f08cb316c74129ed7d894d6", // 691 + "b1b2f33dfdffd71c2a7f8406d8d3f3ea025d2963", // 692 + "03f8a1ac51b0a47e2cff4e47b9f9b41ed0732827", // 693 + "746daa9ced182d1192688a970d1ab28e9b7380fb", // 694 + "0728a9c03d9701c89394958794dda751fa6cdfd6", // 695 + "aee5a61daa4b9fcdd767b5002fac1cd3055c6da5", // 696 + "5674f6d41e902da682a626645ba0243f9a978f06", // 697 + "7488f5086beb40df9147b35f7748d1bb7f7a15f4", // 698 + "f75e07320ca7f7306dad8a3fdda837ffa0e002f4", // 699 + "ff19adb75004aedfa68f27969b3e3c56eab9738e", // 700 + "955a2874ca232c9b16f1e8d37561a6c1f85dfa08", // 701 + "13cb6eccbec9831fcb24e21fd2233e1d07b59492", // 702 + "ada47a1fc825b3fbefca3bcde9816356a3bc5b75", // 703 + "efb8f8bb7313e67a6063264954ca094318ec129e", // 704 + "0559fa0274e1a902fc9535f1e0cc97c81104efba", // 705 + "68d693efa8f4f5ee62eaa0aa3fed9c08effe2094", // 706 + "468d45fa26fa0f988140cb2345d297b140346316", // 707 + "f27e589ac44c1a9a465e58673044b3addfa70222", // 708 + "991ba13aeb841b359bd7f35454e6a4b47bf1c1fe", // 709 + "d71a092433f2ee87d4a9c42ef68fde0abe6f3ee2", // 710 + "ec43a1306ff9a29686d1124ac315e8f6c7090d56", // 711 + "681e92644b5c0d66ed96d1e63b47714331650199", // 712 + "9bbb187c65f83c77f755829ff6b6e0c713880d00", // 713 + "4cf8e489aefb853ab0373a23570b552756b670eb", // 714 + "7eaf6af0a409a3ac0e7687b152f021df6860439a", // 715 + "8859cde87fa66b8a662e9c17b94757abe996a018", // 716 + "2508f71b9859cd778570101435cab08f046ba45b", // 717 + "1a643a329203b4a8f47ec8e8d6d0915ebd313fa1", // 718 + "0bc6fc892b2ad8f2698a654038678c8ba5354c4d", // 719 + "343d747d1dff318ec6fc6d44046350932af10281", // 720 + "0c33b17286b7669ae51a91f9c86554b84c867e1d", // 721 + "3ef2d56bdd257605b39c2ca50758fe2692274f9f", // 722 + "79a2cda228410c5baf891ea39205faf5928cb368", // 723 + "b6484c9a117cb57aaa721cf8e0e9121e656dde63", // 724 + "54f8faf105db4d5d2220b5a1bf31f9f0838e4bad", // 725 + "3965171e92d7d3ef329c7c1b548d918c67bdb432", // 726 + "966372e0c8a71f50a2baa19107059f12a355785b", // 727 + "44230fafa08ee1ee9bb9b151137c15441aacaf02", // 728 + "c3b9b3b5e4f9890dd26b8b207eb30e63a05257a5", // 729 + "edfcd91bdb43a95341431ec6904150d2d646a16d", // 730 + "f54000f2f7fe2a6bcc6398cd1a5b202672fc28f0", // 731 + "4e743c7ded88bfa931c3f3fe1e0d3e43ef5a40db", // 732 + "91d6f28d62efc88cb9e854bbb749458ce231099e", // 733 + "11915b38a6ba031839f16656eda7369a6009b941", // 734 + "2a67045e44a6eab9a95721e1707aa3d94609597a", // 735 + "ec432db555cc519a4fdd7609033f01963a3bb23e", // 736 + "5caa2fc3fb2142c593c3696eef07d2b2f1ba6e04", // 737 + "514fffd47888242f2df10da24fef253feccd9d0b", // 738 + "f024734d31fa4534c326a1a0e3cd3f8227c3fb48", // 739 + "589c34ce38b1bd454edcf98580dede486f48ab1a", // 740 + "aecc2e6a65e61c5989dc0ac10fb5504a96a244ac", // 741 + "ee425c1b6a1e7e9e94cb8fea87e58757a3931a2d", // 742 + "3af5dbffa8a678c003dafbbad940e7ab52b2c806", // 743 + "2da76e3dae51b2bf8589ea222caf0fde733530b0", // 744 + "297f48392edf648c894913c266a25eae1eb53d54", // 745 + "21add1777889d0fcb778c04f12edb784f42e3efc", // 746 + "24ad50e5bdae6c2f7f57107a5caa1405619fd1a0", // 747 + "d9bf96e7a1a8469f1db806417bce276293d37be7", // 748 + "bb2eda455fd8c81a712726aba73497eb9badd57f", // 749 + "8001038e65aa2265640807ee47c710fc82a3d1da", // 750 + "4b59f510e4af3cd6264d1b1284aaa6d43019a612", // 751 + "26244758995c9e87f833b4aec4881b1764c14dce", // 752 + "9466620e0f3ec436017c576bd3b56e10cbb78e14", // 753 + "328b7baa9551b43b6d616d0e4ee759cde5cc48d5", // 754 + "46d55e5ec9a2d39fc9b72ff331b50d9b63cf3378", // 755 + "313396958e939dc7279f283bfdc5653d5b22e681", // 756 + "fd9dce4a9d98c13df07487b45f204a45acf67073", // 757 + "13da5c44cacc7574e18415b426d7c27624233340", // 758 + "ee1359de649c7af2f7b5c74abe08a990553d96af", // 759 + "32d2bfde94ad7b1cfb80069190cea63a6255268e", // 760 + "c0576944b4b6f3b94409e838a8e21e6674a75d40", // 761 + "945f1809b6891c41b11ca5996205c58f2469978f", // 762 + "fb0b7d6bbcfc98713afb8b363d78f8703f2653fd", // 763 + "ac4c5cffe1ac16e0fc1feb59493cf430b4925170", // 764 + "52f932fe4e60a43f34dccd8c1bbe45926a71fb61", // 765 + "349a22b003014994757047eb15bbde3fd384876f", // 766 + "11320eab99705a039505233499f024300642be8f", // 767 + "5359b011f34667149944ab47c2e00c96a5aa4666", // 768 + "5340f0a45ef06099e8cbce8b9a432c9dc8b3341e", // 769 + "79bb80439421eb8ce85d4382698a67059afd8501", // 770 + "4d695b7765f090fe74690c9faeb721e377cdf37a", // 771 + "8ee83a1ff0f9e2bd7bc8217decfbb2c11353abaf", // 772 + "17b0a37445f979ee330b490b0d320d92191f6b6a", // 773 + "9ffaa708d322cdc6366937a5f8bdcb0349aa1e68", // 774 + "ea315ae5b92ad28c5aad689eee5e171fb46edee9", // 775 + "a0b59d7a870256b365164e7bc9c1b5e0a318d6f1", // 776 + "009a5738735db736436c0e5f41f0eb6c3ee0d50e", // 777 + "1f2c958a6a26733684e0184280c596a73cb07152", // 778 + "e65b0edd1111860fe947ccadf8f4e987d22f2cc2", // 779 + "1a0ebda7105308da0bf7c84f633429fd6a518677", // 780 + "9455ad9b2515ff1daff2bf45d469a04bdfabfd2c", // 781 + "14e00257d6e6d8da3e6195c8f77b0ea292e9cfd4", // 782 + "13d54d5f4c430e7457490796bf24208314483290", // 783 + "4193f9f7f4e3cb528aee39d57df120cd6f29ea18", // 784 + "35152d85f9f3a5cb900edb0b2c95b6d70e37525b", // 785 + "5f4afc252e209131426ceed280ab2d57a897a72b", // 786 + "4110980a02d82318dcf9747dd8cee0be806a38e4", // 787 + "cc6407d078b078040e9336e27d951f9677e850c6", // 788 + "b3159cc58f14e07d64f7fb5eae9279c37be1e315", // 789 + "83a466cc42c1075c38f6a7b1c2b6a942b8f57b4d", // 790 + "2823e8397aca7d969098c6a4179844d695107e38", // 791 + "2a2487a95a91b1c215cc1f85182a8920da4793d1", // 792 + "69bd52d2b4be302aef1c30479d5d089f839eca68", // 793 + "ee09d99484ccd151e8ebb74efcd6fcb0a39bbf83", // 794 + "3c400aa8dc295ecb765196e60a23329d7d4f8a07", // 795 + "4b23d8dd1947e99663f224b273dc6f67dc123142", // 796 + "e736bcc8ee0786bb4d7dab62c804f04a28b66413", // 797 + "d596b2c1e666dc0e01a3f6a87a2468baa8aecd17", // 798 + "710e69990117f41bd091c7411337899cfdc2e793", // 799 + "d0dc4518fa620ad83c1c8131761e3dab796b2737", // 800 + "9ab3ff45aa82f35b37dab468f7b46c956e6dbeb8", // 801 + "f5bfe5a4c9e937252504240cd8127236b7a365c2", // 802 + "b6cf66e9b72f97551187c029cb6be398fbe090da", // 803 + "a6027e7f783a77a73480761a14824d15fe7eb31d", // 804 + "c80c35861306781a901c419c6003044381f464a1", // 805 + "15f20dd44e32cad6b39086baebf038a6cacb7a0a", // 806 + "0b4857a2025afd1317afcc04dbaf8b9b6f2c56c6", // 807 + "e3c8de29f6b806c6cbd67c22b54fa16f07bcf3ee", // 808 + "9780d5e3281b525234c5356a8b03200b0dd7699f", // 809 + "408d7bed23bbceda3c8038629c413920c55bebec", // 810 + "81d0691e95ec4d7521156de668fb68266bfb5a4d", // 811 + "4b85ae2027ebc87f0a537b018f7dad4f18f0dd9d", // 812 + "167d3122d95d343223b28e13783142e6b402e03c", // 813 + "5a508d7570ca5718a02c34cc85df609cadff4d17", // 814 + "20e968c8719b57fdd4aac614f148f88c8ecbc200", // 815 + "cea1e6a636bb194d8f3f91e03b78a09e0b79f474", // 816 + "13467475a137f101cb3d4160a742e93f10c1855d", // 817 + "c6b09d81d16c3096490b1b9c0c33bb20810ae133", // 818 + "ff29a7e28c4b9da0e1c041a9513d2b31072d7a7b", // 819 + "9d276a42095ed385a5656328c8fa96c12cb71013", // 820 + "0df071c631d8619ff63d8639a9ec272967e346b5", // 821 + "f1e152095a1ffcffb4b51902688de671e88363e3", // 822 + "2640758ad423639fdf4654374c50730dd0bcb8f1", // 823 + "761f1c1b13c660fd7a51a31aac5807731a6cdb57", // 824 + "0892a9c043856680b00977f75de0b03cf02d14d3", // 825 + "5b138884bfb48bb5cfe0754f1c9243c059fbaffa", // 826 + "a147b77f4aae038e4c941f6136a3efd4dd0dbd7c", // 827 + "fcac325d5460d4df06afd2db808c686bd7292713", // 828 + "e900d9facb99ddac4f90dce65c4fe2cc6f22f6bd", // 829 + "b8350727a7d129304705ad910e56876b27048fd4", // 830 + "28f55fbb8d239809e9556fb40cbc9690675f9fe7", // 831 + "632c0b07b935eae1ef22cb09f7bf7330ed1a53cd", // 832 + "401e2ea2fd91aea4c82a08770927c73d5f5a96bf", // 833 + "e02f3f4606865aa669a1801fecdc745eef5a1cd0", // 834 + "16ca126085608e33bee250ba469fa5fbb0354cc0", // 835 + "7e6ec6d96316a2220e1f6f2b837a697e94498ca7", // 836 + "a67e603962343385f858a29d42a0eb501b2a80ed", // 837 + "0518c0308616488bb9aa05c2eb6945edaa452eb2", // 838 + "5218b85b9d1682c396bc09647089dbf1d43f60e0", // 839 + "ac6fca5a7f66ec7842af4da6dc9d91127ce3e01d", // 840 + "3831ff39016bc8313fe538b6ab5bb05d0a4a4622", // 841 + "9072313ee5e223a06f6ce05c661aafc38d491c2f", // 842 + "321e2f6ae837f152fe2f428bd203e4d45cfdcc36", // 843 + "dab54436a3fd2502592004f8f0961bf72c320e5e", // 844 + "a4bfa662028225dee984bfcc765c20b926106eb0", // 845 + "538383d8bc01a4c36083a156bfe9e6503568f161", // 846 + "6d408fc1b58a68d572801bd76259d97af4a6133e", // 847 + "b40fecfb7f54d9c5df3173481c8d27fdae4574bf", // 848 + "5c4b264965188f2408549b66b090e381c14e630e", // 849 + "64074c0b3b487141f77fb18d85b98c0f7ee64b75", // 850 + "377a9c6cbd602a8f8366826710433d12566e6594", // 851 + "80119bc555a993807123b3f2f9675305e257d2d9", // 852 + "3e9457ba4ade5082fd22d8245d77758e181d09fe", // 853 + "b4f1716b2a642b029b1af56051dbaa36ecb5430c", // 854 + "d16e60f49fb941f3cbde78426611cd757b3b3b8e", // 855 + "5c74aff6adfb6d77f3b94307b59d8cbfdaf61891", // 856 + "e734d57f3b2c63a601412b345b461392dff2e716", // 857 + "e0c8ad55eb46d2ec5f9d2d25f3f90a39169ad796", // 858 + "e9edec40950de253c95ff911d62123041034e135", // 859 + "28df231d3019b51e232f7821d00ae54a1ab69a46", // 860 + "ce3d0a50da05e8805c7d075df15eee20661487c1", // 861 + "f3f41e2c8332e4bc03d065981947119449d42d31", // 862 + "b56fc64b2b3d03cba57c69709d3cb44d85594b5f", // 863 + "37eddebeb9114a32e52937a9871ef14e296499f2", // 864 + "e85736158a8736ca8305cfea87b1861eb8c58eab", // 865 + "517ecea491f8d5d21cf8e9e362442289b2511359", // 866 + "484291312a3dcaf560e962648e91d4f48a662596", // 867 + "ed30d7f01ea2562939ec240e45f010d1d6eb6e9d", // 868 + "ccc45856c46ba3e423d738e080a6fb111fa60f74", // 869 + "e4b48204a8856c926ebf8499e93ac1e1893af4b1", // 870 + "50c31e24b2bc94da05da0fbb853a3b692cc63b6a", // 871 + "f68af3ae18944d7e2fa7f20c95d7771d41dd303b", // 872 + "48fe99e9adbe45023cb0d33209567551f1cf9ef7", // 873 + "a7de096360cbfb72f7a55e7067d3a718248f8aae", // 874 + "65a139f010df814e46ea2bf4eee6ddde5e60371a", // 875 + "6f0a026c0fe0aadc71725863b698767c30a21619", // 876 + "58d849c95b2366e13751bbe53acc7a18e1613393", // 877 + "d27e832c558c9a2d47b9a3e5e6c0d36245700c8c", // 878 + "59e3257d06911964eb6329cdcc170093c54cd77f", // 879 + "f8dd51f8d33426a09aefe43bfcc56f0c505a9c18", // 880 + "b9fd879800a9548096adabbb5794b11d25605946", // 881 + "07a54e75a30bfec56a53e05a979adacc6ba90839", // 882 + "cb463874d965028e2bd01d32261a517112492ede", // 883 + "6a15d71b87c69441737770eafc0c4213e760f1ba", // 884 + "8912a6271f745c2b9aa3bb469b81a6dd85537ef0", // 885 + "2aa353fca8c2c34f8989d523bf0eaed92bb30e98", // 886 + "6bbdb81fcc6d95705eaf31c03aca9c869ab5c94b", // 887 + "1962c36d9ccf5feb7d815e8fe5f20fe5496db347", // 888 + "470e85ac0538a58d55e593efe96d0043bcca130a", // 889 + "7a12cf26ee8a421e8d4f531df50afba0f2d94aa1", // 890 + "d0f759ef5dd6109e1589e18ed525b309641d267d", // 891 + "f8e2ac6a9f2ebe3d48a1e17df2c0dd2b4c195c81", // 892 + "bd020b704c60d9825fce8b464e929f36968e5148", // 893 + "ad9cae411e1a874c9acffe4aa66fdca7d0a67680", // 894 + "a73f3a0b626cca9474880970f94eb4459a04df7b", // 895 + "33d81c0c09c525877905dfe9daad8976b6d66c98", // 896 + "0b67826be73f7fc5b5882c4550579b965a9f0087", // 897 + "45e93618d780d04fbe051ae5baa5f410053e6bad", // 898 + "d6b9393f2c14045665a75badae58024b127d54c7", // 899 + "197be4bd2c4754149c332964cbbd02550f80e143", // 900 + "69af351d4e997fae774a3e7e1ad5adba9878c1c5", // 901 + "4dbf94e0d0c3225c1ca3d02f71046f0fe434d277", // 902 + "84d500b5d96528bcf05e2202a866c47f419c4dcf", // 903 + "865bb9eb5a6c82832a27c47d3e00ed1837511de0", // 904 + "ed5022fd57322735b6772c45f10cce56a5836c52", // 905 + "ebd88a7223dd32089800924097036a9dc53ec8e3", // 906 + "8f376b099606742310832809336d177615da431d", // 907 + "4e78742cea813e820fe4706655ddcc08c057e0cb", // 908 + "7a5093616dc2cf919048ee22eec44e48fd4ca290", // 909 + "b76d6bb6c46ab9b7ed259f9d8a66b41d7728a9de", // 910 + "24bfac0667e6ee01cf9758e0af3761c7c3b59d0e", // 911 + "c14959198519bdd42f8ac5a760751385a1c43189", // 912 + "d783679253bbe2f2660ad9ae717524729f30eaa4", // 913 + "23960cc1dfd424bb217ea9d328af49d3d41138fa", // 914 + "4e341a022fde4660e2cb766921d250f603074882", // 915 + "67b54e63c7ea59e64fbac25130ab09d8fa5de2e7", // 916 + "667201e59769e03923d15638eed98fe5d7ceda5b", // 917 + "ec3fdf5000ae11d14a3fd82f438c86acc203be30", // 918 + "0a7846e532bc940820b4ec000a7de3edc5aaa690", // 919 + "b357d95ed359256c6649ecbbd25e97bb0a671a6b", // 920 + "a6d3d6d9016af14180371e8b5204738f1d7798df", // 921 + "54b1c8397633ff92491d26b2bb8c243ffcec20a2", // 922 + "afd8b9038d976b69fbeef7a1afcf1fb25dba7c9e", // 923 + "0d2b21749f51e58d41a025e9a3c172236f998992", // 924 + "ee0a618c27adbb3f231b77d5c8d69d2c1995e94b", // 925 + "228ad29d074f705fd546108395d0f0649c7f9bc8", // 926 + "bf1fe4d6c242a97bf99609e8bc6b3dc553dae637", // 927 + "c9039f3faa990418f881a14c2c8ea94e933dbafa", // 928 + "37bfe30560c34ba640ce4e8ac042731338e6a5d3", // 929 + "152890a2b8d69b9d8179d1966027a065b576ec89", // 930 + "4ced8161ec43094d55d88b2e891c245fcabb78e7", // 931 + "9a15347b5841b44edcc428ac478c2d72e20d8867", // 932 + "03fa773ee04c909925dcb58595670e57ee878109", // 933 + "027eaeb4b9a421ea87311bef303f50ef607a6c78", // 934 + "f016a0d28be4548b5a4bc1aa7675ed701ae73ad2", // 935 + "06d20e45e70ab5eec82448c080fd94fbefad56fa", // 936 + "931457a168ce67439c495c45acbd904e614801be", // 937 + "9ee49bf0ad27d31ed792ae91ce0b3e6985b5638e", // 938 + "b886c9efac2eb220be74a75e8d9946276e2ca868", // 939 + "93260d53e8fcd15fa1196d6dab8602d7504a09c0", // 940 + "a9d5e4320500901a363ce58a13bf03c719161e4b", // 941 + "6a6d31684ec814d9aea94c865e08ffafb976c73b", // 942 + "4cbb9b7877f713146a65bb2a9a7819809814402e", // 943 + "66cac2578f72cd835989b6c674220d0607a52896", // 944 + "5057c86d5ebeac723e635748337f107dae7d6012", // 945 + "fed98e3b60fd03827206cf0c638897d48ead6fe4", // 946 + "bf58a3bc4d6e9f462ea7485fb0b54a6be53c3c06", // 947 + "a24b528e224463ad810eb4b2077d59db33c6c4bd", // 948 + "cfb15e566216d8ffcc137980d16777928162d451", // 949 + "444b07a125cb439867e047f6eb7085c9fcaf0044", // 950 + "2419e52020effe9a081de3b352f9832dbc5bded5", // 951 + "fa55278e4c9982465f55270afd309ea96af2d6d4", // 952 + "a3b64b68576fe38d84598c33b103300ce3c291d7", // 953 + "78d2e1daeabe3bf89c476405b07ddb154549451e", // 954 + "f71269632322fbabec68061d7bfb09da1122abcf", // 955 + "2ead90afa04a029dd5e4b2b5b9a695784c96b263", // 956 + "6309f4efd9e9ca5158aed15321e139471c6a681f", // 957 + "c00a4f4a1ea7ca6a6054fde0d32923d2b49d1842", // 958 + "e46b0a6c63631c8e3df3a78a9ce5a8073c7e27a4", // 959 + "53fba995ced4793d10a2938bf087071fe3745ba2", // 960 + "b1bf705f4eb9a29b53651bb305f5a0c7dd84de31", // 961 + "6b5239f084eb7e9211f007fea86bd6eb1c279578", // 962 + "2fe9ccba1627e8ec897355483e492ddee5524621", // 963 + "91639756b97c21c88a94b5a864c878b5bc1e9a83", // 964 + "2ecd3bd6e7fa6e7dcf004a7686cb48264f24fab0", // 965 + "4b7e5d68f16505195a79766a65a3cf46c403de49", // 966 + "fd5113ca5b7e82087a1ffcadc9f558c756105093", // 967 + "e247ce61e5ba08de90c416ce864d4e52a293b511", // 968 + "ec41634329089577e331b2191412a188ea7ac96b", // 969 + "4894430a2c183a6e4215a9b122faaef936bd614e", // 970 + "1b86d5790c8d4a6346f5244cb07bb82e29464f8f", // 971 + "3e7d58446cbc442005953f5ddf45b2d5562054a7", // 972 + "b9c542f8f5a5a3148a100a088868151f63625d7a", // 973 + "fcf618688e161756dd081b021118b09dd8fb9f22", // 974 + "db301604cdb314a3794c2c5ca96c7c43509794d3", // 975 + "12d3af586307d956f7a0049714c80adcaa62adaf", // 976 + "b5e632ba95313eca4218ee2aae46aff4627046b1", // 977 + "ad66fc745b35849c5f8bb3bd6a3aee7537815fa3", // 978 + "2910bc7c1cbfb1ce37c48a28ca01417b561fd6b0", // 979 + "08d4c58466d8be4ba335ad1da129d316b9a0abfd", // 980 + "8a3e5492314b2c9afd84641434afc5c97e1ea1ce", // 981 + "f114b6647b225c8db01b9ead09aab3802a10d740", // 982 + "63fe425a28b5eda36434b0d3465941d229d71303", // 983 + "e391646d26797c485c05a2cca7a60aea62c0cc54", // 984 + "60d28c89f1bc24cfcb06938fd07aa77c70e5e5a0", // 985 + "42dc9f3d387e568db4db1535635b6cb3a465047a", // 986 + "49b4a197cd7501c874a74f926f71ee28eb14f7c2", // 987 + "85e1203c0f13f1ae5554bf88f436285a06e7c038", // 988 + "e2d9a086a16fee9729c0a291bd24cd51ef4e10a5", // 989 + "f5b86640b9e70508661abb4159ed881c5cf19ddf", // 990 + "240a70e18156ab882bb754e2ac4d5d64968121aa", // 991 + "b7a50728403673e84bacb8a23ce064400039f91d", // 992 + "51af3e17b53bf4bcc09e5c87bbf93348ab3ca723", // 993 + "27424c61eb68c46698ab51efe9335124e1793577", // 994 + "fc44f5bbbaca7f9da1df4957a2f912dee0a6a038", // 995 + "89d1b15b5117e5aef784c794e20448ab96ad6b11", // 996 + "d073fa39cfccafb587dd0af73233ac73f6fa9076", // 997 + "eace126f18b0a7f0208710471dde441640e32c85", // 998 + "d8e4b5c1594d2e9c1c4154eb8ea5715cfe9dd055", // 999 + "291e9a6c66994949b57ba5e650361e98fc36b1ba", // 1000 + "55a9b1959d8b3e16697d5cdea31028addacf1c4e", // 1001 + "02d471c68efe4b6723ecde0cd9012d7bbca87f9f", // 1002 + "94a5ca952cc53a700f5573e985ad77eaf692ba27", // 1003 + "9ce167424252fbf12d40e5c438266c6429e88140", // 1004 + "ce95bee5de0c8cd275c64c911624d0f31e60bbda", // 1005 + "8a3637861a7ee07c5eeae682e587af9595c2c220", // 1006 + "0e9b4c6a55e28b20f20a3a9f400f1c0bed8fa042", // 1007 + "d9b792cbf38de3ce28af576bf59b310727c7c8ab", // 1008 + "4131c53af8e59a69b37dd785b8fb66087a097aef", // 1009 + "28d1b515346692bdeca61cf6e8fa7b88af92a3cb", // 1010 + "675b19413f2d970c62b313782a4f33ed5be0bdf3", // 1011 + "17c22202de45d4469e525b865fb945b7119f1a0c", // 1012 + "08a3e6be175c165d196bb5e85a47bd887d5741a1", // 1013 + "02c2aa9c7265650453ae9fb84eb5a8c256f1a476", // 1014 + "04b7c97fa762fc9e34c41c64582c4fdf6c61f596", // 1015 + "5b097830972ac215879ef266044abb19d0a01592", // 1016 + "82a6a86da0dacbe10b6fc1dcafaa3ff21811e038", // 1017 + "5000d96b7c027eba7f2884dfa6e909badf202046", // 1018 + "8d20d60b32a800c1ef23054b829ef0c2015fcb37", // 1019 + "c9ddd895ba082253067cdd51a8feb282be72daee", // 1020 + "94d26e84786cba2a0f12030ed35bf84836a320c0", // 1021 + "7d0b6821f1e7bc145be92714426b4ce739c7067a", // 1022 + "4aa47ffaf3b2dd2f0241fe22200fca88980f88ab", // 1023 + "8eca554631df9ead14510e1a70ae48c70f9b9384", // 1024 + "1009de2ef6183bd6d8c08f1ed8df4847e71f7acb", // 1025 + "131176ba838c3081756bf804bd0296859b375113", // 1026 + "07483bc8c0b3dcf19cf96be391403406d2705101", // 1027 + "96ab3f345a8d6b4f0bf4cef9b8ff573119d4ba2c", // 1028 + "6d421d883be5d1cab82f384a706b82d41f0903e2", // 1029 + "8cf49940e7e703198b2192726f98a730528534d4", // 1030 + "3428120b666c9a1d84d483f594731f7acef2bdb1", // 1031 + "3cf3374f6200e7a28454659db31d437eb1369d81", // 1032 + "bbeb5b75694fa45e7859418408be0dd98ec03334", // 1033 + "9314fe4ef2f9bc3be966143da01bca94b96d9a52", // 1034 + "07488dbbdc991103586a370f684f21b6f557c39f", // 1035 + "b769352652d1ffaba76073cdd329b062a3a5412f", // 1036 + "e6d7d3dfa20065fa2521bf2b93ae97b764eb2c53", // 1037 + "cf04f66a2b532ab5d59a8b82dd0796f1e3c8d16b", // 1038 + "7a629f0614e209a3bb9bb36a29d5a65645133577", // 1039 + "f2ec54452c45ade29eaa0e0f62402001b0335e3f", // 1040 + "aea84ff0ff7761a40577a69404f0422c6d4eadbf", // 1041 + "955a63fa02436e88b07d833f44c8c35f08cf3325", // 1042 + "3d3f58da3d196d3ecac7b311d064c3edd72a48bf", // 1043 + "c4a79e9dbd450141d417ba3f7d8f90ce4e7ada93", // 1044 + "3bbd74130e8222604ac0517a2028516600f3be82", // 1045 + "145f2858d363387563514b2c0eb8c2ab3fb703f7", // 1046 + "c61c708d45ece5cdbe7cf4151802e87f7522aea4", // 1047 + "5a5b4db328ce47e9cc9b4b34e415c6d49ffb249b", // 1048 + "54947c7ea0cd801e636ba0a6c70141b1425244a1", // 1049 + "530ed9122a14f1f14b4792412d04cfcd721c8df5", // 1050 + "12c590855f1b90b7e7204f9baba2c0a8dcc18554", // 1051 + "8a09ac40583499137138dd240eef8e9c61c1b7ed", // 1052 + "47a4da260174001e3ebd2164d1f69a0e7826518e", // 1053 + "f355dd00e3439e5c60f29371305158fd95a2d8f8", // 1054 + "8513cbc189c53eee2d4ad5c0da146131bffdc594", // 1055 + "ec0c467f5e9441d9ece11df8d265a6700d3d7d31", // 1056 + "348057119c14c08b818dbd5ecb61924c8f06f9f0", // 1057 + "f558a8a9c8e72e09c1b497d3ab6385f9f4dabbfe", // 1058 + "8f4bd60a13f8843f705055cb44ca176552715b8d", // 1059 + "b13b25b5dd089f9262a4b563e4e3fc323be98f9a", // 1060 + "2e63e1886a1820e83a83415868d867984e317222", // 1061 + "15517e83607df21190947c809fc72fca57dc3f71", // 1062 + "88669c12b68f7cbb80bfb51b750c74af18375573", // 1063 + "db827ee0dedfedf53387cd853b355d7929b529b7", // 1064 + "c14de4941e1ec749f029f6ef1a6676c330ecbb3f", // 1065 + "cdfcf1f596d5c174b27729dbbf0ef92e8aa0baaa", // 1066 + "2ba29eefba345a9c7338454d3bc7541a096ef0b7", // 1067 + "7df9621f17ad18c58a5af7991d1262200fafa90f", // 1068 + "3ed002d635522384ad6e878f3a6659758f51c912", // 1069 + "fbb250c1005acea0b64662c97a3010a471200968", // 1070 + "ac3b56e230831121a74357ade0c0d09ac92a2faa", // 1071 + "8ca05b961b823c3dbdb78305f932aebde84918cf", // 1072 + "b53be4607d27a18674211b4aefcb56c44dd6d69d", // 1073 + "5c55055cb2f4df57402bfae32b7cc71b3b005705", // 1074 + "186c2ccc8d7c379ef1788a0c3bbc0e7cf7d951f3", // 1075 + "a32b00fe4d5f0f457184f125014790582c5b0083", // 1076 + "c5ca624ab40baf4e72cf29c56d68eb5348d6d638", // 1077 + "83377c3ce9267cc618a5a600802d4cc31d0278ac", // 1078 + "c40d9ffe5ee2de240894471c80a79e00591e3a8c", // 1079 + "74ac5b508de0d32441a2b37eaa2375b403b6b9fc", // 1080 + "4516dfb400a3488e8b33325314b7c4bc7206827d", // 1081 + "29782f36202aaf25200b3fb443745e58c7128df0", // 1082 + "a1d0a24a9db255de016f6c70ade5e8452d1f21c6", // 1083 + "dbda88cad5ed3e34a35aabe23b361e08625b5f1a", // 1084 + "6c82172788f0657df4657c198a7fd1f806fceb8d", // 1085 + "37c25426eadb7fd39eca37eab3abfb5ccbabef86", // 1086 + "8d17dd807258d7475803e09bfa7cd08402780e32", // 1087 + "983649d747d71b2353f7f63a3f80bbf73558b503", // 1088 + "2f09b1b76e49b737079b3723f066c9183ed436e8", // 1089 + "ac92eaea0e51f7e6b8c25921b5dd4a7fcf49e512", // 1090 + "78a7db3c752362e7bbce140a59bb53062deba57a", // 1091 + "eb3a3674a81b47f963f1830544e4b3984b71aec5", // 1092 + "f59fdabec3a5cbf0eb83ca74e8ab81110a72df1d", // 1093 + "c12d0b5d0d1053e762fecdb0146d0c8ae8f9e719", // 1094 + "ad85d2427db4309bbb5744c0d07afc826ca7da4b", // 1095 + "80f0d857ab15060c96d81513e28fb7a70f996974", // 1096 + "40df1066b61ae6f0b15a0a28cb10e26a9f610d7f", // 1097 + "e3b63a798b98b71157b75ae5e26051abcecbaad8", // 1098 + "e93a10d2b6a9854e8fd0238404663fa793941076", // 1099 + "a40ecd039370b04fb85cee5df708ad164f8f72a9", // 1100 + "2500a3feba4ed38a14969c1e4f413925c696f75b", // 1101 + "7cedbc408288929c633f3d238bbead7be4cd0e0a", // 1102 + "b535714e9fc98dec2fb092808c4a3f006fc9cdb8", // 1103 + "7ab980319c4cf74508a6ff299e0fd18f17d2ae2d", // 1104 + "44b23f8c37caae40106a949f9cd4df7825fd03ec", // 1105 + "58b5acc7cca14961e6af03699ba9075a63e2f737", // 1106 + "0ed35fb38a5b7ed1f0473d82f9b32260738f7dcc", // 1107 + "0e19d0af849c85616c5238e4a930de4a010fdf80", // 1108 + "6d34af33e87e3a639bfdb11f74e69d3c6d200975", // 1109 + "4ec804fda53778bbca5521051383e7abf61055dd", // 1110 + "37cfeed820f05c015da9768a40f2b772795596a4", // 1111 + "97da4823f4c27f8ddad2c1ce5abe93e329028a1e", // 1112 + "bc12e331c8bce6795eed96debdfb8a96250d9b7e", // 1113 + "b86910ce5176c0b75858093d1eb48b386378f744", // 1114 + "06bc8e419af6ca889c042bb471304ca7f485656a", // 1115 + "0d71be2901508860dc4ba5e56f18bd49be0c8395", // 1116 + "883daa9e634a65ad55886fce7a6365a8d4bf4bb1", // 1117 + "5056b284efa6b5be224e95858dc6751e8e1491c7", // 1118 + "732203c93b0e82e11c84987730e3650dba87d859", // 1119 + "f2fa8b14c60f4b7313ca4edc7ac9355bd9dcb760", // 1120 + "1e0eb1fb951514bca6bc67707e3609f66422e704", // 1121 + "c7d30ea74c74ec70882dffd822a966ec23ed8aeb", // 1122 + "d3e635c00480a868d320df0e8dc1d8626a644b21", // 1123 + "5d8efed6bb289bff5a16c79cf1230174515aaf1f", // 1124 + "91bde1ffb7e3b6757d247a4b8712da8616078e27", // 1125 + "449a3c1f237744f315767e0b76f77ab0fe299540", // 1126 + "57b1b43ea66c84963550a737b4707585fe15ae02", // 1127 + "ddc7d68605499c333e04b9b3852389505837759a", // 1128 + "453589db5006be3d774461616214bd1a245fc082", // 1129 + "554836aa5d4007ee3bea33c00fe915f29ddf79f9", // 1130 + "b768ea080b3866b24226134d6abe46dd4b21bcc7", // 1131 + "7b8b716bbf78cf6e14239de16687322663c04615", // 1132 + "eb560917b3fc41e82d838426441bf34ddf15597c", // 1133 + "232452a55bee795a17cceeb45a2a156d1571c4d1", // 1134 + "29b577ba4f7ef1c8e90b16083d1e8eb0f9f12f86", // 1135 + "dd4cc47c25f8998e73b932df1fc560ff4da0cbd4", // 1136 + "17f57d648af79ce57b2682fed828afa277e73a2e", // 1137 + "35825fdba5f78e3a47c4cf3cca56158565c4d23a", // 1138 + "b3dbc01c33de589db7ea176c397188a7d14883bc", // 1139 + "06d58bd816a6a9503774489824c6897dc588e448", // 1140 + "655b48eb3fac18d8dbdc9db88c78c40c007d00b1", // 1141 + "3ef7948ee36455d7614aa758569ef3ee00a3ddb6", // 1142 + "f6c734115f2ca00325606730217b89dcb0eaf480", // 1143 + "d91f17afb3632e51d3feecc65fc6faf58c904dcc", // 1144 + "1e25d705a0e9ff8035afcc0eaa1bef7d2e7e0375", // 1145 + "8b343d3c76d121389c4caea90e91a05008abb2d6", // 1146 + "708ee6fd3e5dca93beb1f1c614a776e75e2ade4d", // 1147 + "4b016ceb7628503f70120f4eaede19078b4ff04f", // 1148 + "3b3ce966a63d6f3173ea39efa00055969f1c4e21", // 1149 + "b8d6e6db6198de12569811c06bd0881e51fc539c", // 1150 + "c6c23fb3ae50a86abc7de89d03241f9327f127b1", // 1151 + "105b8fd0ded97bac13162f6371a5d812ce2d0725", // 1152 + "727adbd7ce255966c77605545a4156f816fc4b35", // 1153 + "cbcdbe954901064512a8b554bd5792bf562c05b6", // 1154 + "45f0c67bc60f6497ffab88ffbce0deebd4141a11", // 1155 + "34b44ae275fb22d3585ba8ff2ed91bf68d8bc752", // 1156 + "207f39691ec118b470e95665354c2d0111291703", // 1157 + "de183d3e80c9cecfe93f7f93bdaf0107dc21293c", // 1158 + "485c39162c90e182ff34e9b177e0d42f9227d356", // 1159 + "6f39ea60602a2986270143545a5f2866a81f45a8", // 1160 + "c3dad79ca7a20d5b1f5c190601f7fa5043f1746f", // 1161 + "f02ce82598b7924168d6ea4b2f79b5cde81f4636", // 1162 + "c2c0da8e5de308ca159367dcff65956e7ba56d1e", // 1163 + "f1da14a5b719d41aea0af40b41023d571c56e9d4", // 1164 + "d07ca7216d6822c2d468b138ad904b5ddcb4710b", // 1165 + "5a1cb594812a581c7ae221d09dadab70f405ca9e", // 1166 + "81382ebe93ddf02e2fdd741514f854d559e70e03", // 1167 + "c81da573b1e55f271992a86a8fed56dbe86794da", // 1168 + "5e262afc78d837281b27da43d2f27b5dab616000", // 1169 + "8e7feee2d510b785cdd929a2f9f72260bfbdf039", // 1170 + "0e84554006eb22501e5f9f469a27f4fe17d2bb95", // 1171 + "44c37ae560293e0104a61c525a7388aa5549ef4e", // 1172 + "c3e095248e47d236dfb7786bb6b2cca413ae09da", // 1173 + "e9c1ba682ff2d04a42e8638eb4382bf48ea4acfc", // 1174 + "05d57bf24308c137b514e12fcfe52dbe8f19b71a", // 1175 + "ff1cc83e2613164eb5b2fe2c9d98c1afec7e1fa7", // 1176 + "1cad817d3102d2831c47d2d4d3f87fa527ebb062", // 1177 + "8d09d3dde2be531e9e52112807396d936cb31b86", // 1178 + "4a444f56611c1f038e6450568667a926943c6f16", // 1179 + "ace84c8e391d84a2d266dacf05aaa29dfda39611", // 1180 + "7703c7bf32d7cc5d730e5217b21e0d19fc63cf4e", // 1181 + "aae11279cd829487f8c184eb5b3dddb305d60179", // 1182 + "b4cdd9f12f2133566dd428cb09dd1b166a78eac6", // 1183 + "e04e34d54cd55c910d413baa381582069bb05de9", // 1184 + "cbabfe70de8366bcfc36e6fdd3a70f97c78228d9", // 1185 + "2e734bb85bb9f71cb3074d69a56c8a598f324444", // 1186 + "4afc6ea7b32b1ce867942b49557929a62dc17985", // 1187 + "6f1ffdb23d74238992ef9d2afa7d0c0322c2411b", // 1188 + "47c779223c6e18c93af4c2f3859ba5653ac03a4b", // 1189 + "e490046dcd461a8b283edeea8c4dad1a8b44fbd3", // 1190 + "d24386a56ca28aea207ac9cb030025f1503a9c0e", // 1191 + "124a094463e14b4d50fa9f54ab8490a8b9632c37", // 1192 + "fcd43fccb693664b3afdf1261606753db8462816", // 1193 + "45009c02372286ed204313428d0d1083f24ae87a", // 1194 + "90428584093d46bf3a68e0de3359b30d9ed56389", // 1195 + "62707c8f514abcbc65d7e79564da3f451caab56d", // 1196 + "baf72c67d84213367b4b48e77280b14307059015", // 1197 + "efb522b5682e234b1c13485d58d979215ac3360f", // 1198 + "61fa4f4a074f04aaf6e38895483d870aac3592d0", // 1199 + "5792ece075f7e62dce610bebcc7e77563361916d", // 1200 + "461901c08b86ce8ee0cd0c3f49114273c676f88f", // 1201 + "65a971033e6cc68a7fc8a1cf2e548a142d483d39", // 1202 + "5e63bc3d51e1753928d05b587014b4664f88f5ba", // 1203 + "b1ba81d2699931be65cc636b7d449e066403ea9d", // 1204 + "1bacdc371e4c0128b555e9bc18f613facfe57591", // 1205 + "e31872ff34846a3d70088363b00cd31ec3553450", // 1206 + "a90687e96a2950c67ac6619de6f59d18d55eccc5", // 1207 + "8391f2940b0ef39f1b62ac8e00c14ce91601f0ba", // 1208 + "47daf4a093c9def8c08783c8a0f7741b85da9b76", // 1209 + "6abb42695155b111f34d47375c786ac88a9cb5e2", // 1210 + "3285643530e84a0f6a894336b474d879a3b7251b", // 1211 + "19f3545c271b36922b763ec7e857067730ad5184", // 1212 + "e34ff032fc94ee852af70bb74f64bc854b66687e", // 1213 + "2cdcd428d7fb9c5d12ca3bd8f3c7f8fad97ad22a", // 1214 + "63782f3c53f215927b57d65f7c8175b7e7a80d8e", // 1215 + "56ce7503f6ceeb988d7579fc5a23968d4eddf6e2", // 1216 + "5249b595c035d13e404bd15e72fc378de89b7076", // 1217 + "cfc2e94b33995d5f9f500129fe991ab1f561e84d", // 1218 + "9205d19a85a67047911ed52d2fc9da035264f019", // 1219 + "1906f3521c9f1eba4b0f8d419e6ed6833d233281", // 1220 + "e86e635d8788391173fcc6759167574a6dfbded7", // 1221 + "885ce9111bf60aa8d91b2f01560a63ec7cc636aa", // 1222 + "8b8c5a320f1c1197498b6514c02a7322ad2b7270", // 1223 + "47db378cc06d6ce421106020a602095f0a07252c", // 1224 + "6f09a5777ca92761a845248b45bef735ce14ba60", // 1225 + "2f71911688874695ba0f3ce769be1a945cc50c2d", // 1226 + "f765afe006bbc2a7bc2618b01e0a3dd3936ccd64", // 1227 + "d63e198de2063c61cabf8dc8535c93ff482026d0", // 1228 + "542ca49834b1cb2b64c085c214b9e484978c54f1", // 1229 + "efd5e690c5f4b06c76d20694fbafda4c37e51ee5", // 1230 + "fb6b85f0b3a483509433b956e6d34ee585224ebb", // 1231 + "b26a81fe27f83d2f23e470b381a7d2d0d094dd35", // 1232 + "01d1e6be107affd4a2caf26a1f62079fee137c13", // 1233 + "172040410953a58e70a1aa0237ec8dfbb4e99d54", // 1234 + "a55dfaefe9127cfeb9ce0a85eeb4f8ce847fad04", // 1235 + "3ae17059fc257f2fcd55e4153cdfc91a70ae4c41", // 1236 + "b9bea49ca420245a4644ec97123a5f1a266f3538", // 1237 + "39311057c6896d4e3697bcfc9cfaf4a9b2b7af0c", // 1238 + "c8f634249b2b8477bad55858beea886cfc1300db", // 1239 + "c9330a405fbd4c267c7cd38a3eef9abc0308089b", // 1240 + "ae2dade5abf519ee97ff3d81e79b712a910611e4", // 1241 + "4f6d23f6f9bc8ea85f2489250872669ee08d0286", // 1242 + "dea83f246c89c99f574c5029e16af144ba1f101f", // 1243 + "66a98c2f413bd116d183b4ed46808743c37abccc", // 1244 + "1c9f2152f3dbf01427ef17a7e9d7b14c30350555", // 1245 + "bc56260cea026044085986ea4f1f00f00f45f718", // 1246 + "84a9060164356634f76bce90f0839d679b524272", // 1247 + "b6af6fff77832b1d23f539f482bb83ddeb4e7a9a", // 1248 + "31904dffe71c5ea640cd4b339320f4f3cc03d8ff", // 1249 + "a5b45bd52275946f8dd7df773c0ec72eba09d299", // 1250 + "fcceb77e2eac6cf091b9fa93ed552cbd970f1730", // 1251 + "355d6f0b12c862d61426c9193df7797eb9b4e1f2", // 1252 + "bcced855e9aca5c298200df2c22640fc71c660b9", // 1253 + "62d5c66bcb6c657a143a11c7cea29f10132310ba", // 1254 + "3a13e553948090dab35de6bd81d01f2f102da92b", // 1255 + "b43ac8c75ea871d8edf16b0210bcb19055d71941", // 1256 + "22a42416dd869fbfaba576e1b17e7632a82ba6be", // 1257 + "ec6499f65711d03f8a36e187083ac4b5395fc9cc", // 1258 + "a1a2306db5691c58a7a639c75e003983cfe2dc38", // 1259 + "c61be963cc56216d5103aac1015c4d4c777df951", // 1260 + "5c6878e631b75b12324addbcd5bf94d5b9740c25", // 1261 + "9bf151658fedb920bedc8023bb86c03f60459076", // 1262 + "6284475143b1d4de74930574994c88e4d9a1cb86", // 1263 + "8871d1afb829003b65bd542ee8628710c8706f04", // 1264 + "9eba46bc99e0a450a20a7248fbae7383186b56fb", // 1265 + "a519435be2f9278e69b5baa12f2be601febed432", // 1266 + "4145ab2cc5017f8725904e5db9a810e3f44e8f24", // 1267 + "367046dd8378534acbc39a106b4c765aef5540d9", // 1268 + "f264a34d8e49cb3deb2dcb330a1f229b0efcb0fb", // 1269 + "7928bbed0e02c410a71c1deb512d1c1000871e7d", // 1270 + "20ab12f0fc95e1f14675d6ebd52d5221f094dfd6", // 1271 + "d832bd1b9b47fdad0f5060d0d73ee68709bae788", // 1272 + "d4da10da4666a0d10e93c0ef4ea711ac1ebf2564", // 1273 + "499a148166b18ab757e4300cb4dc5f74248a5349", // 1274 + "d54664102a61314572b16b2ff460e9d806b09fd3", // 1275 + "3851be9819f7bbbabfea9f8026d55b349455c309", // 1276 + "7a4b9f3609e3f3b6f003f84cb5b610f98286d4e7", // 1277 + "b3bc241e59caff4529841db19bfbd6a5a00ce855", // 1278 + "a9d471d97abbcb1635f00761c2a4ff2a37f6cf8f", // 1279 + "caac73c779eb67e5e53abc9c6eaa2e78a4ac39c0", // 1280 + "49655f337ec7f2986e66e4879d49192195361fbb", // 1281 + "f2fdde4fdebe174c27e9f7a700e5657ed4e2e8d8", // 1282 + "d50c93fac06e797da8aaf4682877a75ddd3da710", // 1283 + "7c79b095c4f04ac0aad8bb172b89510130cce74c", // 1284 + "81fcb9ba115c52e3864de69e41c5a459a2e2a636", // 1285 + "ffc5a582fbbdef9154ce08ea70373e8fa13671f0", // 1286 + "fa6a46794f88b07bd73296812312ac66961efc17", // 1287 + "3d763c02915832dbcb2beeebbd4ae7e5d2a4ebfd", // 1288 + "d5fe51110ab3c8122dd663387e8aeac405e6c141", // 1289 + "78f81de3427eec2c2a6d0b6fe1c780b78f32f778", // 1290 + "83d56c241c1c454cbc6dd61dc67e1fff21b48dcd", // 1291 + "542c6b3a75e57f139de4a3838deec11c5fb503c5", // 1292 + "f7bba603ff5fe829d290fb784f7607ef1a99aa4c", // 1293 + "c70eb41721adaed0595c031ac8a6736c102ad8ac", // 1294 + "1fce002b83027c17908790fdb169af54c086b3ee", // 1295 + "65ad9bad8886f94a87b5564e32a47aa55baa5952", // 1296 + "544661968794d6703cf72fa8d028b6dd6179ed68", // 1297 + "a0992ea61a7992e74752bfd27aa87bf7e61ba43b", // 1298 + "cd42422e2e582d9caa3a928a47f30b6f4aff550e", // 1299 + "29e9d291e763e70140cda0cfb1b61325c42828d3", // 1300 + "349402cf135fe690ef25cd3076d1f9277145c81f", // 1301 + "5cdb2bd4bdda2766e06de63798f4987ca60b0ece", // 1302 + "b9792742371c1334317f1b2074a4c138a1ec41a4", // 1303 + "a33a0721f0d1cba9823e0962b874046e80edf843", // 1304 + "5a45312ea0aa4c5c5319bce109d1a1bd88d47ff3", // 1305 + "d4ce883b5274663a35a77324b4b8ab4392c3a953", // 1306 + "8a449a194a865d3eddcf0a77b94f140c78503440", // 1307 + "21a8f94d2a0097daaff6ec50403d75252e12ee56", // 1308 + "866ac7ce3245cdf474e1234fb30c3064d6d490da", // 1309 + "5eb212ec1cbd0708c6db9f0f859ceb65f1c7c644", // 1310 + "5f36ea56cb329641aabbba5518d4cab9f8b659d4", // 1311 + "51064c3624c96ea934a29eb40f2b13b7484098e8", // 1312 + "6ba07d62d97a55656217fa3069ff0966142844ff", // 1313 + "a224f7078152da6c8c63404c68ae602a3d7a6dd4", // 1314 + "303546ba1a03911477e6e7f22486633859edc81b", // 1315 + "9bb5c423131e2a0abb6c8005819a3e4745f03211", // 1316 + "e1ac54f5c37dd597a6def3a456248a5e6f7cc187", // 1317 + "94b7c0952229cd089a760fe42172e5804f6f1162", // 1318 + "b74cc1ae88cef184d0a389ca1053a6fe9f5f37d5", // 1319 + "071e77f97bc10fc4b662060a268899a433c0ad71", // 1320 + "924c0a32d27e5ed3537a1bbb3c76c51443912693", // 1321 + "70bbfd9be7a2c2f32442c47ffa93f10682f369f7", // 1322 + "47e4bf12ec69a097c0e7f2b810d76480f097a7a5", // 1323 + "716ce0f6cbcfff4ed73cc889774d8497c239e7f7", // 1324 + "5e7f251a01a45bba31424669ca5aee83d593f94e", // 1325 + "e5a4fe0e10325c375aceb67b740d782db3ab331d", // 1326 + "e6ff6fde183d62e901f787e605016f81d5abbdf2", // 1327 + "ed5d411e424cce595e8ed584e8c16191619ddfbd", // 1328 + "39e3cc7567e9a379565a67488fc87c7dec1280e1", // 1329 + "aeea71a0641bae3ea7c4cf31a8930d25d79f7810", // 1330 + "796071ad61e1852212040d1901b2e9a16bdf2070", // 1331 + "45de939c2fd5b0e442dad8904ff2b2dc8648a072", // 1332 + "d4c5a56c776f00e318e8604b6c2a56443fd4bcbb", // 1333 + "1ac4231301544c422b2882337abc35fef54c1053", // 1334 + "1db3acc727559637dc08636d9b14169391c4053d", // 1335 + "0cc989efb0649c638f541cc158337b7ccff06956", // 1336 + "671ba2dd28f19b2525eba11c042961ef7b60d3f1", // 1337 + "47820c36fed2d7c79de07bd390e02f621e9162b5", // 1338 + "5b8b4962ff8382ada344af48ad46740e43e28ff6", // 1339 + "027ae87e12669eb7e172c5c6c2ad7dbba09a0ae0", // 1340 + "12a5304b390096ab7216a44feacf7fcb615d5f20", // 1341 + "7265a4bbafab18709ec85426d576644c7914f73e", // 1342 + "d65c768392e4a9eb333182d593d9318afaa98e95", // 1343 + "8cf578ff7e4543442be83e8f92e4a5e4e1f9eca9", // 1344 + "d16b9407448bc004886216ee5a0d85c4e1b60a6b", // 1345 + "35ad13973c6fe7aab3cc015244f10db0a1429768", // 1346 + "1198b1568993462bb45033baf1b163fba4799f8a", // 1347 + "be999b1a3d672681101e90695fb119bb48b2f201", // 1348 + "ce1fa6e45dd583e67e3028f520c6e4fad98947fa", // 1349 + "047340977d3ce4ea2ab9be4ccc20e98275c6f05c", // 1350 + "24faa51b5ed6c0cffb2a233ed5e4da31e9857e8a", // 1351 + "cb9c30415c7c8380fe0fab3bc39d015dcc260425", // 1352 + "df43a10a6b12f18e65dec956b92db2aa8d8c61d9", // 1353 + "fecb765db258e0fb8355323856b32468ed480c83", // 1354 + "1e13b8d5fda850fec704188ecdabd3a1f09bf8e3", // 1355 + "255bb6bb2939f39fea1f5262d0582aa5673cecb2", // 1356 + "c93969aaad8d59ef0d73273cb07da5d085d36be7", // 1357 + "936fd12c31a8753a35781a3184bc266f3d398c96", // 1358 + "4633b548ee9222417d69513fd2636be11c2c0bcb", // 1359 + "a62b4af747d60cb647de51b072ad3627f38ddc73", // 1360 + "56080f753de490127797ddb908ca247eaf6caa19", // 1361 + "aaa715f55609c72522e2f93ba232611dd0690e93", // 1362 + "79ec22ac17ee18cef694776eb7274cd1a488214b", // 1363 + "5d4b2621f053b7f5dca37d13e30522ed9bc4e0f4", // 1364 + "552989c5c7b109ff0cf4519addf8ae7a209ff31b", // 1365 + "17d4629cfc9e083b6bf02c621d1e4448a78a839d", // 1366 + "3937bdd0cbc5cbb60725ae2c6c258554b5a52024", // 1367 + "2d4316a4b16c55b5a6b1407011e0e18bef95c1ee", // 1368 + "4d9a0e05449c8e0a3fd81c23e05cc4eb9c39c3a0", // 1369 + "7c93777a19709eb482b71a399f473e86467354c9", // 1370 + "e96202c8b49c22647cc022d522a1339114a98b46", // 1371 + "e1fab0c99a74b3f0ffaee03a1328638052f27c97", // 1372 + "fd1bb1eb56cf4ded458c97cfe26c5582a4c051a1", // 1373 + "896e713db153cdf36f6c6109dd91d4c3d382c835", // 1374 + "6c50ab29198a63c9f247a75d6f95bc2d62d1cad0", // 1375 + "7039fd6298fe8654b2c7b38f23bd7602e588ca7d", // 1376 + "1363048fc5760805511e7489ba36128dd9e82153", // 1377 + "cfefcadf82bfe5b1ecac175fa6eeb22f2d75c0f2", // 1378 + "ab00c60d399da2f234de430e3a7eb50c5f0d3020", // 1379 + "3ae18d3512efb13cf714cfdc4d0f43c540a3b5a0", // 1380 + "6254cfe230cdf0cbf81d6d8d9fff14a8f174ac7d", // 1381 + "ff947a33d7daf778abce19a19a83f3a30525d0e8", // 1382 + "10398d12d83911ecdd6df38f3f0e46c31692fcb6", // 1383 + "a79bca8bdef0ab2c2105e7a7cb2f2049ee75151a", // 1384 + "f13d834a0e065088e9645001b4ff7a17a60634b4", // 1385 + "04e36a18623cdb3e09a04e1b84a7f0fe364f2634", // 1386 + "012caa4d476c43415fa23c508abb3ccd062fa432", // 1387 + "d4a13580aed12b82e3f912b099850314dd0ac87c", // 1388 + "b5dbbfd023346e87e32bd34193baac1ea7a6e142", // 1389 + "0886dd3b788548bd0752c373334880efd7a37f05", // 1390 + "6d3430510e636a939219424012f42c149ccfe00c", // 1391 + "cf6ae326fb2deac5a6d2736dfd05b6a64bee2d14", // 1392 + "e27abcae9d92c2498c80155c87a06b6cab44db31", // 1393 + "efb854bee3f39e5303c0ed4da762c94ffa2f61be", // 1394 + "f6d858e7fdbdcbdab9987f0d4e7e8b59dc0b5b91", // 1395 + "650d5514d4e61d53f821b1bedada149a109d78b2", // 1396 + "693a5b8aba25a5296c837073888d651aed1d8925", // 1397 + "54da4ac24ad9eb61dc2b1c6eed6ba665e559dcf5", // 1398 + "77c49a814670f01f4dce5e257b53aebd83a54b46", // 1399 + "e57e612fda1fe344f4968cd43041ac7f1edc5518", // 1400 + "8bce904dfdc33717886da94e3e76074777dee837", // 1401 + "4ba963fed4556a2387688d88fda11273f04fd7f9", // 1402 + "0341e347c4f65391b389b07aae03bf5058f84f65", // 1403 + "7d8129b5ec5575e4031b2ea01930cdd29e65e2a7", // 1404 + "d436912d2b4f3113003477846f0c468a4d9c11b5", // 1405 + "004455c479c4d9db0bcbeb1c89a8838cb24e61b1", // 1406 + "65813db6ba6f0062f81380c9d81b85aa354fdf03", // 1407 + "b9b3f04b3ba29df486e53941dadda2fdf0d36b7e", // 1408 + "b96bc2d7d7fcea362c6318973c13d0bc25a9397e", // 1409 + "78f4905d25c83ddbda4e88e0322552e06cb1ac4c", // 1410 + "339d1ef6af4feded90354f1edbdbe706045263a7", // 1411 + "8eed7c9b6cb92822acef49470e35e63f510cabe1", // 1412 + "7818e7ed9003af6e8e07dad4ccfb4463ea834e1c", // 1413 + "d56a5564b58be36f6ee0205f75d76998283f26ca", // 1414 + "9f61b47da79f41f4dfa54a9035e4a1517ecf001e", // 1415 + "023073966aeca3a548990034bcf11c1b42fd569e", // 1416 + "c6d86edd6c9e38bd53335ab347a1989b2ad69614", // 1417 + "f396779d4ac9e6eb7da3f191059fa7633d24c11c", // 1418 + "989ecb264cd438540e850001fdac81bcb008fa46", // 1419 + "8df5c80c561bb21c2278660c004a732e2449b120", // 1420 + "13b652a9b3b3e30336089c004959d8653fe49c31", // 1421 + "a4d50804510cfe4b351b643d48d128efbdf67d9a", // 1422 + "d3910b6a903756dca91b937023acb8b12164a0c6", // 1423 + "4aafc6139aa5a13ab810c734e5b20ad1fa40fbe3", // 1424 + "f8ed14b1af0895ec88626a49d88512150b5709be", // 1425 + "5d5d7ffc3e481eb48dba368034d7bafc9196f7d6", // 1426 + "5c12bf862e751b7967f008a4d42e17b23e6dd9b8", // 1427 + "32af6a7ea0d325b34f392b79f46f68fb235f452e", // 1428 + "7557cecebbb68f628f7b333ec69f42c259f5042b", // 1429 + "7e115d6ef61b82be21fc13b35887dba932cdb150", // 1430 + "703d3f9def7b31f28e3d8b75a73a44233ed3b8a5", // 1431 + "753ff91f65c16c37ef44a9f577aabe629c4931d2", // 1432 + "77fa3a9bca530e34f922a691d9bc000718d21586", // 1433 + "ec04bcb94f05e9f993eb8c057ef96b310cf83dbd", // 1434 + "a7937aad67f201e93c82473baa445dec2fb47b92", // 1435 + "7126c619288ffeb784b3e1821f78f767931639dd", // 1436 + "22f4f17a6fd3d31942519ac4c1b1152d396e3080", // 1437 + "93b312159882910ea8c294f01aae0d3cc695b27e", // 1438 + "50a7c6d5d3c5a146be322bfdb8d21693e9d04661", // 1439 + "da28398845bd15679d51b370d969fda0195efafd", // 1440 + "25298fe39b46c42a884d4c0e35ceb05455ce4c5e", // 1441 + "bdb6772945bae99d848868eaa4c81bb2026d78c7", // 1442 + "168b3ac3fbc0f7dd323ce0cdc02b1ddff3dd871f", // 1443 + "9490d1a05182df093c453c334fe35d5f8fe341dc", // 1444 + "268a8677423fa957badce098bcea36a843eb49e1", // 1445 + "6a1012a132ee2daa00701b499628b1a5a9c55880", // 1446 + "9dd48a523c1b65358ce86a9b9a77214e0a5010b1", // 1447 + "cf5c02d0de9c638e785213753cbd14666a3e97a7", // 1448 + "acb55ddf642ff2df7ac4aa33176e4cb487f1ed68", // 1449 + "ec2adf87eed1458409108980a25d8487035dfd67", // 1450 + "67c43ea944c3a28734f9e79f9407e5e84d466940", // 1451 + "603ea27375c24f6171ccf4a53dc3a85d5191c758", // 1452 + "931a7d05aa0f2d1edc7edd56842e3c73422f0c37", // 1453 + "13b18cbda8d71d60141e29b050b3a5b2fa63a4cf", // 1454 + "6ef9d6630e1fdf92a0b7587f68c8f5f312b7b5d0", // 1455 + "5e8b80266c93281322f866355739e2fe4a1bbdf8", // 1456 + "391496510909e4c3ac6d610777f3c21a7fd8cc66", // 1457 + "d4aa40a009481e3bee92fcca9d652ba5b76a3a3f", // 1458 + "936bfedbae1e11874f7ab326e1e29f8fa219382b", // 1459 + "1709daf2f7e73198b23d8403cac63c772cb447d4", // 1460 + "3699e401e6ec170fcf7c4977a4e8cd233f12fe7f", // 1461 + "c69d64da3fa88ee787238cdda855c54cf6834f41", // 1462 + "556786f73365e3db6518e39c87f51cc5a96b37f7", // 1463 + "58438bc823eecdb917c5c75d42c8fa1c79d14f52", // 1464 + "af47872d8a8ff4f3a5664bafe52897c2736baeef", // 1465 + "24b04a1b9b51648d2376f40b3e2872cba1b561a8", // 1466 + "c9ef5faf0dc83ce0cd070f0780db7673bbe128fb", // 1467 + "c4e95df4e11b52aed462b1b5ab9ff2bd405093e8", // 1468 + "1e7d75b775cd7552f212b7437834f65ba9ab9ffe", // 1469 + "7bf049521baf30ac51a9923230d7807d8e87862b", // 1470 + "defb7b9990f679df0ccce89d35b7a523d060b525", // 1471 + "c8d2806e7e2755f4aaf3bfeb49efa5f273fb9c5b", // 1472 + "03f434e2c28452f32150709b68727ec5c9813c79", // 1473 + "a9f38b01e9bd38e42a2da20a16bc7529bd5ecc1b", // 1474 + "60ae6d5174438c348e9b05a389745916e111bbdf", // 1475 + "09ff08cdf0d229d9c6eac02a9844b4bb39762c0a", // 1476 + "85ac6165dcfabc757e86210eececac26af118336", // 1477 + "cc03230edf895e2f3cf6d5c8bc8664278ed6f5dd", // 1478 + "370268aa3a6be484fdf114afcf55865f70e9bb8a", // 1479 + "e2547c14a354345cf32a5d40ec35ee8394a60b1d", // 1480 + "8e51720f2ab2e66b1e3ed57415c460dee534c2e7", // 1481 + "31d3870f5a58375688ba9b23cad1fe2eed1e1a7d", // 1482 + "837aea55010b4f752eb2f21e562bd9c6a9607277", // 1483 + "8a45007d34c36e7f527a8fb1c39e54d44f209f5e", // 1484 + "7175848b010a1314379fe28e44b69bb3e5551c5b", // 1485 + "593b59fd6350aa3b212f5896ddb242a028fdb849", // 1486 + "8bfbf6928b8ac77c476f83c9271ffe1dabeaa7fe", // 1487 + "f0ebebf09d6bb4e874f891d2a9622c5229eebd13", // 1488 + "301e5f354ca74a39dd79e572906b3ca1142e65a7", // 1489 + "d5ea948b6341ce30a0f16bd45486a1605785772a", // 1490 + "ed460796fe765a3e32cc794b63fc9b7eabbd89b8", // 1491 + "3bd3d11da7edbdfc2b731381b83f21ec47647981", // 1492 + "7912a094d743ea638afca130ec8fcc8d3ed59652", // 1493 + "8c7283d2b1b771a0179c68b591d915ffd08f02cc", // 1494 + "7e16575de39704f007f199907d5b8627e6a1edf7", // 1495 + "35f311389db6e8771f5ad745fe0b4012304ca99e", // 1496 + "2c3dd9357e44295da7b9d887c2b633d1b4d0925b", // 1497 + "06dd7d1a353d8d5dc3df671b0e6a0d1d57e8be44", // 1498 + "3ed5594efb00d2667dbca6035d329f70ce5b0dd1", // 1499 + "c520e6109835c876fd98636efec43dd61634b7d3", // 1500 + "b53b879d8455057c18b6678378ec75137980346a", // 1501 + "36b0b371d28fe6fbf394ce7d850874173f7ebe3a", // 1502 + "16c2d78510a3e259f464c500700206f857b37164", // 1503 + "a8af96e4f09d2efe407b601367b21cee62b5f28a", // 1504 + "f899267c651cb58f3ec94eb37a5d24cae6d28bf7", // 1505 + "7939c7af218906f6bb1cc52983bc6f209429c48a", // 1506 + "21515dedb08aa45a207e4bda7e3b2b029eded95a", // 1507 + "a11a0602400ea715322c27bb28d6db1903ee8829", // 1508 + "52b07cb7f540b8b052d3d4cae11ba3568491eb57", // 1509 + "9ee02e3894ac9be872c20d57124d249d988c2e5a", // 1510 + "051cb3644185f4bfc878b6db25f804295c35a8f9", // 1511 + "ac66982e1289ec0407eab0a6c1f8a0da868cf94f", // 1512 + "f70f82c493e292a83c7c14226095bb90e016c88e", // 1513 + "8831fd9e33c87eda7471954f89fd4fd07b9c14f0", // 1514 + "e85761ccef6814abc47ab8a395de42d048b9dd5e", // 1515 + "6d5127869840d04c3485405f542a280572cf41a0", // 1516 + "6d7ff1240d0399a33ead2a01c0de740319ca42d9", // 1517 + "ec0c6fa22ce688f975db766c92723c65cd8eeafb", // 1518 + "bc103676f0ce813fecb248dfbd20a82acfadf45d", // 1519 + "f8fe38897d245eb3a739239789ea420d1a3f91a9", // 1520 + "8870a05d6ed8285e5d33b76c4e2c4033cc4c3710", // 1521 + "5f601fbf1cf69438a7467d595dcf195b3b0dbb77", // 1522 + "31e53e1c45e8dc73c6a0d907b375bd51ab354a31", // 1523 + "d785a474d1517adf902a975102f32a9d75c3d225", // 1524 + "e845f01c09005546055fbae90c497a9b174ef98f", // 1525 + "35b01b11023bafa50f192acedb4d95ef72c1eefb", // 1526 + "8631b9a6ab1a74bb7860d53f2f9ccf054d9d31cb", // 1527 + "7ae3646ffeb479db5cd8a02591e1f674750bd7f6", // 1528 + "b7b527217a3085449f681df3c6c155b781ff5f2d", // 1529 + "dca4c302402d733e29162cd2926d51a4cda630e5", // 1530 + "dcc866aaea814861b52af678beb3177a2e33b73a", // 1531 + "7ffd0e2e0fa2698bdcd84e3dc19f11d70bc5cfd6", // 1532 + "feb5b6425b69196778e27933f3cb706e66cae73e", // 1533 + "1033596d0cc203401739d8884436e6e53180aa13", // 1534 + "b77f2ac6a90f0d460cbae8a90e2250f545ba97a1", // 1535 + "e64efbd5ce9eb4d474db96be1aeca46ed4554839", // 1536 + "9c044f2f3381df832563c3b140c98fbb6d6e9a6e", // 1537 + "1720a65829440ed558dc24ce2830ad3b50bdc148", // 1538 + "7703a787e864c41288b433093142c2864b1d0609", // 1539 + "04a7ad77d61d962ec564c8378b3acc1e109b4064", // 1540 + "4702d9b4d37254dce67ef9c936a1974a217969ac", // 1541 + "25e5889d43e490d6983f3040d0e45090b041c823", // 1542 + "20ab412957350854cc912dd32d56c7e342e91d2a", // 1543 + "2feba7aed70527b073873e9f6db7b9e22da758ff", // 1544 + "1650055cb7a8291b2877ae32803d147cb440eb49", // 1545 + "8cec433a48dd49cd82a59af2b4eb9424081ea79a", // 1546 + "8a7a8402c8f2feb1f8104ab40f1ca55df3c325ae", // 1547 + "06b140e958a2ea6804b55c1c5250283c03d472cb", // 1548 + "4c6a772ed32b866f9c2fcf30b53ce77a9c169a9f", // 1549 + "04a111363462e098d20c6a35326f54be32a7dc35", // 1550 + "b4a9e055b3fd25a59db2a9bc8bdb5cd528000129", // 1551 + "bcacc71d7dc90d711423deec58c9888c432a94c7", // 1552 + "ee073bde9f7805b3a1b5c87652e5cecaf26c6ed1", // 1553 + "51209e0bca4ec51ac5ed4551f7882afa23a3d0d2", // 1554 + "39feae2cbad3dee004e0febc7b9fd6be8bbfebbb", // 1555 + "2652a0dd490249b8bd134db0d8dd7c2a62103134", // 1556 + "05fde8e7b7a799d30a512b704f3fc31dc82cebe1", // 1557 + "ab4cf7b0848395cc417b3f3fcba2f3615ebf5a93", // 1558 + "ffa040bd7e789ee818b59aab2fd7262b6bf7511b", // 1559 + "8dfad8384b1a5521d0cad1d0aa9407387eee485b", // 1560 + "f3b9e789a99cf97f6b29f3e4d4322900bb08912f", // 1561 + "f6733339aebfb43af78e87aedfe02e108a571134", // 1562 + "f4e09abb9c1b3113ba9b5e34a8ebf9b8488e4f49", // 1563 + "da38be5f3259134c93c0559669cdc00fb0f6857d", // 1564 + "0f4ee50de167880832e7dce5916b7720753d176f", // 1565 + "de31af7c17020080a71bda6f506aaf206558b8ee", // 1566 + "806807aaed369d3dd9d713594165b6d43beeacf3", // 1567 + "2136b04ac7cbd7b7e64f343c21f37d28f050441a", // 1568 + "ee1cf54c501aa9c68a2bbce06ec71e6bad579fd8", // 1569 + "1b44b357890e2226678d35b217ca8d569d987d85", // 1570 + "7336d41aa67a09a5ea802b5b445f80bcd66afe61", // 1571 + "01e9e72aa864a87d6885609f8f8ea7aa71515401", // 1572 + "d9d3bc5cdf80a9e4f25e0ffc32903a77e7ee042c", // 1573 + "4a82f38a1b4702091631abe0f4a02dfe04901dc4", // 1574 + "e3122ee905f8ff50d2f17f94a17488fbe7fed3c5", // 1575 + "ff66678b95d91d3bbb2e66d15eeb18275f4af601", // 1576 + "2e058797c8bfdb0e4b09f8bd835a72e360b17b36", // 1577 + "c55e0fd816f8dc7b051d15272374d16689f790fa", // 1578 + "41217ccfe0da98c9566e8e67f2c775f5b4320c75", // 1579 + "464722a8c8800dd9e2f37903c6b07b3eb40cb9d4", // 1580 + "8ce225dda481580a42c9ca56c194212feead9ec1", // 1581 + "fd60667d788458284f05aef840d77825cfa29457", // 1582 + "66349b11e4c2b17e000a2a1d702393c8e1f0cea9", // 1583 + "0f0b8a62fac180eb7445d74949cb3a77a979aba0", // 1584 + "49d3298636596c50b49a9b6d5f6058a76ff9479a", // 1585 + "7a06f40db76ccd4a819087d20d428102c59290e0", // 1586 + "c1be0648a1229337ca256ea78cdc81e25c19bbc8", // 1587 + "885b0f6b14c13c3cd155b48030164e4f1a3fe54f", // 1588 + "017b1650d96aa01caa2936131b416f980edb6002", // 1589 + "5d4a777875aabf632262197f3e0b155c0fa6edc5", // 1590 + "33e6b2a6bf5aa0b720982724664f09b9e4abf97f", // 1591 + "26658a9023a76af37a51754226a8a653f41edfa3", // 1592 + "09ae24cc65f617808a7a3962dbeaa59893bf15a9", // 1593 + "75feedadba10bed39bdc17ecc440d0015e3e1998", // 1594 + "665a2dd67c86c832933c8f86d55ef048fcca0fae", // 1595 + "4f7f3cfe5effd7177b741c0cd41d7ae39ed91b2e", // 1596 + "c3155e77d84e668cbf5a0d85395235bec7bf3d3f", // 1597 + "9d8bb6042dac54b13d38a4400decf49cb6df978f", // 1598 + "7c1838f132e3d8792f3f7d9926627e1cc3cad70c", // 1599 + "2afbf6c38d3750ee56d622f94d0f669af95d00b8", // 1600 + "571d0128cb80d7412e53e3bfad7b92d8a14f101c", // 1601 + "bd304a40789e9571624b517266e5bcb05f4515e2", // 1602 + "633b628e86dbd36449e8d6dca112e41812fdd276", // 1603 + "b1fca6a881fc0f76417485d2087d782ee086860a", // 1604 + "14b411ee952b2b7f354f6526a8c929a6ec684042", // 1605 + "64aed4bcf3323a04fd7ce25c618ef51b12725a8d", // 1606 + "ac67f793a16a741af0351bddd7c4464d9c8dd561", // 1607 + "729b76648f8fec9e3c48fda0338a4c9c5de57c91", // 1608 + "00e8a9f05c0bdc4e2702034fbce2e7d9233456c7", // 1609 + "19be6a1a07592db46423ae03dd618f1379d33757", // 1610 + "a925944b42fa4bc5137b8827a0fe072888cb042b", // 1611 + "ae1b89b6008e3c304d27b3961c60c6600415933c", // 1612 + "5f94f53384d1875a59d39dc1edd3012c6688c340", // 1613 + "d537787921df56a80537421bb2071280bc09b0d9", // 1614 + "ab59e47412e52b74d2878cf269062e0b65712d31", // 1615 + "ac695c8622ef70122e82b679163ab8d30d56ee12", // 1616 + "f3ef3abe7755f70ed6fe3b686b35311d958ffee0", // 1617 + "fe876b3e3379f4e1dbaa230ab5b18c751528c0fa", // 1618 + "9176ae3e28a4707a06e9e4410dc110a7c9a205cf", // 1619 + "97a6d750ea26735b68cc89d699dd2de659f370fb", // 1620 + "025bc517ec88e88ad75c3d7f71118a2390036edc", // 1621 + "fefd418540bb0038f48c8f9ee3ff9440498b7a7d", // 1622 + "eec415a165c0b821d9b3e462e8ae071b7bf376f3", // 1623 + "c1658516be85e4e11428f1793d5eeacbd0c98581", // 1624 + "745bbd8afe2284ac31950d57e84e573dfd376e89", // 1625 + "75a772666ea3a6118060d7aa2d3b6420c53c4dc9", // 1626 + "c729ae245e1c64d547c1d71a59815ae0dfb201e3", // 1627 + "af76930c0ac8080dc5849696be074ed979bcfdc2", // 1628 + "c635ad0e3ce192ce1372a80bbe6fa27dff6ed771", // 1629 + "d1c0de8e3d4f2c63d92a5a22ee0151f63923c12a", // 1630 + "483f982fb95af19356c54a5c9802d68ac5ba5324", // 1631 + "a97a83b54c65f8805dd079b4b17ba3142dcb567e", // 1632 + "0f66a777041ccdea82ed44c5d7a1bea578d7485b", // 1633 + "c570301842dba5ce7d508f3a6cc14d5d01309a18", // 1634 + "f565222201094ab2f86f9f8eec08ff4ace6d0dc8", // 1635 + "d0fadf5d937960832ea700a690a52e914f1d7293", // 1636 + "fe5a64c592fb65a9352a39801a9630fcfd3f94a4", // 1637 + "c5878a99650ff47c1fa8489a0ef2a56f6c7adb43", // 1638 + "9f372edc5e1510663629035f8f31090bc3c58897", // 1639 + "a642370560f57b52638f2d56b558fe049273effb", // 1640 + "802c135eba7e9ca7e36ef33addfec54aaf2714cc", // 1641 + "555b6ad1fe1b7e4c1a8073ff9d0d4715f9823527", // 1642 + "9d24b06692ced122f2d57527c8d01fe41bc2d984", // 1643 + "2ab753a2886c7c6067a214810418a5ed9679c266", // 1644 + "4c40f8996dd86359591e729b4cad6b9372620a27", // 1645 + "104541283cfea933d740a0f2e5f50c18d9017579", // 1646 + "16b84add41aa73aacbd3bdfd5da31e3d69e8db64", // 1647 + "8379d2eeeea54396636a3273c3eda21b5736513f", // 1648 + "930adcb23edadae208ec03595c6ba2d40420628a", // 1649 + "c7dbdce440bab72a75fd2dc26f0a1433f5285e0a", // 1650 + "03448f82f6e4897be6746761a390b6d0bbe71111", // 1651 + "0f60c3ab96a39788f65248356e7dbfe817325bb0", // 1652 + "fef159ea7a400d54c675ec4f5b8da1376d99c94a", // 1653 + "17601459a7d643b96731d79119008c913652594d", // 1654 + "08908d580f202aa2242e732a4416cb93d9d222b5", // 1655 + "6cd2e8c33752824b48e75a1400df5c343b392056", // 1656 + "ccd1ef68c856ce7bd1991e76ad185a6d8eaa4a92", // 1657 + "182bd591f9c3bc0c7939a4c40d5f7b882328147d", // 1658 + "af54a02648f50087291987d04f4b7f767d47bdc5", // 1659 + "2d47ae19d77abadc0bbff9c7ba1139902f35f845", // 1660 + "09da8994dbfe12b82d72b0d1598698e1270b6d60", // 1661 + "cb3cd6510b3225c6069664e6465a5b2d9e6b7483", // 1662 + "eae3c1210e667c18f28d1fb52f097a59f0b2281e", // 1663 + "26783cbb07ac4c9220ffaa849fd45e1e532491eb", // 1664 + "b1a5e8a191cd5ebfdfd37f21c84fa60e1a1d08bb", // 1665 + "1425deec7d5de752df3ae39f4ebf380f4ae0fdd9", // 1666 + "9cc3eddb96cc77b312b6a4eb590a821b4d4f84b9", // 1667 + "ff096c2f114b1d351b9b3778d1763d9e9e11167d", // 1668 + "e471b97331cc816ef4ab55d995ef8946ea3df241", // 1669 + "3806dbfae93a2f02ae69b55b81df84bd10381544", // 1670 + "ccd773da75fa8121859e92852164cc7267b32943", // 1671 + "5a73aa80ace849f2035edf90f0f488e7efa37e51", // 1672 + "8728035f3da5f967be5e5261e47f4e7d47e45060", // 1673 + "dac9f474f04b046d552a28ff15484b10aef4d24f", // 1674 + "962bb3d4a9dc51ac02c47aab2c37757cb10cbc9a", // 1675 + "0055242f3600d1ee296f8109ba34297889553e29", // 1676 + "ba6f5e1342348799621539dedccd348c359bee6b", // 1677 + "b542f6ed7b5ff70a1c36f0cb920c0d0e3a0e8d27", // 1678 + "09696f617d57a2720a12a5b8090dbb2f5f8194f7", // 1679 + "541e64ab41d711f2f85ecdb42d65faed175af05d", // 1680 + "4ceb7d7d496071e8246d952fba1f3b95639d2b1f", // 1681 + "2ffb3cb0bf8e5837322f601c9ce8b084061ca69c", // 1682 + "c4cee73a161d8aeda100db8a5cbe2df4d4ca7c5c", // 1683 + "e45bcc3b5dc7a83de69c025f1b8b971ea2997229", // 1684 + "0f8ad890f5904881ba8ce0963d533c51af838422", // 1685 + "c130fd2f25f4932678055ca9c5c9a43cb3bbdc5b", // 1686 + "8f16f45154c98e79d3a92469b6cb050b84ee51e4", // 1687 + "002639b99895b4ec7fde64020e86aec14a76034e", // 1688 + "5f47b31966b86498c4b7688842224ac160a69819", // 1689 + "89a891f2a387806722e1e963802d6ce51fb5c459", // 1690 + "3cfd10a3409f5164c099c30413a98edc69bea869", // 1691 + "2b5cf7d2d4a9ccc70cac706796852c82df8ef6bf", // 1692 + "47ce7615bc2f2fb3c3405c0bb1dc8420ac8a711a", // 1693 + "b42f85a82dc8c81d797bcfaae2bdd83de9506d6b", // 1694 + "e1719dd0704dbf1ed5ac9ae9a224767c437b2825", // 1695 + "5b8ec0b63c5285a43a1bd3574dfa1b173635795c", // 1696 + "f03f5916aedad9f1b2475716f0814b7e859e96e8", // 1697 + "cb176a395a9a9d2f60355734326584b5955a1d60", // 1698 + "084c8652bdbf3590ba0b6a1071d01085beaa1056", // 1699 + "7f5c6cba01b453e365bd3d9636378bafd310134d", // 1700 + "daa95303294f400a6383f044fe468f77271c7904", // 1701 + "75d8ed8edb5c66d121f7d25b577aae04f5d5d728", // 1702 + "3af492c9d3d963ac96ce7d848c19e28a31f7e0e8", // 1703 + "eb6d74eba0bd279ce6c783f2c811ac5aad04035c", // 1704 + "49d6205fc479bac52f72b3522f0fbc68dae9ad0c", // 1705 + "7e05af9c37ceb82f86614cf2755f49716f5e28b6", // 1706 + "ed095cca3abdbbaf840d802a28d6f0fdf918f649", // 1707 + "18877693f1296f206ff07e18642b35f7aea6d430", // 1708 + "437459b345c05764a9c2cac797d5fcf90faa4c51", // 1709 + "a7fe720e1bb3387b716b0a38f43a51a1a7c3b3f3", // 1710 + "4c2a33f8b14203bdcd785b18fa164e7c7e0362c8", // 1711 + "5f4c732f1f628c6fb7fa899a68b7de047fb1ce6b", // 1712 + "9e4c583dcb8d28859ecad9faf2dda8f9e8ed3a07", // 1713 + "0f25e4c05e5a8ec6519815c0c13b7f581da957ef", // 1714 + "b0ccf9df38b7a5db35c50faa1609d221dce44b64", // 1715 + "72216ac84a34f86d7e7c9219d90a3e9ace7eec31", // 1716 + "3e9b6a6c08c3d18a6e31f8e7f90002d0b6fd6c50", // 1717 + "c5a35519012441960cd3ea3ce1c5ba8b77765f75", // 1718 + "39b01e0c8bf6c2a97a759d57dd9196779e5a3fb3", // 1719 + "a515e27c6848cf38fc698af93966eda7e8f8c5ab", // 1720 + "2987f979093f165d28711ef8f7e3f5b019da62f7", // 1721 + "cd7f600026810b98313ef34faf9e324bfb88ec46", // 1722 + "51de785527b0937d79ba4cf37e6bb20172b12440", // 1723 + "0726080de957bf1b2ba2273e8bf3c2249aa421ba", // 1724 + "74dd7343f706ebe965ac945fa85bf30b3e263384", // 1725 + "416e0b747497d0d99a0641fd51d1094918f029c2", // 1726 + "d76f024628967fc7cf292f6e6844823cdb34cc17", // 1727 + "6d0811a1157b77c0d4d6bb1647a5c413f21ba810", // 1728 + "902d6c0f299b12b6e917ed37ed4e5c5e25a69ad4", // 1729 + "7ae46a5cba26c875b6d09fc0ac4d88eae5a5bbbb", // 1730 + "e1e1dd030869e83d4a8f60207f23a950d8da4c21", // 1731 + "9964be907e500835356269968ff623e0cbd54812", // 1732 + "9c23d722c5502ad2414c55f901b49883bd582bfe", // 1733 + "6faf308ae985ec6b4c0f0ad3df187a5e6b0a37b9", // 1734 + "f9aaf334a2900fb43d23cdaaf71169ffc29a4353", // 1735 + "3587d069697d8d1e765b35f00aa7c43f83c931bb", // 1736 + "1312d5add1db2ce7d8b960ecc8674ccba9500846", // 1737 + "b81a3007cf40f6df7063de456f8f18f19ce3d20a", // 1738 + "b18e28ffc800bc10efc996db76103cd34cbd21ff", // 1739 + "a26bbc27c6f6cc2eabffb0fbc86589197a3969b6", // 1740 + "fa4a9118cbd5a163bd3891a00b74d7a4341a26d3", // 1741 + "a99b62ee6cd6a0b8713f0361493314f63f205303", // 1742 + "3f2d103f757fb3f72e2fb0db46b7160299671fd9", // 1743 + "bb7d21ca34dde65f5735a58244ecc9279ba862a2", // 1744 + "e72feb51c3493798d0b1411a8e7eb2de114eed80", // 1745 + "2e571ae9b24a4deec88932f70f8f7ca85d443307", // 1746 + "2dbcbd090605c56f0622258e81a36abb66d588aa", // 1747 + "c140aab7f64cdb080373a1cc895895b307a01b78", // 1748 + "3e536c34b4a07308239cfbafb954f59734db2524", // 1749 + "b01afafa207934b70488c83e9e1c970d8df87268", // 1750 + "0f2ccf837450b8eda9c8c41abfb6804f228603e7", // 1751 + "476f696abedc2de00a70f311b87017817d48a774", // 1752 + "fbe73e02791652af6d32a79e0b1defd565866496", // 1753 + "968b9f55f8a3ee2231cd3f3fdf9cca29a0252a0e", // 1754 + "7d416501419f7603e965c1daed0c59bcba463906", // 1755 + "318c55ca84b6e7b4b1a7eb2be9809b05dd2e10c0", // 1756 + "b761432a3ac0a55d37e3304ee40393391be4fd0d", // 1757 + "d8b3c213fcad02f54c27051c1f6e1fd1311bbc52", // 1758 + "fcf9aa465b7bed7f59f8af45dedee986bb1aea3f", // 1759 + "cddd8d077675412ba0fc6042254523f9faba31ef", // 1760 + "1c0dafcaffcbfbef45c2a7b7245f55cb21af9fab", // 1761 + "0c28d381ef26212dd34e4483e33f75d38f239253", // 1762 + "9fe38adf87eb73e8a816b2c5adc00f5b730dcf42", // 1763 + "e8e7520734249f42f028ab012b7c3d14d3f26d80", // 1764 + "1a8bc5d87fc5c86e1540d4371a439b2cb83d686e", // 1765 + "be3de37b2d5228ab4ad3e74b4829bdd492fd19a2", // 1766 + "504b98c417041910db9dff91a8b20d81ba726b9b", // 1767 + "1790a5736bedcecd5f8c5651e189605a47e55fa5", // 1768 + "d0c446e539b07cf78041fe41fa8aa014d48a5264", // 1769 + "6fd72aa539ee0eb1cb7cf8e1ec81ff3a3faa9ee1", // 1770 + "035e4ce762af950bd919f3b498ead6dc51c25b57", // 1771 + "b1f8a49360b04e6197370908f6ee59fde589bd89", // 1772 + "e65f613497bbf07dd9e8413c3bfb1e70e3323b64", // 1773 + "77783930366b7d7763e6df17c5b55ce8b628eb39", // 1774 + "e69d5044d9ac34e3d7d54bc7b780b9ba94da99ab", // 1775 + "a1aee1071e2ead078812805248dc68cd294e9ea5", // 1776 + "a925c37e1b4ed51c6a8806631d4dce9391d58858", // 1777 + "746c87a4b71cae8e1f9670a1fa227267e08ce31a", // 1778 + "351e813e7cf85d7a8f21f50dc3244e0a6da6626b", // 1779 + "6593bbbe4a7f8e7fe2b37e03cc7db178dec02305", // 1780 + "68eef9ee92bab540bd3ad95df4a1a89f96d01b66", // 1781 + "ae2c0f57467b0aaaea8f2e3af64b8f9b0b4ea88e", // 1782 + "c5c92050a4d3bfdf7b44e9fff8a2f8d6c8a1ca3f", // 1783 + "1f3eea79ff5789b28d92898b67443dd9852c078e", // 1784 + "e826a984588a068f5c777cc9e40f99966d28e9e0", // 1785 + "11dbb8c4beac95ec3220e73e9518af91246848ad", // 1786 + "ea1402ec880ce4307f8d748574debf642f93d526", // 1787 + "534b79f2e2e765e0454d44876f19848151850d16", // 1788 + "4bfc459d8ec77152abae5afcfb7906271a1e5f9d", // 1789 + "69332efcf24e56e356157eec481de97da1bece10", // 1790 + "3ae2b858ef7746c0818a69145c435f9c0b695809", // 1791 + "7eff27d54ba85653e7dc1c2547a9fd490189d145", // 1792 + "68eaea59db1d5eaa07a97d9aa3643a9f96767bfe", // 1793 + "cc5ff08651d4d99a104b4ba733c8d553b7faa1e7", // 1794 + "6c46a6b7c88017921a8e2bd2236c7db4a7c3d616", // 1795 + "468578ebd44364d734822ab24258c3de068b141d", // 1796 + "5006e9916013e070db165f892bb54dc421927634", // 1797 + "560b60e2e442ed6d24f57880e063757fe7730a2e", // 1798 + "b754a2ae25f07664ff15832c6100cafedac1510d", // 1799 + "1af30cef525647ba33b2b14218ec54e262f94a5a", // 1800 + "69579fc8299fe2508ef5783e424be9b23d251386", // 1801 + "056ec9e07d58b48f016299a104a7f5a53cfef007", // 1802 + "2337a8ce057dd1fbb30a4dbba7cf1be9b7394722", // 1803 + "577a91a16101ebe0b5af0d60dcece68a6b11417e", // 1804 + "68ef6b1c745efcd6adfa74371d62613c223fd7cc", // 1805 + "a8dce2fe122fb4cc3e7da36c7a0a67b69247f86c", // 1806 + "826e5639239a3e10bb580a6ec5bc8e387f9a5e8b", // 1807 + "73ac16761a54dc5861435e48b68d113989c4e6ee", // 1808 + "c8e6f5d5dd9077e75a28b1ab5e94591d5d31db61", // 1809 + "f8842d8d1e0bb8a0258500e8a59fd99b43e3847a", // 1810 + "e659c2188fcf1351a1e2e6d81ef68319bf8c9f62", // 1811 + "2dd561e9e0290e44e2f000bbe1dd34eb73876d0e", // 1812 + "713cb7509fb2430fe038b7890de47c124c021f08", // 1813 + "7aa14f694210f5d821f7c0b316787d9a24c96d19", // 1814 + "9968e073b98c2c2d7376a71244e68372acb331e4", // 1815 + "13a9be0b1a3dd96e1fdf8db80f6c1d20a51d8449", // 1816 + "717564bd229a472dd205db6d7ba5c5cbc4ecad78", // 1817 + "77a59747e11a5873e4c6c05d7c3ed4813c5dd7e9", // 1818 + "c38a835f13e2762002297f8a948f3acb6d311c39", // 1819 + "7b96ef7d3693783c20a9647a83257655f5fc44f2", // 1820 + "495eb7fb939689540fefe29addb947c31769f732", // 1821 + "26bd19d7ac6e00b705b1daaa43327aaf3ad2abbc", // 1822 + "d3aee3f0e8a8de10590ebde23485d541cb8af239", // 1823 + "0aca569584c0c6cf9eefdb65abb616fb40fd2cc4", // 1824 + "9b55fe3ddb61e1568cc59c4fd07c8cfc4585052d", // 1825 + "e10cea62a79bff8d6c4b68817f77a01d0538eeb8", // 1826 + "caf7f7d790b88251da061d86f547ef75f88f7ada", // 1827 + "dbaeca2b88c0a803ca8b12e94a75abc4f3ae7983", // 1828 + "12d26f09c3e04d77221f9c5a184901f73a00dc12", // 1829 + "65d8a78e5e9ae6bb924bcd004cf7d0b9bf705c1c", // 1830 + "dc61711a579e52f77e59c473f6787925c9bdfdc3", // 1831 + "47d268661ad213fe0a0fa32eb1c760c9ca964eed", // 1832 + "da8331b5ca0e0ffc0098c8f6c6587030d6576933", // 1833 + "6f066e9a10327545b4241143e7755b08f4cc70d6", // 1834 + "f126750c73949ba4176de7550b3e428ff123b527", // 1835 + "3ad644a24b4d5ae419f722484492e7bdf363d545", // 1836 + "dd09d75b99b5abb304d68c5c756e4ca559327264", // 1837 + "bc16284e3c977f75f582678f446921910afc1590", // 1838 + "0ad71d73140461328db8df8a028952e314f5dee0", // 1839 + "c0f37fb2369aa24c1e0c3254fb65c69069101342", // 1840 + "0fce23b4e1cc3bb4ff39a665ce4689a72494b06a", // 1841 + "72098bbf1504132983de1ec7da0dd90628f5ef93", // 1842 + "e9a76a2af99beb2813e25dfe43dae7dc429a4dbc", // 1843 + "934551a8a3c90b031781cd932c4511e5824c9acb", // 1844 + "6c768f1e28a445dbfc1edddb964f9c78fdc39e99", // 1845 + "6029ba0f5cc92f4e2c068cfa71e1f1aa201d59da", // 1846 + "89427cbf8dee97c02a4f923f49f90b39080e6c99", // 1847 + "040eccedf0c52fe845d41309556bcf8baf58b160", // 1848 + "7587b3ef3e3e39675382286d0d413be9481355b5", // 1849 + "95600c88facc77eadd0f9a5c3210cc9c72dd4fcd", // 1850 + "3cfb8735e078c7d71a0750107deb0751c2248821", // 1851 + "ad41d3e10e65d682dd1763b9dac6a5707045ab13", // 1852 + "a27d8769e925dc69d3bbd604ff83fb9c247c79fe", // 1853 + "a805b95633e2ea5e8bdcc964e674c80cfa8e1760", // 1854 + "ca449d075eeda33b7cd36fc7c09123b2b52777a2", // 1855 + "ef777ff16c184f9b719faaacbcf0113829acae4c", // 1856 + "ef1b1e85a7a62a4bf10a0dac6babee38c968f8e0", // 1857 + "95a2e578099f1b79dd74b9520414e24fcfc1a27f", // 1858 + "8339993c71476486703e012fb7142ca7de2e9740", // 1859 + "541a9d12e82e5652356374a6b3f9afb9e4eadee2", // 1860 + "d5140dd1912f7a6c7134dcc1099d6f5dd3bf47f5", // 1861 + "ced645d6a63c3d1cd1f9245e10c13ef55385b5e9", // 1862 + "e0d246fbf685ece51f66bcc908589ee80c884f60", // 1863 + "30ed55dd80e2acf89dcb9e24101ff7089270fb34", // 1864 + "c4550e234ca963d548a0e3ec6c46e4f858fe2c79", // 1865 + "ee66001cb6e954f5c101491e7c6e036844e558e6", // 1866 + "1f2d3939a8fa33651257d75913ff2453e086dbee", // 1867 + "4345484aed6988d7fb6a2cd3af77d9301a0b9f9f", // 1868 + "b9aec44aef59c456e0e07de0b4157ad61929fcb2", // 1869 + "5e97e65a042a58e4c3254df997064eaa9e6a0daf", // 1870 + "50ce244a8dd2584a9b252c88df946990ebf6a1cc", // 1871 + "f8fde4cb6408bf069cd1ee3bec115fd3b6b8d65e", // 1872 + "ddc81414b4aca5c423b71536a1796f9ec838b2c1", // 1873 + "f73ef6a2aa8d3516a7ee444341885e267e5526a2", // 1874 + "f66f4ff929ede8af5a7470fb42fc68f995852369", // 1875 + "66e962e45f827b914992337a2d5ec80565a34c9a", // 1876 + "74a12c44fadb24b0b5b9d4c78ca1c29f6e812a0e", // 1877 + "9a764ec28ef9c7b714547342c50811240033985f", // 1878 + "c78257c23f9ac6db241621926e9b0b81043739a9", // 1879 + "89b6e826723954e299fea1bc476fe59397c61e63", // 1880 + "849e8b458da23ed75c2d679dc9bb9ea42e53a7fd", // 1881 + "1ea68bc9cf736bc2577a863cd129a918ae8182f3", // 1882 + "fe188bda9218e24c1b12ae71eedad425f38421c2", // 1883 + "fc0353243435032024ac9f842b510a93e62e5f13", // 1884 + "e7c9fd3e1f88b7f73075b528ff7325bae5f2cf1f", // 1885 + "a5ec8b2b4bfdb9799a00c00bcb05540bffcaef35", // 1886 + "cb3cbaf841f7180fddc94a4a17eb84a035aef2a4", // 1887 + "3e7c19e9f428719e2aa0458c7b47727a783e2a2c", // 1888 + "99fa5fd6bdc24e59bb6beda34a7d0589a9d634f2", // 1889 + "d52a7a3f9ebb566d3e179d873e41e76f16ceaa83", // 1890 + "fcf7bbb6b1dbee572992dd2715f3a505c2749b53", // 1891 + "2fbf619b0403e3c6c68b56894137813c7efba168", // 1892 + "5ce431d62d129b0aeb3fc10fc7efa085494a3ee3", // 1893 + "0b0a73554bdb0975ecc820fdedb67d03bcf8eeaa", // 1894 + "f837891e4ab21bc5187eb2b0e580b6ced458774a", // 1895 + "a71827dd34e30efa2b29d431fcb4334c11c2e722", // 1896 + "231b1a6539b86e5778bd5256ebf75b26796663f3", // 1897 + "e75771e133a3ced4f3aee99f991bceac937b4ca0", // 1898 + "e5ce48f6d0a61c4064e163da9c28496b49b5985f", // 1899 + "fd52e957a4ea868f06e845b68f0b1cd3476bb4bf", // 1900 + "31c723878ced38506f522cba3a218fb186cf6c32", // 1901 + "19bc3dd6e61acdb410d08221cd9edbb97b608de9", // 1902 + "1c25f6b474a7958284a21f9b5fe9fdb7ffee13cd", // 1903 + "83af3fae683b960644b7ab258ad25503642ac14a", // 1904 + "651c14fedc85bacf7f50c12c2d0ee194583a5cd7", // 1905 + "7caaf3db99e289473c53fd6667438118a2a1c10f", // 1906 + "8ebd9552c7e9feab8785c8c9ec602800676d362f", // 1907 + "ceb12f1b2bbfc8e7a7450b6a008a2f68bade1742", // 1908 + "687a4793e6804dc723ddb8d2c13b6ee05151b13f", // 1909 + "0ac4801cad6f4eecdeb234a0c9991a364eec96bd", // 1910 + "b8186d1984a35f0b9e093879d270d74b7eeec30f", // 1911 + "b5204f48c59d44a8255ff95ec0e1370859bf31d9", // 1912 + "79f4202d3c9c26175ea6d7081d5fc7f461a280df", // 1913 + "2c4e7ca17c1d2114a17c42f5299c556132870b04", // 1914 + "4d08a02c50a73ec6b5c4e261c7ea8e3ef5922aad", // 1915 + "eb0f10219daac7c6b9f44817b18a2729a4d435f4", // 1916 + "b344f46c691d6289352ae80072f9970fdd5675ed", // 1917 + "a3653357c5369d7b95a2263b3cddfa496aae7fa8", // 1918 + "3064dc2768e0cece89a16b93db0a204df18dbdf6", // 1919 + "53f48c2f83d37400e1506852dae616195e109154", // 1920 + "356d3f6ffe4235246bb25ba6ff64949448ac5f8c", // 1921 + "6e8467bb8f4b452a2f86c5959955bb2f3e18bd5e", // 1922 + "acf9f808bcac53d2cb5fa9260f3fde314976a277", // 1923 + "66927dc599707601219a3241c790fe8dab2eec48", // 1924 + "d44870f300e128a627ced295e1f91457e53030b2", // 1925 + "ee7722a036edb3314fabfc1880bc3389291c48e0", // 1926 + "f5c545781412c11946eb96cbbc7aa337633b7c5f", // 1927 + "a95e110a80f3706815824d6751f61f157f04c28d", // 1928 + "077264f07845182a01cfd5f73240f1db69220112", // 1929 + "a641267f5cec265b057bfc670ddb711ad3ccd5d9", // 1930 + "0f474cb23eb6d800d1f38be7e1506493039022f9", // 1931 + "bfce3d443fe0062a59f813da980d4627f6137ace", // 1932 + "eb9f528b1b19cb639e1f25075a880467a5f1c8a9", // 1933 + "fd4e80ece341dbbc7511c0e08c084029ee8a02b9", // 1934 + "7a7fbd7676118916b0a0b1c2ea6723b5772f103e", // 1935 + "791d535f8a1784bea43f44e2fd7a9864e95e1c17", // 1936 + "ca2aafba38c393521d6049e6b07bde99d5f63a8a", // 1937 + "03bc4344ccbd6572477a54268af8c6eebdc0a857", // 1938 + "5a24802bcd1f3071b34dbbb79c67eb5e44d8b851", // 1939 + "bd8f6ce5e66141a7a4f1fb900e273cafdc075cf4", // 1940 + "759391e8bd6038430ffabcacd4194e2ff1c931d8", // 1941 + "eaa02c00612d6b8bb3ad8479aaf41f1fd43dd6fb", // 1942 + "fc13035fd962c61a4e6f25c10d790772548670c0", // 1943 + "5c32fcc92ca8800396e45c20c31726fb08f44c02", // 1944 + "9055932b1de7e0ff1e9f247250e2f80cd9351d7d", // 1945 + "dfa4510abb8cecbd601f4d63ba6b8e5689e82078", // 1946 + "3a144496b8a533fcb4965f2b041dec064cab0347", // 1947 + "c3b909477b1b09044c7643b9af8914ec5eda1ace", // 1948 + "3afe6515dbed9a0f2f3e3adb0883804772222793", // 1949 + "7201e7a50f4cf1faf57027697c6106b1dc0651e0", // 1950 + "ca7185b6a8989cb103767eca83144d4e4749aea2", // 1951 + "5c288ca0a7624c470372e58b8bbff2b88a21d129", // 1952 + "7e94dfae886b7a8f769631e4c4c408142a7816da", // 1953 + "9d6e638131df4f0b662bf8906681e102527bcfdd", // 1954 + "965f3b43480744c8d520f39e6e9f82e6f71d2ae3", // 1955 + "f40c2a223fb90b0e83e5aa9177a6030fb715cb64", // 1956 + "ddc8d0d54d82e90b266674ce54c4c5bf967d3fa7", // 1957 + "60ef788b2a70e2a3c9c5e558cb4c6c8041ab1ca6", // 1958 + "a5ea5e21d2a442d024ae4b48cfeb88e708b4d291", // 1959 + "49d98fb5b412c91a420eff4dcbfe3073fc2092e3", // 1960 + "3b9d04b50ee366f050ae96e453f79812abdf9d06", // 1961 + "5a66295590fa4a133d9ccf7f4aef18bec4def41c", // 1962 + "a64f4c434e2579c8d440bc1eb89eadace5676649", // 1963 + "b76d64045654abc90736d506a65aa1d3d691dfe5", // 1964 + "91ad02a7522168de939696cb4b5735f883eb7b7d", // 1965 + "3291148345a54dc8b5a2523fd161e2af61042f9e", // 1966 + "38c5d01ad3d6941ab1ce411db80ca88f9635f969", // 1967 + "6aced5b6dbaae2176cd0a5d298cf903a17c10268", // 1968 + "67b09d1a0914ff36eecd08a844830022d97b6173", // 1969 + "2b87dd42e96ddda2e3825fad31d06e8b427ae642", // 1970 + "7e1f071ae5219db6c16fcb753e239db0c16177c3", // 1971 + "84dcaa3f19efec61c84a43ce29c26f8a14c4949f", // 1972 + "9aa3996c893e1fe34dc3e9605b5a8305da1a19ad", // 1973 + "7abefe971e22a72e504ddd2e8b54e06f0420c8c2", // 1974 + "60f57c87009cbae18ada6d5c4d86521d0ba659a8", // 1975 + "63a9b768740e79c94b285271f199b880f1d06909", // 1976 + "1b56d6c196162d6c55daca26cb9b140207ec2ab3", // 1977 + "70059fc3f1130df96214e2312ae8559b779db91f", // 1978 + "550df6f635cb9ecbea05aac8d9b754d41abe3e25", // 1979 + "7a593d75a51ffa37ca64b2857bfa0e8f7fabab3f", // 1980 + "57e124f2b9aa2435d92d2740e38d7101f3b80253", // 1981 + "1718638f03771931e3b2d424ea25dc19a2f2d4c2", // 1982 + "9517ebf1345bd968d83fcec7571e1e3b8adf8d79", // 1983 + "fce9872e777b4ffd77ae794c4b31c528c5ff4792", // 1984 + "0c4a4afbbd0ec3d9f89bd4f05e33a9221150cc2e", // 1985 + "462bf4a985b3728f54a28945520b81a0dc8c9f19", // 1986 + "69510b719b1de3f2a6c742f4b4ebc0f440f231ee", // 1987 + "b1dd38826a43113110e392bcac43e3028b29f334", // 1988 + "8754947b3f50ff0893872e368fa3a23063e051fa", // 1989 + "8e0ec3c81db6999bd368c183227fa0665f6ec4e4", // 1990 + "28d610786c7849049bd58da8a3db6ce385480c11", // 1991 + "d02e9df3bb2f9763e418983bccaa623662027dbb", // 1992 + "bebdb45fece96bcc095394f2e6916d0949cfdf17", // 1993 + "24bf898415e9905b8b125553596e79b4c66b1632", // 1994 + "9b3d17923373c5d74fe7345f18d0cecf87d586ea", // 1995 + "32bbf7e461a0086d80ac425348a877ac768bd52c", // 1996 + "eb8025a8f5bed2914fcc4a101d67d219dad0152d", // 1997 + "79fbafc4e2eed0b5185c4f4f2a4ccb58c2d682d2", // 1998 + "cfec4028e12d1aece2af816e3f2c4555705e09bb", // 1999 + "059c294680401882abc912771fff152ee638ee80", // 2000 + "a4895848e5974ab3a979137b5ba1276b89a0d65c", // 2001 + "5a0e7b1e8f7e1d8edfb0464e4f894d467abf627a", // 2002 + "c064514f9fe1fd1e4c21624b5f7699d57a524283", // 2003 + "a405563f8db6dc3d1a83894e5d02d1473e2863bd", // 2004 + "929b981d9af6b7ec3b535b7e506594d60c9d75c5", // 2005 + "33e4531e3ca72d6c92f64e0033bb3520202c2a6b", // 2006 + "d0d979e90704eb002113f6176a8d7dcde5eb0eca", // 2007 + "ea48d7e160e1a1c82432aad0130deade69aa2838", // 2008 + "984acaac786d123c0c7a144151e98afa2cab5abe", // 2009 + "4c9a3ad59662c3da1e04a4f49415245003cfce34", // 2010 + "6c58bfe5bb4f14aceba6d673ba0ddded145c4325", // 2011 + "c2721bdf2479ce65320f357da6cf9f2e2574d497", // 2012 + "e78899cba3bac05ce02057956a2d957adc0ace39", // 2013 + "b5637d96b1ace807b528ad8bc23219172a7da03b", // 2014 + "743c6ce16366d709dea5b8f089b92b601cc98142", // 2015 + "d06069f148753543b85f4bb7ba9a4e9ad0216cd8", // 2016 + "85590cce518664dd92be7c1dddd3c0c2f8aacf06", // 2017 + "bf9492a5cf187593d6365adcb9fb12e3ac138385", // 2018 + "a79bf6319da47cfff9382bc2d448a60a06a5e4d5", // 2019 + "7cd907de4172f74534eab18e14a4bdc275ca635d", // 2020 + "0e69e127f77adce4b395f0ac780cc754a819b01b", // 2021 + "10bc55a36e6460655959a46de7f901661fd30cbb", // 2022 + "2e9cb034721dafbbcc8ab344461f82f74957ff41", // 2023 + "c4c4ac0f1c505fab6ff889a2c0b7a287b2974374", // 2024 + "84defcbd5e8a240b4e4fbc6311a9ebc7b6352b6c", // 2025 + "64d7a8e514d85c4ed2141674d85473d37a8cfebc", // 2026 + "1f589c75a37195e0133ec50f2ec491c50ff8c229", // 2027 + "4f0424e0133d5a799aa510c1ef827bbfa40d2076", // 2028 + "940d73653701f7ff1db788d8685db451f6ce4176", // 2029 + "7112360cf17609cb122553e22ad8336176040ef2", // 2030 + "c4c991a0d42f3279e832bbc191d1834338f51b72", // 2031 + "ae869fbad7ef5e6ddae4a73d5b8c471fa8cad72d", // 2032 + "7f778b2a0c44d78808d2493e16b274f280209711", // 2033 + "61981fb66ed3addc4840c66be52ad83f719542bd", // 2034 + "256253b3b7ac556eeacfa4af0b2800015c2de23e", // 2035 + "b92edb8d4ca37a512d2e2b501d770748f80d4b34", // 2036 + "0218ec7c7fb98a76baba67fd917ba20c659e6c8a", // 2037 + "ffdeed08967ec365ed616c85fb4ce308fa16dbed", // 2038 + "be39d2e77bd249edfdf779792d847ba98c99afa7", // 2039 + "5976c29c7ef926b8809fff4fad1f5d21a1a98339", // 2040 + "b8e0bb29946bacab96fbb5cbc5c50f99f3660b9b", // 2041 + "e0c38052a6f6beba4616e9212db2c32e96320d33", // 2042 + "1d98d5f9f92f4d9021d46678f2458efe46d99500", // 2043 + "e8dbab8993e612895cc314ddb971db4c0aa13c59", // 2044 + "0b8c1e88b6bd590a69e5295f157d10fc0423379f", // 2045 + "e2f9f011c7be1b4757abf54e23e39bd877c87340", // 2046 + "e14a3c2243dc05a9e4b690add3d4fadfb51b4a64", // 2047 + "dc28591e574d1eac79ad07b7a4d01d5026a02428", // 2048 + "e2c9efd6d15dd93800100f4f0555f1f100e14963", // 2049 + "1ac361dd88cd65ab7ab2807f3b291876ee9ee2f6", // 2050 + "3adc67753a3d2f3b910f78cb9342be67c2bb602c", // 2051 + "3bc1204cf2b81ef68e606fc542897ea9da99691a", // 2052 + "374b8e8eaa44162afcfc1f198cf0a24233057261", // 2053 + "40c26f2e46c97abd1ffb9b9b9163ac2dbb094a0f", // 2054 + "a411f7194ae7311fc10de79d6b73827680e8f820", // 2055 + "2bb8c0ba67c805bce65525ef75d74821bda7d448", // 2056 + "20e9c3a01039cf6e75fbb5e1025fd69b961e976d", // 2057 + "78a4390b7342178d55439f5318f4fe253dcc90d0", // 2058 + "e26896e60733e38aead6f3338bc6cd7c7250f424", // 2059 + "991040dc378bbba9821cc1d1e5e0de8c72eb0cb8", // 2060 + "50ddfe662133078b1ead273d2ba2595bccd3aad2", // 2061 + "d29fbc49a291688843fd9d3916bd996ce505e8c4", // 2062 + "740653ff2dc4b74964e4b9f3e198bd46e65f1a5d", // 2063 + "6f5208006c952a7577519e41bc64715d5a9e3cb7", // 2064 + "d53c8a01a3fa10af3a56968d07811a9813a313a9", // 2065 + "8ae830172b9b9d3508893d4fc4086b84322602bd", // 2066 + "52d2286c79d10e19410ac01d1554498e0902a054", // 2067 + "00a0b722e9455a2ab65b8dc4fab213da7bf14d6e", // 2068 + "de207fc2e3c58b6c870debad4f365bc849e668ae", // 2069 + "bfa51c9ee0a1a969af50bded5c0038a1a0371cc7", // 2070 + "93a0fa8e08f0af874dbac435d3fcdca81a1df389", // 2071 + "00002b9dda9bd9409f4654e4bc760daf46512a29", // 2072 + "73765553617ccb862043189c0e863acd7849fa2e", // 2073 + "ff4bd01c80ea3ddb47aba411d1a16a407a603cd8", // 2074 + "a5ebdb05e30053c3a4daad8e8540c45ffc8b3c4b", // 2075 + "9652fb0fd226c94b4ca9635764c56853d8b82986", // 2076 + "a83f2b99e06caaa479b11d9109cb057a581ee2f2", // 2077 + "75b210b175d72424b9193b37f1c39eee04ed28fc", // 2078 + "899bb8c31537a334121aa3d341aff7061cbc7ccd", // 2079 + "627bde04a2ef992bb89a6e7c93e3ce24e63ae635", // 2080 + "5a6fdb558fc7e9c885395c507519233215b6864d", // 2081 + "b5ac8975af9fdf9c29d3a99ea38f4beec419ccca", // 2082 + "996c9574d142a85c107a3dc978a628e0bda333d0", // 2083 + "f2f602d9bca60301cd822d31189f9f28b7cbc96c", // 2084 + "61d90bdf5395fb06def00c464f76a4ae6d5e8c26", // 2085 + "7f9ecab16e1e4668f9b78d7f6be0057b0bc02942", // 2086 + "df36c6ba2433f281284b864caee84c9ada20f4af", // 2087 + "8891fc797cc427c6923f2b86f969a01fcfcd7dc9", // 2088 + "9bcf6d76b08ce82d087ac75867e9f198be5a5556", // 2089 + "6ddf8f2b4a20920753872cd03ada9ec35ea80a6e", // 2090 + "d5dba604edad0fa4f11cea1cd2ceac63774e03eb", // 2091 + "8ceff07fa16a316c4bcac83d7439e2b72bb0b5cd", // 2092 + "b95a4dc272a00d0cd24a66906bb2e59e0e013364", // 2093 + "6b519d8aec2b36eeef3f4ead51766d9cbcb037f7", // 2094 + "e2bf694171d8bb5e602f850a70c4235dddf65ea8", // 2095 + "48a5074db3f593a9f697c85909262b098be6a3f6", // 2096 + "a8009b12513778585e57019d9f466dcc59e0cbcc", // 2097 + "9f0842b9b4668b459df0dcc785cf0ef1b1d2100d", // 2098 + "30cb1996144792f957f408c22316ed57c1aeced4", // 2099 + "68ea991331e8ef69b474ad846a3269eaf6407e02", // 2100 + "aa685bd22159655a1c9dd630083a0a38105a82dc", // 2101 + "5ea4bf3b7d415db7851005c324ddac0aab8f73d2", // 2102 + "488ca66e7aabb65fb17dee5572135aec24effce8", // 2103 + "2466b8d3c7460905fd1a0fd435c8db2ebf1c62ca", // 2104 + "ef13288c6dd6cab4640aa11ce1bbd3c562419cf3", // 2105 + "b739f3e594e678b6df489974f593303a84f86ab6", // 2106 + "f23f998955d49a1aadec75201c233f13b1677d6b", // 2107 + "142b820aaf16c9869dd9bfd2b7f46de7243b7ada", // 2108 + "0b3cb9442486937b010015025424d0e2a0847a13", // 2109 + "94714a753331c8c76822f70edb6d0f2914e58e8a", // 2110 + "cb55150be9090ae02d2b6915a0f209f60c20965a", // 2111 + "37601cfca80e43a316f75422f4e685181e258944", // 2112 + "c3690da5ccc85a69f4d7c8b6b35dccd21dfe6871", // 2113 + "62d901737b9b1caa70a7e0107d2641c37773d2a6", // 2114 + "439ea7a6f067c6c6c6e35875e40f55755ed735e7", // 2115 + "c42f4e6abbaba45e1a37fca66b857f90f25b2a6f", // 2116 + "b75d0da6dbd3a74ca881e1fcf47cef9965e4fa4a", // 2117 + "a3696240f4038ca913c6635ca95c60242deefc3d", // 2118 + "74d0e0a79cdf8f1a61fe43ee6636dda8a2322268", // 2119 + "55137388465f3a0d58bb19e91a652b3bef3ee242", // 2120 + "0be3df6f7d046fff89ca37a0861f18c3df0109aa", // 2121 + "8a737fef8e8f92ab63b2e0039639fbbcc9381d68", // 2122 + "a05cb9e1f9631a5e194721d1fbdecdbdf2805b33", // 2123 + "00900624c347f445a50886478c5017adf29582ed", // 2124 + "4c1d452c1960a42d9d89d8ef5aba0d566d17feef", // 2125 + "052dabc7b2d54417a28c0f53202f4796edee6101", // 2126 + "0b8e280b7e313cdd7ccd6bd1b3f0e85e54d8ef7f", // 2127 + "543129e8bfac5c7bcea0a88384eb6da816d86459", // 2128 + "5ac30bb793d9be87f4849377f17313a9766ce8ce", // 2129 + "4a944fbaf493e0d283b04e057fa67309fe76c964", // 2130 + "961a1737acb18e42fb09309b63e0961f3aea9cd8", // 2131 + "6589592a2af99d311b1dfa50845933a235389099", // 2132 + "3be457ccf28afd0f2cee5eb10bdeb8f4a9801dce", // 2133 + "14bd2712ccea2c0eaa1afaddca8928da1dc18a7a", // 2134 + "7d075eda81dbf888e120db8e28c6a6dbdb35478c", // 2135 + "039560a10fb20957f9d39c9b5c9ad4b5cc4200c3", // 2136 + "a89977a341d57471646187f08a4197c01b5dc740", // 2137 + "0b6c96b8abbc043289fd78554b04430bd0acccc6", // 2138 + "315221da7d206d6e8e3945a8f556d64ef053ad6d", // 2139 + "dd8290d8025b990a5773235b8486c2c9c368d077", // 2140 + "ca14df8564f5f55bd802d11572491a46cb671af8", // 2141 + "824eee609ee719f447e0de2b520132a1e6224891", // 2142 + "bf657c070664392ff5b1d113aa4b47898fcec306", // 2143 + "e0203305355f3e409f68da8eadbc3d9426ed4327", // 2144 + "cbc3a1e07130bd6f93452e73620fd09f49092327", // 2145 + "ea76d53d7ad8758227684e1a204ae0d36ba28466", // 2146 + "a87655633c50c138cf18106996ec813b347235de", // 2147 + "a5466cff88aa6cb914b81a323c597ebfb48a7cf7", // 2148 + "ce933539b32e42fc61c30efa7411ab4bffd63698", // 2149 + "2d636ff8f9c3abe658d49b0ed081a41e67b11d4d", // 2150 + "0fc4c8a9683027409b902b860dc921eada691284", // 2151 + "46b04263caaea5eb7b85ef76ca71527e57d4fe9b", // 2152 + "022de9b5d57e7774610e03474fdfeed3ad5fc1ff", // 2153 + "eeff48f2f9afd875468e86f4f81cc2d34a86d4d4", // 2154 + "b55e6ab6aa1dcbfedd141aa6423ecf47490eacdb", // 2155 + "8ee6edfe35400bbdbf6200c928784180e875b5ea", // 2156 + "0d5aea4e204a94b3f3a0fdb5bdc914b8875b045d", // 2157 + "1a1994b5a7e41951c495724656c55b8516dc50bb", // 2158 + "45a2cf1a36fdd3d51ceb44179fba4469853ce957", // 2159 + "d69066b42690440664121aac7bfb827f97c60657", // 2160 + "e6ef0ea0f042d48eb923b1d5984460a3948406fe", // 2161 + "8e4bbc54fae8111008996408f6df12160105209a", // 2162 + "91a063d1936080c6a5a6cb4bae064f1583df750a", // 2163 + "1e509e98671a2df98d56e12e8116dad1124175af", // 2164 + "c034d57dfb00ef4a7ca2a0059a0ac592d28ef46e", // 2165 + "5079e8b09663134aa1e27233c591c4144d9a9412", // 2166 + "cc1f56b58ffc50ced92d292803d0952254618ab3", // 2167 + "31ab0f14ad938fd772ce3c9240d0ec6994fb00c6", // 2168 + "c002e00fa0abd7f8c39930ec025765f69c22d4f3", // 2169 + "c5e7697b2a30a6e7a714455f83c2e331a76b005c", // 2170 + "ec191717ac4785a88dad3a77427e546c63a0e11d", // 2171 + "5aae790a4ce8ab7841a9d17f4fbacf1cd9727469", // 2172 + "49716692189d7240932f0bf3e8b9eb7c9c91ed58", // 2173 + "5b0655e2611570742fff016ecdda1653115e5822", // 2174 + "aa137ec6452e6cc8ce3a03306957f8b38d15f023", // 2175 + "155f753a0f0b6b0ce9185255cadee446231c1691", // 2176 + "9d0dadb310ffcd8eefd6057648bd24308580bbec", // 2177 + "1b3ef31b21d7456a664d9ee407897c2481eb4462", // 2178 + "99f550ee43a0157282f9138a2e69efc8895a9cf5", // 2179 + "c2adcc2bf4ec08bd240f658bef6189bfe00c0526", // 2180 + "8bfec64bfb4cb857d9500c7b251984ee265db2ca", // 2181 + "28248e5d5208c6395d451bffb8906c16e7803137", // 2182 + "202797d3ac292a5d2057a4ee95d5418e03004b8c", // 2183 + "4a2e5deb92e833ed51d27f92fb2ed18e0498ab8e", // 2184 + "bbb5f1038eae2a10f675b5ab4603f1ee6e855b66", // 2185 + "ffae2ef76b3fcef66992e49775be62380c748a81", // 2186 + "d3d91a53bb912021c27e148467405444a53e4714", // 2187 + "65798ecd0e19a0d20d6c06617f9b55e81a0accd9", // 2188 + "8b16350e90c23975c6400026a18d04f281b49218", // 2189 + "b81452d35d51984dd56aa90be626c8fcec1d7af7", // 2190 + "2f68511873d3e74d9e9da78022d966e3914bf8db", // 2191 + "f31467cd390fa0540a7de59c9811dc929ed76dbb", // 2192 + "b7e5fd20d914fe638d5b010c311a8961bd843cde", // 2193 + "e312dea2b7cd476978fb150c1d844f8dcd69c90a", // 2194 + "354fa70b5e65e23198cd689efdf37a7240b5cdf3", // 2195 + "596c45ebfeb06218a09d37df4ce3333088acc4b2", // 2196 + "aabb70c8d65926be7c97c21144b60e7a2b617364", // 2197 + "7ae8422d7d80afb2867bf1d135cb277d33882f8b", // 2198 + "8d66a0b0a51dedca7e2965550c1851673191742a", // 2199 + "36ce2c5dc438ea1eedc1d75c034f0390eeb13697", // 2200 + "a6f9e42cb5bb62f100314bbd5ba7ff703b256e25", // 2201 + "d11ba07abda6b4e8efd937317ed8a6a9286b7fbe", // 2202 + "975b2a71b5f6eb6e43c5c7ef2af3c35d5b110a60", // 2203 + "57c8e1eccacba91f19f2a89b3b126e1c47aacbc4", // 2204 + "623ec72f2ea18797bdf8bf9478a648cc45ad118c", // 2205 + "030ed9a84b0b1c2a125d784af9ba0d229a003700", // 2206 + "5cee8082ba0a85c028221281a151ddd0f305147d", // 2207 + "487a38b42c27430cd68a75f4d649750ce8a22856", // 2208 + "0adeea26ad27bc53f3c2d6e6a9c9f4de4320aed3", // 2209 + "2a4b4ffc11b2d4ead9109b2c0e133295be0f0739", // 2210 + "fe44ea8dded656459516201034c5965e1a5e039f", // 2211 + "2db0f4e09d026d7fa93a4579da9764e954301540", // 2212 + "fe444f351c5dfb81e3d854aee59207329d3abc80", // 2213 + "62d62674e923a4cc45b400da1b12f4272757e11c", // 2214 + "282f1a36f34bd54033b3d63876dde6386b645b9c", // 2215 + "dbfbc6a88b2c660e3f9e672625de8da52e39a482", // 2216 + "6c98c583689e864f6fe95064bea186e4faebc449", // 2217 + "df0b01a73186a06d6aafd518095c462232d2de2b", // 2218 + "819cc0a6afcac6f1169559ef776537e1fb56c981", // 2219 + "8b1103f43d2412a912fb11fb6e4fcf152b5bab6b", // 2220 + "c96b026f035faa63654ae788ba97f6968b1d6d51", // 2221 + "063dcf34f3cbb2491d428459cd3c7e77fe5155ae", // 2222 + "c17ac562ac1e47f226ae80edbbb80d8a966d1ca8", // 2223 + "0e0e2c0c8ac023123d6544ce7f5dc5f81dfb145d", // 2224 + "c339314af6b2622c1741ce29ac1b9f7c969d663b", // 2225 + "6e9e663f782d7bd88c099f6d363da299de9b3fe3", // 2226 + "30c5248b213b933b8632f8d04571d61c522e886f", // 2227 + "501171244b27604857cba6c384d6b8ededac7ddd", // 2228 + "3eacbe3480e9a1862851df91a1d7945c1837e4a9", // 2229 + "44382ab8e8a3207ba9b79f2419529126b4fd237e", // 2230 + "079d8cf1e61cdaa18be0b3fa33beafb0399e20b8", // 2231 + "093136402d6bc976ace9175b0bd8d830c5a23278", // 2232 + "ef63f9b10291c5f54e5657e20307cb237059e56d", // 2233 + "232fc8aac26215a30bbd50e9f12383dcce7b8a0c", // 2234 + "613076cb48165ee3471819f6a746b7d90df42dd3", // 2235 + "f928ecc264afe0c4f4472cbbdc4793c0a306033d", // 2236 + "aeb8bf8aac5c28d555a8b55772e3345f9a33eee3", // 2237 + "70090ffa612dfabdf4f45a0f5dd97f81a53b5ca1", // 2238 + "6e3b73ebdca3c874c25d7993aba013b926263998", // 2239 + "74ae5d256f272bcece897de74e7631cb355df0b3", // 2240 + "d4a6c4e18889fb2d66e0733c0109edc5706f9018", // 2241 + "640ff5a4481040175ba83f64d41ac5704395f0a7", // 2242 + "57ed646640bbd89beb8916c41d678a3dcc652c72", // 2243 + "e3820d0580a0e7aaf9b6ab3b834888b70aad4d8b", // 2244 + "efd86a096d67052ca3129fc01b21ecd8893ec5b5", // 2245 + "1de2de032ec8a020fff87842f95f488c572d3d0e", // 2246 + "505db5a94f4a85d012a4727677a1098d2c00550a", // 2247 + "5d00ab1ff0bb3427f773bd3d2ae12fc6de1bf11b", // 2248 + "258562eb672c58ad689aefcb75e18646d4615b7a", // 2249 + "15c3058f1f16afc7810b5e0324642c2929f5029a", // 2250 + "d2672d217613fb8ef6930bf66744fd4c74c0c51f", // 2251 + "88669ae54a762847e9b43fb1f49f4e7d2dcb6aec", // 2252 + "453cdb68801e364745acd1aaabaae60186ec5e01", // 2253 + "6c2a464a9f355edeeccbd28535a1058fdb3fb48b", // 2254 + "f1bab5f7ecd6127bfccda9e684b92baff67285db", // 2255 + "b333c180ac3b3af3bf4ad763f096ceeb7b0996d5", // 2256 + "7bd4a209383e424856ab43487830f50bbdad1909", // 2257 + "1a72768f369a1cc0eb32b5d8121ac2d815c1e53f", // 2258 + "e08d0686a181d7e36bb4d1d3259bb2a5dc24802b", // 2259 + "85850552df5b2e87ebb9bd7ee26f9719e1f21c31", // 2260 + "ab325459e93460aacbb7098e7d3b28bda80f77b1", // 2261 + "e93e60532d1186b63670bea61778a4355b82fa11", // 2262 + "d3873f29838037830bde2aa804bf704f9beaba78", // 2263 + "a9a56805dad0a47dfacb3114bda20bca9e0c97cb", // 2264 + "63d07f9d0b3d8a4a7f1e7456f4588f47ad084172", // 2265 + "488af3c467c3df49a82beb4fba8bdc0caaea96ec", // 2266 + "f573d667f8df720e2bf5a43e95c978e15a55b9e0", // 2267 + "bd405c99ad27fa40267f8c6d00f46def90d6f98d", // 2268 + "ebb3d7db161320708604fc7a8c4e086765d20fd7", // 2269 + "8bcc93edc93e378cd9b95e7564d387fee68ea278", // 2270 + "bc36099198cf293bb8ecaac40ae65210cbf36217", // 2271 + "86803ed719348d414c1e6d3a2576f56205cdaeb7", // 2272 + "dd3044d04464f735c719c6f772376801554bbf95", // 2273 + "658d3c3ea036252c8d91cd8778ad5d77cecd93e6", // 2274 + "d5681462ede5b0c2ee97d4c2a1e4c78dda660bb4", // 2275 + "df1d482a1b72fa5b00a9805029a7b727c9a4566c", // 2276 + "0250c7692a098c4738c8cc474998b9d7a9c42dc3", // 2277 + "ed1a1c87ddce103d45d947a9a3f03ea43479e833", // 2278 + "fc7fb7a5caf25a48c4f0fccea788c81790dd9a37", // 2279 + "6eb53b797b5e84817c736f1c3a8253e8bcdad0cd", // 2280 + "dbee2e91e789a53250f884abdc0d1ba8223ccf73", // 2281 + "b66db6902a82155c393db1422610a95ba5ecfdeb", // 2282 + "f8a891abb8be5bfa7cc22f1307d642a1116b9eb8", // 2283 + "532a24b8023941e97f8fd98737a10a594ac0281e", // 2284 + "9e9b798450fb3e5132f2f342d86c67d4208d9780", // 2285 + "2012241d47ff23998f77a4ed3645e9189fa36324", // 2286 + "9e13740cd3d18b4057700a6ec032094c50eb7ce1", // 2287 + "c1c790bce7648aa9199173ae58de7f15997b5dc1", // 2288 + "024cf3cc2a2f5bfa0f0f4c1506fdeb9e4348e4f2", // 2289 + "d2886f42848c0a7c660d2541e3bea6d88f71188a", // 2290 + "d3afe52cc81057db67093906a3447a4fb03e7983", // 2291 + "133874f1f39b6c99079c622311501d218939141f", // 2292 + "31da998e043b73c2f478b24e45126e35ee1bf84a", // 2293 + "185e556e97263948366901aff27ceb55a1610e3c", // 2294 + "f90cb85aa7a6af28f109b7a6f9d7d7dd64a390a2", // 2295 + "321fb57d1e6e93a06c1f65fd637b4a7e636e4eff", // 2296 + "831f7d38a42a208b1cf6a22d2a2e5dae2e957da8", // 2297 + "de0118ce8c4ba27a5591b5d47a6577afa9d0822a", // 2298 + "aff03d7e5716bbd6dcb23a8ac4d72d69dad6e1b8", // 2299 + "4501d365109949222882d6eadb1ef3fedd5fa02e", // 2300 + "905b966af82139a4137ed85c736da74169988e20", // 2301 + "47553a0b2ede3c2cd5c391352134835b268eed92", // 2302 + "42a68042f9e85a5813fe6b0bad3da2addf74f029", // 2303 + "70c70150fe5ca1e59a151905d53c90b8aff480e0", // 2304 + "8a9540bfc97238cf7cc570479d0170c5498266f1", // 2305 + "212882f3413121609b93e3b7bd4d1234ea7f1c95", // 2306 + "4c01252d0b66bc48255e6358c12b672781482488", // 2307 + "b50ea3d871e6b26b44f06790e31970092afe1d52", // 2308 + "95a425fb422d891218bef313df23f155c9c19036", // 2309 + "c39afa26c3427a39a69932452f223c1a7e899d6c", // 2310 + "21599c1759d3f57a941577538102c51cfde08847", // 2311 + "221b62c3a22c7c8be87580cacba941d0f303240f", // 2312 + "92a5aaba3c3a51cf717590d532802aea0e15f4d9", // 2313 + "6d906d0352c08aa7c18e8d066a595184dfb1f2a6", // 2314 + "418ab73620dc46d005003149d37340de7c31d435", // 2315 + "2673ba035a59e353200d74a427c2d2e846b768b3", // 2316 + "10f432cde132447e3dee7344d03f199740603d7a", // 2317 + "ff4709ce54b6fdb2f0fad3f8e2c023f6297b6b3a", // 2318 + "b5cf10bb8bac7ae1462948f256431cc8b75a897e", // 2319 + "1578306bbfeaa19e53bb41fac8acbc413e1876ab", // 2320 + "e4bf686edd2593dfc5da32b78c8bff85d352bd50", // 2321 + "e7ef3f3f97d11b01253652b0981523f3693b8ee7", // 2322 + "b50420016bf6aeb94ed433063f3dd6deb717b7bb", // 2323 + "fecf0b04955d4bb876fecde1c2750cfa72f91a50", // 2324 + "34c60dc1878114f4361d29be783ad01e231ab5dc", // 2325 + "78594dfb5db4f9328b58d5e0972d6a55be2e0237", // 2326 + "3710a17ffdf2732737421dc042a00771a27784a6", // 2327 + "d4ae5b385550a63abb0babe924fc0b2d941ccf62", // 2328 + "03d14d0af681eb450dbab5bd2a20e612ef9f3eb9", // 2329 + "93c91cabb7f89002b3c1174d97e61759569c93be", // 2330 + "d8ac8e780d659684b61a5fc401f7c3e153fef097", // 2331 + "9d297ed66c9c6be434353a4171c4366775cbd35d", // 2332 + "b71cc379b5c78a86e491c5cc105a3b8320d5a365", // 2333 + "826e81025d3f6803ba744dfb158058b09ba1fd35", // 2334 + "5b8963a43a9fbe1728d51212882f90bad0f318a8", // 2335 + "36af2d5647416cf7c6b7083f5f58a3e8de818ef8", // 2336 + "878b480104109190961e2f7682a8dc284991eb5d", // 2337 + "583c6f7603cd11d5f28ba0871bdd711a53346a84", // 2338 + "d936b5bfbd4153a1aefe1bb1d7ac8d724a6f4291", // 2339 + "ef379ea683fc219f29a749ea239aae194561a2d8", // 2340 + "0426b5bc26016a06e99ce2191d248598b5d6302c", // 2341 + "b98184c4bd0264d39a2f9d4e0ae12d1480f650bf", // 2342 + "e7875221f772fdc9f0b39bb7436b1f702854c84c", // 2343 + "baa7114d42af4e95554c475d56f13bbf826d3bb1", // 2344 + "c1f54f5ddde98c664b394124f3bdea2885043402", // 2345 + "32a184aa209bed860cc93283d0065abafc20a126", // 2346 + "bf117ed155c070b052455c24cd4b44231fbb23a0", // 2347 + "f8d846914cd8115804a5927284c0f1cbd7c6aed8", // 2348 + "bdf0f158ce76c9d7a011b71fd6c6d602eb7ff307", // 2349 + "0f8897726695b75b6a66bc01619845025aa91a9a", // 2350 + "9a53666e423b8d97e3f52e4dcc3f749b78d57dfc", // 2351 + "8cc32b24d237d61a79cb3188d12a60d117b369b2", // 2352 + "2395aa32366247f665ebf1435443f15fb9c8266b", // 2353 + "9abb39331eca632e69b3986dd1a208042087a718", // 2354 + "be6dd2bea976a3b9711510e510afea86bc159189", // 2355 + "c608b49c5105f3245e0c8db3ab6c13ae56a47fde", // 2356 + "483082bd1e6dfe9dd7b0e908783ed5861b0f8e21", // 2357 + "562fa2c03553cc954b74eac675ffb96a1c5fdeb7", // 2358 + "d819c307bd4c6d85ee6076bc6e2dbb3f39b29aaf", // 2359 + "7f3d7013d782dc85df24c4d576132ad00874f979", // 2360 + "6160cda46d9f530a7ac226095f1c055d01d8038a", // 2361 + "a81b4472e026e1a5059e0ac6318e640d1ceaad05", // 2362 + "b703b753f1892e28676f422b23e039dae2e86e03", // 2363 + "21b7f2c0aec486e4b4ea3250b515f8959bd3ebc6", // 2364 + "26f3f3ad44ae6a7af3bdf41c4b6094c9b22469ff", // 2365 + "ad8f5f4d2b0058a7df1fe343988b6f1a2fc6d29d", // 2366 + "5cdc0e6277d74ab9ebd057a19043c62ef5c52305", // 2367 + "bd7c273e7863921015713fcee81922c05fd80c93", // 2368 + "fd95b4a8b58ddaf9e4114a1040696cd1f71a946a", // 2369 + "8d0dc5a19089b87f0b7036018eebeb725f48c8a4", // 2370 + "dd30da35e25c2761fffe448d618fd9329c14c656", // 2371 + "880701c80b9cc0aad682e16cd675b36c05e40501", // 2372 + "76ccb0f22a9a2b1df689e428edb3046a1fb7b2ad", // 2373 + "22a8549ed442eb36ce41754b14ef3757b86e96c4", // 2374 + "f9fc0472ceca5fe69ce194084ba08e6573abcf52", // 2375 + "bd6866d9400afc16738fdb6ce4fde4a5e2f268c2", // 2376 + "379ec266bd7f7eb5af1c983c5f0da5f0d5660580", // 2377 + "8239c42ec4f11b0f804e504dc8a2127ff930c2fd", // 2378 + "bbe1524bda8a6a23ce8ccf27b988e06224a9190c", // 2379 + "3584d0edf49a8a2bb2959b4597568f215c88869a", // 2380 + "13ecc9f8164e16416dc0b8f56ca8918fe4eb6c15", // 2381 + "450afb3d207d282a2d0df780a5e4190a36994e38", // 2382 + "3aee8d532c0e7e004fb87dd8e3d7dbd21b0f2215", // 2383 + "8abbb7c283b63da798712ced4e76b81a42248384", // 2384 + "0589852bd607e623707053c4b5327350cff49a94", // 2385 + "96106a3d82011783982d9466777abe812c3c67b3", // 2386 + "fa246dd30dedf907a92ff9304a23e5119ae8ba10", // 2387 + "32463460e83d7ad260f6d751ea61f9098a0e82ee", // 2388 + "8ccb0041decdaf31b6710f3d5a9ed0a022aaedd4", // 2389 + "86024baa4194cc17fcd1348e068e91b72d836a35", // 2390 + "dd72e605b3ba12dbecb498337001ada36b7777e6", // 2391 + "501a6ba3b5ad83db9581dc6960334f1e442530c0", // 2392 + "08eaa5cfb76e17e9442b8c65acd65eadd6408ee3", // 2393 + "9483191e5de8cfa9fdb847df7d15e739c63bb704", // 2394 + "c483656ef4a947c58aeacbc3e225550d7a206a6a", // 2395 + "09109fee2a05011bf71a40022c17ed13c7454f84", // 2396 + "6ca1f547e992859c96160f7baa9d2dbde8d72673", // 2397 + "a5ecd5f03fe523912732e452448acd77fc6b9de2", // 2398 + "3963bcd392d9efdffe7e27303c5f637f3b5cb509", // 2399 + "67273ce80ac5fccd94f666adcf9d0244f5cbc537", // 2400 + "73cb32e5c0d334a962e792638c5291380fb1eceb", // 2401 + "fedeba970d088a3a1400a74c72b66670a6643bd1", // 2402 + "6c01eb9455d9e4e00afd322abd6caa8b6bc865f5", // 2403 + "e08b68a16f07c56d994549240bb6e488d8509799", // 2404 + "7787bdc21e7ee77edaf6bbe4c8b2cc5dc2f28a13", // 2405 + "62d7d788ae903ced479011005452a13829be3097", // 2406 + "c7e0454f63db4adfd6d2433b1a79442614ea2acb", // 2407 + "fe988d0f20fa07444b81812024bfdbc85e734211", // 2408 + "c69932772c2896ca21fdaf442ff8abccecf36413", // 2409 + "599de8a239a8d527d5f013a78277aebaa7e48c26", // 2410 + "0b1d8a2199b7f721a360b14410c9125408fa96a3", // 2411 + "fcefbfd0284cc628f2d92f4feb77fca645864181", // 2412 + "841a2122469aad8b8656807c49ac7b99da1d86ee", // 2413 + "0c7bcc6b2d31ec4a6b8207c7d7a672dbdb389818", // 2414 + "acd96b7f53dbca83d7424946ffd5eb98d812300d", // 2415 + "849a96f576f272b6d25b6429c6e717cd0bb19142", // 2416 + "b3d81b43d983fe46a6a83d06ce3f0cd701df21b6", // 2417 + "4a508a57a01eb4f658f0ec5b3c55f749cd0f62f5", // 2418 + "a826f10cda2f0447828cadd9a95e726673625b8a", // 2419 + "78e6629c9958703c2834e0cffffc1f04a3ee9882", // 2420 + "15e6eaaf134d4e07f889debab805769a2923e216", // 2421 + "40092a70bda62ae5ed3d90bed89e99e7756c4673", // 2422 + "50444cd8234a2dee0717250260d3b6894ffc9594", // 2423 + "20164f60b594425d07b9db27e64e767aa093bfdd", // 2424 + "9acce51d6838822f8f7fdc42a09ccbcf7156e154", // 2425 + "b6f99c4aa5be86b6c2669689a54bab46f47e335f", // 2426 + "95574e2d0d2cd50c3b51d6b70e545068198a49d1", // 2427 + "c86f98fb4ed91a7db17ce72dd3a1bd25c53dbe56", // 2428 + "6732bf2e3d6a616765be16201b62500f43f63a35", // 2429 + "75dd6cb820d8f181a83d3c74fa29cfe5652b52a9", // 2430 + "fb1de21483bee5c22e511dbd24a1314cbf783da3", // 2431 + "4c0f9a3e01dd6a7f622e52d66fb8441c9c0d6ecf", // 2432 + "db517d3bf63aba7f347b27b4accb02215fce2194", // 2433 + "6bfa0962248dc59db11c2cb15795b323d90e9ae2", // 2434 + "6881ae0009cd1b907510b65d3031249e3884d00c", // 2435 + "f622037f277f507361b68ca3dfe87652d9ec190b", // 2436 + "b442570addd77172949400d9176824c10aa0f3b2", // 2437 + "c4cd88f65f661a2684785369f43cbccd6fdb3e8b", // 2438 + "91b834efc220eea2d28f7a6e99890f53bdfdec09", // 2439 + "b0398884672d7070bedf95f3ec1acbff2e6c6bb2", // 2440 + "23d0b54585186055c42acfed18ee4e36e0f3706d", // 2441 + "ffbe9801d6d5c593ead4283661f3b54dbc195980", // 2442 + "466726dac87b6fa611b3de14e1a42b12665b41e7", // 2443 + "56f21769e977e6ea92b469599506626a1eb818ca", // 2444 + "191e3d74b7037efa473fbbdd7ad07ada75e8efb5", // 2445 + "381b6e61fb78b67884808e901536febf740e6f0a", // 2446 + "5b35960c03a836a032d5d7aaa6026494ffb370a2", // 2447 + "2465fb61d6d65f2a5416994343e4d1f8847f3f1d", // 2448 + "82b81fb392cccd0c1cd087cc75756cc6f0c64276", // 2449 + "83d11011cb48809319d6a9e174012ce4bc7137a3", // 2450 + "757dd00d39e1c26adc86f354b9a61024319a8ea2", // 2451 + "b7398bcdebbbf4df0d87b707366135e92f17cd5b", // 2452 + "a381deef0a3c300852864142db4f5f32125384b8", // 2453 + "093e8f67af5c1222e50a977e6d0cd015dd5724f5", // 2454 + "53ee00814621a9217067a4c48a289e242ac46712", // 2455 + "23538094fd0571d9baa28554bb5b66d88721c975", // 2456 + "30f0b6930c44e3786759ca6f066e3c9a7245aef4", // 2457 + "5b7816def2745a3ad4ae5d785f486af635be1f8c", // 2458 + "24b65fcebb472dafabc0248ffebbd46c19025199", // 2459 + "59eb35755f0f0bf60beeceed99938e04e96e1dd6", // 2460 + "ecc2dbb74c1414645dae4144744fd9ecf8fd5fb9", // 2461 + "eca4352b7743a85886a63872856a1c1d3c2e3db5", // 2462 + "9c7f2eefd345481af8fb7ed33cd9b4101b0cc3c5", // 2463 + "971cd60b63c57eec04b3b99b7334324ef975a194", // 2464 + "71cabf818f96f97f0d93bd2d6598f2bbfa53c247", // 2465 + "7e1b1c11b5b431561b6ffc27fd05cb99a09d988e", // 2466 + "ba70c2a857a386a8269d97021c43667fb01274f1", // 2467 + "5bd909370e7edefb8e04c1aaeac2df7a3019b14a", // 2468 + "178eaf043131a1c2e1c4c2a1369462f6c60ca7d4", // 2469 + "332d41181fe2f807231d59c662313dbdfddf847d", // 2470 + "b0afd331b4337635634115054b0a1d135036af48", // 2471 + "90c7d83e553f43a62ed128276ecc47cdd1a063f3", // 2472 + "c869545211bd64df67f0f0efaf775b5724e7fa44", // 2473 + "de577cf892f2b9e22230af5660d942c54d9e6fa5", // 2474 + "390597ac1d6cbf90baf360de64b4ac6c1ef18297", // 2475 + "401e360d917989274eaf2008505bdb19270ceb33", // 2476 + "9d31f8a964fee9146653df0d0600a3927030cdb7", // 2477 + "da06f4f9943d534540fe775e704a6af92fe7870f", // 2478 + "3daca0359f4ace27b9a101e1fce988f0e8564de0", // 2479 + "4261e0dab8a1d1432805bbc9e44c149d4b7f5747", // 2480 + "cf4a15fbcdb929bdb6d49b45a92bf778a216197e", // 2481 + "427bea7213cc24b46302cd95ccec4aa7ac238974", // 2482 + "d0e30a0b6a5e86aab91bbe8663b10386e453945f", // 2483 + "5c00ebfe60876236521620717635c9496369b862", // 2484 + "a0c2ffecae04064268cdc81697d0f03f19eb27d0", // 2485 + "c16ff6aa52f13e032249b9d79c17b370cd077534", // 2486 + "80119bc7f444d51e8dc288b2fc6121bc6d9f93f2", // 2487 + "074097a6a19425c80911d34e4dc61157ef8ba514", // 2488 + "12580d2808151ae68111de7c65107523eeabc3fe", // 2489 + "482e535e33bac8e3fbb9bb631bbe07b43c92a98f", // 2490 + "b0f02c86c6f85cb9f553fcf91763627824a1e8f9", // 2491 + "965d4a2cd658aa039483de0b52b1c4990d3a9fac", // 2492 + "729751a95253ec44c0210de627a4ac9e49ba54d6", // 2493 + "4c857336fb8506a531b49133f5b5f32d388d74cd", // 2494 + "714405cf3fc985c3671a40fd408e8684d98be181", // 2495 + "02d593229cdaa5607ad9343ab251e65be099c9b8", // 2496 + "422c5dc8ae0f31d2573f6c78ee72746944873530", // 2497 + "15a5ed2b03a369e19a7e1af56f4646d5a9112005", // 2498 + "0c2c841b06dde4b7f38a01b38b5f47a80f27a4e1", // 2499 + "9f6046f78274241a3b30fee6e04ce7e046c5165c", // 2500 + "e6511ac969c83b529b2cd9186c939b7ad7ce2178", // 2501 + "a01a6bfcbdd63be2b7b9389f4327eccba3332980", // 2502 + "3b642716434278b3f9a85899c3554b496e2b1166", // 2503 + "35cc297e0d828009dbd9b908d9447e85cb175cc4", // 2504 + "4b93fae9f219d57d812ff91aeb264572c067e66f", // 2505 + "d0d85b286e4111dd541aa7843b107f6753e0b897", // 2506 + "6507be78ce7e4b70272188af3b4aedb82f8b8a65", // 2507 + "4207315e4a902d65082cb7faa6e1c3b52fab7cec", // 2508 + "780b468c3f4ca642596a460704effefc22e0817e", // 2509 + "4239a0189027cecd94eeaf8d40711a9e61b92284", // 2510 + "5b0fac45fce3e67710a9f7a4c83a119e41510302", // 2511 + "9467044d6bcf8903e5ce942fdcdf397fb71ac299", // 2512 + "37fd625e8be3a2cce17d5c432e53de6da3691a2a", // 2513 + "fce26323074cdff4d8f33d3f05f3ee17f618019b", // 2514 + "3550a533724f2c927038198bbacbb2161d223980", // 2515 + "5e6b5ee27dc6431e7baca18a2a5606cf0bed79a0", // 2516 + "68fcdbe4e74e19b8ed3e689911fa02a689ff9752", // 2517 + "c8ca23c6b5925750e6b3b58fffaa263285fddc36", // 2518 + "6043bd376996d9cf65ded627a6d07ed261246248", // 2519 + "70d42e2f4e1e5dbf022e26e05edab61b83774acf", // 2520 + "134578903c500ff1d3927d8d50a290babc57dad2", // 2521 + "5e4bd42ce443cf206f53bfc3ea6c7ba14bbf9de1", // 2522 + "9d396299179f84ddabea9f37c6e97350fdbba74d", // 2523 + "d25e0227ccb46cea1a56f323a15456241fbcee72", // 2524 + "8b7434f191caaa0632429160fef12edc89cbcd1d", // 2525 + "22a5ee46acd730237d5bb025370f898185dbe3d7", // 2526 + "46b66dabcb7a138702a1629ecd32109f243e7017", // 2527 + "02e0f9a9f12b0a43f0ce5d59b7e9192ea12b4527", // 2528 + "afd344c3c117ee38886672adb785563a395c83d3", // 2529 + "60c31e63622000aae7695e33b1bac4ee2414c747", // 2530 + "c00d59e2666feab84d098f8613252210de60d315", // 2531 + "399b11e1e9f124bd817b753471a8968f960d84cb", // 2532 + "95d64b664aa3865ec5f38bbe227644ab8e8bb10c", // 2533 + "561f90813661984f386d011fc905c793a7809c09", // 2534 + "2e6e267e3aa6cb2199109a0019594d3a69baf60a", // 2535 + "cfbea79b5aedd6aeaf3589ce2a520c1db4495571", // 2536 + "439021eb0b6ee0c607d47bf8971c9ab271c72991", // 2537 + "5134360e822bd3f9912c6add55bda5881c110190", // 2538 + "955d9864d3ae2826df222fcfc167083e1b27a941", // 2539 + "0c197b94ffeab6ac1542c5b144f69904f5088791", // 2540 + "75d6cdf6528b0a6afbf523fdd173655db02a9e89", // 2541 + "94b4230e47435ef45f5eadb908736efa134cb6e5", // 2542 + "9cb96fdc2db30dcebbddf9cadd1f82c39d46e3e2", // 2543 + "f939e6ab2d63e86f6a57936018168798744058dd", // 2544 + "a4fc3d6a2b19e544bd716ce4553312020e03819e", // 2545 + "e049b4126e543189c428d5579584d835d72243ab", // 2546 + "37c5c32ea77b9327ffa9ef48f55f51139124c684", // 2547 + "7ef2b39c82d982a68a905fc34ec6017bf638cd38", // 2548 + "b49aa2ac9d330378c7cbbd28fd094253ea179d79", // 2549 + "76bb1585478907b9df51d86183ec0a2ae49a97a0", // 2550 + "405d7d52f34c5d96caf16b85fbb97bb42b0886b3", // 2551 + "34e779d9ddd5494ff518f4f0926101d3cd120aa4", // 2552 + "8d2ad77c1f4ed68c9a5a93324247697db7cf0808", // 2553 + "9b12e30b492b347b54723b20287c685bbee94bc8", // 2554 + "79a2959213593f33e6689b6d8cfdf893864fd153", // 2555 + "8a9641c6992a09fc783491d70f3b5644776a41f5", // 2556 + "b09cdea8570909f6ed8157489bced859823ffed9", // 2557 + "4040f6a244e10dedba267eeac9ea28f3811c7669", // 2558 + "6e961da45a0cc54229f741956ade15b2f722ab7d", // 2559 + "18b91d5e92204a9f362a2eb8e603c2a82d331b66", // 2560 + "3180134a6f1969eb07642cda2768c2f14b66adeb", // 2561 + "c27082a26b22bb251e84903b8484c61b1ad9560c", // 2562 + "f7ef2eb4915427238896a29ccc029e07d6266c09", // 2563 + "d85d819cb4a344020ba3edb8d358debbe5384d26", // 2564 + "1583b540cdfe85fdc8d0dcfa5733353922412ae0", // 2565 + "827428279272627ad9ea61611c0c7c2560553afd", // 2566 + "439c51024ff53b6ea057f27d8ae136c5ef7b2efc", // 2567 + "527faedf2d3734d1147d784758a8e3b7b32e103a", // 2568 + "e53efb0570c44841f8516af56efb7205c9e0b29c", // 2569 + "8143b6a5143d1dfac00b42470c8264372fa6c2d6", // 2570 + "16b3dfc9835bd549a5f3e904badca217b9612728", // 2571 + "1718cafbb8ced8859fe2fba7309344a2eb721201", // 2572 + "a0caa032a1d69447cb897defcb7c1df0d00d6d92", // 2573 + "b1b46bb30352fa0f78bb8fa5ef862a986b05cf10", // 2574 + "7d3871a6ab10ffece25131873dbf3d33316f3a87", // 2575 + "09edc7bc31a30a49ef486a3b836867b6005f82ad", // 2576 + "e8afc9c8dd484e585b84a2dc79ba6c9c3385399e", // 2577 + "df4daaca477c93c318acf98af96b29627dd18bcd", // 2578 + "802ce6766d2a61a5717b3225af09600d73472dbd", // 2579 + "c9ec73c4b5f42ba8fe20251083b6c022a816044d", // 2580 + "8056cf56e98c0b40ce5801921c10f0c8022195d8", // 2581 + "e88b68b3a84efbc3709bcdb775e32ff02d895a26", // 2582 + "70892354d8149c98a2bc0bdd59210261f972ba7c", // 2583 + "674433f480fd6b7e0cf8cc02dc611107fb36c5c7", // 2584 + "3791f1837971460a6340531c0fa54ffb757dbbb8", // 2585 + "25e0bf7e28841767b33ddd0c2b3cda99aacd1e7b", // 2586 + "cf638a0949347e8a5edea302dd74a1b52eb8858e", // 2587 + "9ac081c4d8d8802cdf8384c38fee17dd822d5423", // 2588 + "65f3d6cd93a243177336343e23287b6ad89de6cc", // 2589 + "c6db28be1cb85ab03e43903456be8be077c3824d", // 2590 + "35d5e90f0946927f7a82050737aa4aa43ed2045c", // 2591 + "983e9e98c9195680e7648705272c262b7074bf21", // 2592 + "81d1fc132ea58899addf9fed6c1d573fdcd3a878", // 2593 + "7615b5b23d016497f1834013c9460d9f208e105c", // 2594 + "fb57398bac19c292fe41ec264a0077ac005cff34", // 2595 + "4bfc1e6aaa2ad251add95ea2bcbf5bfe75453b80", // 2596 + "90e4aa45f96a8d8598644dea2c5920a84ce6b231", // 2597 + "9ce0d609a1a4f7f0add433341039aaa2448442a8", // 2598 + "62968a2b8a250fd24b98f23547afa8bd832b28cf", // 2599 + "d291b998327e7614801093d3e92b2968789e0645", // 2600 + "2cafd97ad62229714f67b3adae7c49c5c191bb58", // 2601 + "57226497ac99b58e3c1aa34c7201945ddb1f6d8f", // 2602 + "7d6c96cb98449435a9c37535d0ee401d281cf391", // 2603 + "67c3373fe3dd26d0cb757f6153d98dc7cbcc2e28", // 2604 + "b6ad530fc9d612b8ec24b6a7b5ff94a07a196234", // 2605 + "780a95cedde1822114143c56a9b6d241c3af53b7", // 2606 + "f7a13bdb1d2dedc7b08fe35978ea7ad145c79c70", // 2607 + "2daa0d38f8c41bafc889d7af66d04d01200bf433", // 2608 + "b280a68546c75b0064bd724bf42aafbbd2f82c5d", // 2609 + "9d2ab7b28265f01518ec201bc10e4e20ee411aea", // 2610 + "4b767093e754f75ee126582997183c16129e06f9", // 2611 + "7952eb4199a338e6c8573a0042480ace39b6e02b", // 2612 + "b21d6811f5ff99fd170a708dcea4fa5d233cda5f", // 2613 + "73beddead326f044e02f7ddc6e0730eb89f64081", // 2614 + "f5ebf7dd7ec134252d6b5ceebf4990762a4258ab", // 2615 + "ca4573806d1d94df25c7854269cfbbb6564a7a34", // 2616 + "8328c942a119651af7ce5ff87c1619f219be0280", // 2617 + "d1fc6b5393d353519bdc29c6dd8513142539727b", // 2618 + "6201ce3504d12988be2f49b65b94215122370db4", // 2619 + "d55956093b0eb862e5507ac1780a129cc5c26e98", // 2620 + "ec739ca0083a0e686c4bad2fe8ee1f52d891cd70", // 2621 + "16f6f150967356bba04b955fed73d91552911483", // 2622 + "393126e29addfe03a4feee0e9f3d235cf7903b8c", // 2623 + "98f0bca223a99b91224735ed8fc6183f6ec446a8", // 2624 + "0641ed0967b7f3fa6dcf1e9948a87d175a701b5a", // 2625 + "2889d5ee7a85f3cdc31545d2707c040cd4587d2a", // 2626 + "dd9adb32b439ef3a78d6c941847fe3c7878d379f", // 2627 + "f646111980ba0b20f185a253acfb1b961a190dee", // 2628 + "29b6ddc2ff50bcc4a3d561b7b75c196e034342f7", // 2629 + "2db4b3ea4c5861bce76aa146fa06f13b329fb5e1", // 2630 + "82d7b6d1046489e1a464ba8398af0e90b002ff8c", // 2631 + "dbaa896addc28bc0784ca5e4a1ff17d87ac80a7f", // 2632 + "243adc148a97827f51a7064499d259b2584a8788", // 2633 + "4913fc7a5aa9099c778059d7d37048afad37d7f4", // 2634 + "67ae0f87c0416c0a125013df06973a5c2a1fcc1f", // 2635 + "a4700d500e1a0e5e8d8a4d91c2f9c58db5c2fcb0", // 2636 + "815e8afc1784b5a1ac6ef432426874c79288fbf8", // 2637 + "84289b51cecb83045409d8cdb591c0859fec1f2c", // 2638 + "13c33f54583a245954e4f24f17812f12ed4be381", // 2639 + "f30565e65efa402d6fcc02520ee9b1e8a109ced8", // 2640 + "554ece071fdb90b519bb2c3ae77db7fdacfe6363", // 2641 + "2f60ace6d35bf81fddeb6f7f3ef19949a50e0fd3", // 2642 + "9594ffcb1aa01e6500a911c7c4d8a225392967a0", // 2643 + "9a5938ff7840879f26add490543f828347d81886", // 2644 + "3e3d3033803e5fdf7671d7faebb2d1c47f85208e", // 2645 + "3b8042bac6d7837d50bf1b6f062ab1dc558c8d10", // 2646 + "6dab55f5b507664d5825c75ece4d73b54c4884a7", // 2647 + "248f5ca4656437c4f69c1612c9ee7e83af8e8ec2", // 2648 + "e5563047b651ba8fd0287bea4bcc5191c8afb3d8", // 2649 + "14dbae05782e479c0289e7fcd920ef5a741beea4", // 2650 + "c85ff0c35d726aef0fa04f7432dbf02d071730b5", // 2651 + "4c089bf697db9b799d0c4a3595fe7e25bb52e272", // 2652 + "806f2112cc63ba8af27611d659f0f5ccf3dba9eb", // 2653 + "1f0f1280869939714a495ce97840a107038fde8c", // 2654 + "59723e6ad4e58441c2eec75d94e19b089aaa3edd", // 2655 + "923d952c304f55a3c2ee87d92da847469e674b0c", // 2656 + "fa04afc6a0e305c8c3dea938b81919bb9d57a199", // 2657 + "2f0307c310cd577e6655cbfb08843b2a972825df", // 2658 + "bf8ee72b5d23b00a077a10ca205757000d66cf8c", // 2659 + "815b7ae5c1489df1c7d029ac3119d3d3f1d78921", // 2660 + "381b1ac3140b9c3dd2e4e50c5d1976ee6638e439", // 2661 + "d514788d4fbc45df219dfa2978d738263482ae10", // 2662 + "731fc492535dd599efa720a445f707e3317d9003", // 2663 + "18caa99e5c738685fba0fb68d4b1cecb5a14dbe1", // 2664 + "80c1c0a4f52316a5f8fa95afff40310885cff789", // 2665 + "92e500935d94470ceea591a80531be122d10e756", // 2666 + "ce808f7236dd9a4690444cfcf39f409f7b434d7d", // 2667 + "99b068fc03991bef027b61485f313bcd453dc89c", // 2668 + "fa19454839696c4b2b67be0a13aeda128b27a31f", // 2669 + "47ed2001b573db74cd5695b6ec7762866e1fbceb", // 2670 + "b192d7deaf11de5ba66a3e60bcdcec91c2330721", // 2671 + "b4ce637e39636d29da03731af50246b1ceff1368", // 2672 + "c112d1a0b1f30086260407dad2a21190c92d6144", // 2673 + "8aba564836361920ded122865d8e01044c054d8d", // 2674 + "a2d0b957deab737740a7df45c5333b41f993bcd5", // 2675 + "734f7dfbc648a3deb02e86f2e75dd8209a71361f", // 2676 + "fae7439e12ff8c12a836401ea29ddced12f13c98", // 2677 + "32c40d37b766dd1bbcfffe4e2fb68555382cf75e", // 2678 + "fcb6f5106044529a590a12169b81d0193d8cbf5a", // 2679 + "f135f352212ea0838b7d540703fdbaf8cd5d8472", // 2680 + "fa2b7db3b918ca48c2b0329bc2ce6e59110c87a2", // 2681 + "03bde8bf53636cd259750e78394c54076340830d", // 2682 + "fd69ed5f4e3b24697cb331b20132c13c4acef008", // 2683 + "c7bcc2cbd46c15b1b2b67c3b2f572c7b47e065e8", // 2684 + "51b68d8944d4ed3a13430c5b51ee4bae6a425c50", // 2685 + "8b27ba5e2cf3a3827c4d1f799fa2088f9285aa09", // 2686 + "8818f22fd92b2cb7bf678a3478638efe1db50dcd", // 2687 + "5324fe3acbea785704896de91d195fc274601eb6", // 2688 + "31814e026376fa6d0f8761675e75d71c1aabd4dc", // 2689 + "5cca3e28b4af6a8f539cb1054d4fef38ded78408", // 2690 + "f6ad5faa9cd6cce54bdffd59b40d282c21c25780", // 2691 + "94230d7d72cf2dbe46ad9564c6a60db854322f18", // 2692 + "086edbcea3e5f0885d758107589fa5ae976e5512", // 2693 + "53f3b1af9a262151c91809e97b26f7ef389676a9", // 2694 + "4a4954a1d6599933fabed5ade2ddef0cf2fecb96", // 2695 + "964e8f583e8641919602d4c45d6ab7476a18f398", // 2696 + "ab603714e3ed6ba10b4afa10d28e17f21c4e37b3", // 2697 + "51200dc57368dd8ea20a12c166764a5d8ca4d8bf", // 2698 + "539b522760d5253c1f3f33e6f1058bd07f5a10ce", // 2699 + "c69b2c0b74bb9fb40035044eac083e215e21101f", // 2700 + "e6f90278a1be4d2f57eb4bf87988f8350d77d650", // 2701 + "7ca25e5aef46d01addb71e98bdfea6d5f475a1f0", // 2702 + "c498c5f30096257264280c3039f567d032b6a65d", // 2703 + "075c2cc73abc59899d2f6c1dbe214738ffa629fa", // 2704 + "18cab6bb1f15078f6ff3bd21c916fd44322de2eb", // 2705 + "cfd71ca1edadda0137028c5d5a39febe0333d027", // 2706 + "230e47d449aed47a63bbfb439f564c45883dd5f4", // 2707 + "695be7b5b47fe3a52fa16e8885796a916059c3d6", // 2708 + "b0daa305f97ae45bacc63dda9dfab4908d71a7c9", // 2709 + "a192c612b7b8ce257f4590afe3e03b430918a8b2", // 2710 + "58b3712865b0c9e461c8f766feee39fec65cff4c", // 2711 + "365083b8dd2739763c1e5bb3373ed50277123fc1", // 2712 + "836c13c06d03a56863488f0cdec6c67d2617daab", // 2713 + "ed0f5ef772b6c12f0c2564b822e649979029e7be", // 2714 + "28877b905a97382b0d9f4410037fc87b3ef8d272", // 2715 + "8b6c2db16c844feb7ce21b6409f5c67a85d64b93", // 2716 + "955ecd617d7dcbc74d05e1bfbca1e07b64a27ece", // 2717 + "1025a865dd18c6c5e799e9973ebecdcf31fd68ad", // 2718 + "53075be3a3e887fb0b8385fbc7140194434177c3", // 2719 + "28e1b57b225b1f59898f5f42ec67d7091c43a074", // 2720 + "ee0400c0ae197f3541bbdc91bacf8b63d15f3aa6", // 2721 + "92af8c39d6ba977e3e0a225c186e42d9ce0c1f80", // 2722 + "3fd1530340dabe094c63c2cd7f9b49ea88f8cbfa", // 2723 + "94adbf83cb960765d68fe6df3a8fdc934a256509", // 2724 + "218bccce9e4c5aabba97f409273038c05c725bab", // 2725 + "1d8ec0f3b6c7b70d45e14cf585c3dd689318ac83", // 2726 + "6078b34ea7831458690afbe814230091e2930ec1", // 2727 + "7c0be770deb0e6c8944739dc2dd77fbeeb021b14", // 2728 + "f600c5c6b1c40f1f34232cd2112a16e3ca6585f9", // 2729 + "3eadb02728717d97d5a96e436f1f4d1abc68fc78", // 2730 + "b01a845e872c6aa0d5568441e9b31f0c534ff169", // 2731 + "dfeacfda7d2511223c8d308bb66a1c6b3cf245c8", // 2732 + "99c3c8e3fe7ce3528cb12e575757563d83386e8a", // 2733 + "75ce8519734012f2b41228091eb7c116e8313310", // 2734 + "f5979938472c415eb9bb6d87d44b717eacd6c037", // 2735 + "ae0ba379bcbc8b49a0602b346d057a3f50340874", // 2736 + "09d3091ba3cde9eed9e8de3f96faa4bcb10d4250", // 2737 + "54f46edaa224884e3fca1cc6e62c59673178f303", // 2738 + "772724ec1a8e784eefd85cdedf4d4105e91f327d", // 2739 + "5828c315a56fd9f3a209b8b9c4db5b430d9c3e48", // 2740 + "2019e27d1b9fbf8dd3eeda652c6645df1ad5a8be", // 2741 + "80ebc846031f4e3ea7231e4f7ec6c0127180e039", // 2742 + "a953357819386cdd0a8327cfe75f3e5e41690967", // 2743 + "bb81fd95491a6252f353fde1846a1e27342d7c92", // 2744 + "ce304adb3cc98a97e780086b8fd11bd2aaaac180", // 2745 + "719c76642cc95a41d2ccf5e91fca2ffd351497d6", // 2746 + "7856abc1ac3eedbbe666e93685ca58eab00a6ea6", // 2747 + "0c7148a528d8c6899b006abc51babdd1550b6d63", // 2748 + "e563ac81a7fb493935e72f3cb3c80dede439b09e", // 2749 + "ca274f2fbe50a5b6868edffce0e220a6cbe9606f", // 2750 + "a669752faca98ca0e2c73ecb9324cc43feda1d32", // 2751 + "399766279a1e66c986902479de55564a7a904556", // 2752 + "3a8e17299cf1d975d37c9f1ea7f57c4416d110ba", // 2753 + "1c4e900359537031c34f4e5dd94eac41b850a21b", // 2754 + "ed8a6e355319d7196d2495334fdf9fb88253699e", // 2755 + "446a042df94e7235421370fdfd3031114f98925e", // 2756 + "a94b606027c5ce9049769a7b0eb997d6d05db471", // 2757 + "001a531a1b6273ab1fb429f99f7b06108b1ab246", // 2758 + "1f83ecb62929c94fecb5d499deb81bdb31d08f71", // 2759 + "f4dc8297697f2b786b34befecb47c95dc5e15031", // 2760 + "8e79848bcd64b04abfc2f227cbb9c734504f46b9", // 2761 + "721a4ed0c57d3eb090c7dcba8751f93035e4a23d", // 2762 + "ad2fb1c32194bbc1c106dc1e53240911f6f474ea", // 2763 + "19e44619f0e82630119eb14b02ef77f69b15fd86", // 2764 + "5bcc08a015747e83e6d2497f4b8a9546f0bd4577", // 2765 + "3fc1a14a232968894074ca0400ab9298d255d100", // 2766 + "db986d83e891f104340e72818f1b5d8570c39d21", // 2767 + "10349bc36d560c96aa0c3baf17ee4c330a39efc2", // 2768 + "f0004f0520e06df5bc1f9f982e015ef37e84701e", // 2769 + "a6f4e158f2dc82ae7b60fab0a369b8a91515cf8d", // 2770 + "b79863639f24d746a37930b4b0cf39052f654bc1", // 2771 + "19623dd842609a469629c19060a910965441dbd9", // 2772 + "1d56ae229f2c2583a5ca9ef2137621dd4cf0d295", // 2773 + "2cb29ca11b4e9027e7d2f298863c806620c80816", // 2774 + "14e5cb812f2172fe7851171fc999e7521f356ea3", // 2775 + "36940a7203e56f5b222d17ab35cf060287da679c", // 2776 + "1a37828fe27423e41c86533924d5cdc08bcd3ca0", // 2777 + "daf43a93996b2271b04fe10711885032e6d126a8", // 2778 + "23cb773875d38b8195fab953b6c5e879938d14a8", // 2779 + "18c62dd61122d232ff68342efbf9f76d4bfc8a03", // 2780 + "87bdb9ea2fd8fcdacd5617fdc995f252f1f343fc", // 2781 + "49bfe044843a43c62fb79411ab81617aa9d901ac", // 2782 + "18951c942e69a93934606d844e10fc6112f43c4f", // 2783 + "dce6dfb7442c65eaa7e4c91b990d9acccd3ebf16", // 2784 + "8b5a1550c168a27032c8e00babf94cc4a6b6b5c1", // 2785 + "1a03ce25da28d7fc6e45b416a42bc527f9bc99c9", // 2786 + "f48baed9afa5855fcbf7ebb1092f7c005020cebb", // 2787 + "f32ae6ca631ce7e6beb553543bfe4962db3ec00a", // 2788 + "2fccebf04b313e902ddb1abf408d5c5bff7e9f76", // 2789 + "dc00cfb5ac1b519eb6d03c828acee1ddfef07470", // 2790 + "5986710e894c9b801c08dd012b431262f9a06503", // 2791 + "51d14a83eba4eadc75ae50a79bde7d90bb9d47f8", // 2792 + "ece4e6c725a49c0837b6c4281126c973a665964f", // 2793 + "7e0b5766d9260d980205dc6d9fc4e5b4f2e190ba", // 2794 + "d63f981abd89d8037f40877baf8ef34f0f76f08a", // 2795 + "46fdd8705a710ab61769ce506484816a09348a42", // 2796 + "4ae1b103fd6f8f9fbba320d80388d661f04b4ee3", // 2797 + "c6e25c2041d1d6e09b5929d8e174117c749b9a89", // 2798 + "a339aebcfc5c9e2b8476a0705ff06ac8bb30ffdb", // 2799 + "53c39d3f01a628ab6f0cde5d85c2a2c6a87e676d", // 2800 + "865a862aefdeeabe0119fb075bca11cf7117e171", // 2801 + "b281fc69793f36be0f9e12ac40349115194814e4", // 2802 + "8af71d22b6992ad9e2bff1df1e2b9100b10d6fa1", // 2803 + "c7d0e43a7a174e450ce16d5cc94143f92e528b1f", // 2804 + "cad2d0895574f2bd5eb66197023cfc202b6fc247", // 2805 + "b1b922128464cab3db877b5255c890a65a17e061", // 2806 + "53ca7647da518ea9d60c561a64dda5ae4b52cad0", // 2807 + "cc76ac82f63e16364367208e111322d57c132887", // 2808 + "d7781264b2583e1e825821ac977f157b67476644", // 2809 + "66aa2f7535e6612b094f1e598ce47efa6c60dde7", // 2810 + "0a7027b39d60c86985ca3c221989134d7c7a8a1d", // 2811 + "d1632f486ab78d2d42255248868bc85881a7e98f", // 2812 + "274e1d2cc6d393b9325464be8376fa68453fb6eb", // 2813 + "0268e87cb585765d220f248462020027ae40e834", // 2814 + "fc3a1fc3f29c956d099f22d5531f1441b36fc9be", // 2815 + "d14f4b3b3ffb3d3497776e97710723e02c052574", // 2816 + "245c313f534ce1dc87b7da03811355d88cbf6184", // 2817 + "fc1d93f39400ef5a921f4c5b98dcb672ab020067", // 2818 + "be2d175e18b60d950996fc3c0d59226d6bb5ca04", // 2819 + "5112fcc9f7591681146f66b1c9f9ca5df4c07df4", // 2820 + "d0d09ea2f4ef1eb16e0bb11e7bc264bdd12bd49d", // 2821 + "9b517143f307ad20f3323c114e32820b0331c002", // 2822 + "fdc05eeea946c5042fbd22b79b9ae98226389f07", // 2823 + "0ba926dc26a46ed03d6418d9acb05643aab9d330", // 2824 + "68121422c71ce88efe82e8a25cfa605c45bcee4e", // 2825 + "0128f7f84a538fe61732c24764ea3504a2f3a1b2", // 2826 + "fff1ed0e176b1af823c52f9b5eaf729a6a5f2504", // 2827 + "5fcd5bd4a31938782d1a87bf8240db1ac0177ef4", // 2828 + "246454d6af847d9c100ccdc5a4dd5ff39faa9829", // 2829 + "497d5e15b349d4aae41f9ec865c0d81d0702e214", // 2830 + "838f893850602633123ab88d7531301791cc21fe", // 2831 + "4cda6dab3b1311c6ed0739f6e35cb10e0213c0f8", // 2832 + "65bd7b23657824ed691dba583605e83d91c6ef53", // 2833 + "e32da0aa00c0f3dc359569983aceb0b2a0225a5b", // 2834 + "06d4c491399813bc23d5ae24e644a2bd0fc356f2", // 2835 + "cb08afb1813e50db87300ad2b06f456a9f1ca8f1", // 2836 + "ddbdbc660709cc54118b926c14074f0699460904", // 2837 + "12f4cc2054c0038f7372f1ae43a3afc1e62a30c5", // 2838 + "c4a2875ff9a3978d9db79d998de48a299272a98f", // 2839 + "d0956176c5d8d0e4322a5cee2ca5ca0062836cd7", // 2840 + "8a4b76644e0091a0943cca03a2dc553e6affe50e", // 2841 + "378078c934a80f41a4b10a91ea5f152c56795a99", // 2842 + "f9da35d6c7f4cfe84c4eb33b0924441369a93c18", // 2843 + "cc858fa6220d0d19f3b50fe0ee48874a8fb1ab8f", // 2844 + "4920308d1bc900449fee8ce925dfc1c417d9736c", // 2845 + "21552b4702d9a1a07318c3a4b47c116d5f9ababa", // 2846 + "37a95d9abb5928547569f65e68abf3ee37a90f8c", // 2847 + "41883f41a5724976f719c72cd5a02b051d9642b9", // 2848 + "5bf86917a4125325d899247745761e1e85fe4d3a", // 2849 + "d3c8d494f49c3ce979a2db28dbe8ef9a8de0492d", // 2850 + "291c826de4a7e7d88c481eb761e1514bd7527298", // 2851 + "30a89d439b04de95155b3b04d48d113fa1beb786", // 2852 + "113404923accc98308f379b6e0bc3897e195493a", // 2853 + "58e4b725db82a42433450503c78a0f80aa255ade", // 2854 + "03fd81caf7bd983779b17b99863a98ad4ecc12e8", // 2855 + "9c621a56428258cc829280b8b0d0ec017cdee528", // 2856 + "fc85eb68cb21377e2e23c39999d26774ac2dfd41", // 2857 + "3bee789a5132cbd55d96203210a87f07f6a2d053", // 2858 + "e55a186af8be34c5afd28554fe4ee0600cf9eaa6", // 2859 + "9634bd5422e0d3ba3746cc00efac27a55926ae00", // 2860 + "2aa97e895acd5522afa71ea20b968ffec82b645d", // 2861 + "0c608e4c69c36834083dfce1d509a198da96e428", // 2862 + "b291c666b88f807e28001d248a4566db4586d26d", // 2863 + "bf9763c13fb6c40a4c3376061b31acae42762890", // 2864 + "cd47601032366dca0d66f25fc9690904e970cbc3", // 2865 + "5b8a52dae935bac616e4ac7e18631203d2eff9b9", // 2866 + "c41931bf67b3264a422c4a7adb7ee13fa3150857", // 2867 + "3cb142460f89d49a1ad49b1f3ea5f1e107f990ea", // 2868 + "f0cbf598b2e40101402fc7ad7426cc42a1ffa345", // 2869 + "8a686999617c7f56510b7f45752d233e58b2fb8c", // 2870 + "ac4a1845494c281f3beb71b0acd4ebd7cb1d924b", // 2871 + "4b72f2d5773b665caab2d7976341a05c6ac61df5", // 2872 + "c16c8a562c44cf49e0a2c7a9efb2327ff2c6e3af", // 2873 + "c2e59bc1c5b3f842dd5a8b900660e9b0dca4a01c", // 2874 + "a895e502c5ba51b99f8fa3a90836d22734bc88a9", // 2875 + "07073b6863189aa8e0fbbade55b5bed609383cf4", // 2876 + "a88a91917cd98ed07e0db80add7c5007dff5087b", // 2877 + "c54403cee13344f82496a5697119de0194c7fd03", // 2878 + "1bb11e3105f963fabdc7f7734585e4762703d155", // 2879 + "173afba4fd99d414f6bc3f8bc6a34654029c1da6", // 2880 + "05758686afafb716a7466d27b7b0ddf1a874f6bb", // 2881 + "2562d22b7bf8de0cb2460b0bec57b60bcb634e82", // 2882 + "4cdc7df7d9cda0d0bf98d9bef9e9035136344545", // 2883 + "91f5b7b2720676f6eabcfefd231db6337ffab104", // 2884 + "57d7ae933859c76cf16859100b8016f3ae56b359", // 2885 + "42fdd7208de8b699b6657e4cc79e50e8373e1163", // 2886 + "bf87c5e36833272a7cf59c97ca149a8f2b8c6bfb", // 2887 + "39b6f392612f1a7c0dccb4cba9a3a84613ea2be1", // 2888 + "897d4bc564bd64e638c05183e6e7f70fa20fa780", // 2889 + "7d22054bcfda2a6c8cedf6d6e16ea51e5312df03", // 2890 + "aa3a6b4ddd80c941dcac37a43684e06417e9ee2d", // 2891 + "d0b56443bfd980652ded89de44332142eb8e07d4", // 2892 + "d821c92b0f1eda4509bae9beb961e047262dc8cd", // 2893 + "28422c2eaf2430b126fd2eb0e0a077b81e3576c4", // 2894 + "a692880684bcaff6be011548e055b0a8f9ce0394", // 2895 + "1392bf5e17dd01562a3a771bccb909a70a2627cc", // 2896 + "4dd5d32de7c8f3dcaf8b3ec9168faca93b619771", // 2897 + "665e0e26dc9bc00bcf5ff5c9b9f14d4d2ed80ce7", // 2898 + "d9a1e2ebc26cafca8478d29a85dcfb14c84d5f02", // 2899 + "2fe591a6df334e2dae95a487fcb2397b67c5b585", // 2900 + "1d1010acbb67f15223d99bf5575b39c4b8e366d3", // 2901 + "701f13ac29bb4df63453e6525678c08a301afb96", // 2902 + "144162b2421199a31542fda5a02e5d1bb3875fbf", // 2903 + "60b07a0667d94a3cc4d4916951cfb19225a958f7", // 2904 + "c6c4d59bce57e5f199999a146e8885455e1a2a84", // 2905 + "fbdfa497d36a57a48c9ea2b0328e290325d5a5ba", // 2906 + "48ffe0a0843836c45206565a9ca0960f24af9f29", // 2907 + "9ae7238c861f5e76d63ace4dc87d3a3ab6703614", // 2908 + "39b90d37a0c1b8079e0428935b03892e4a20dd55", // 2909 + "3e879010d88acd36693af823c0f0dfc56944743b", // 2910 + "1eba73a8c0c983637fbcf4791b734d7b438b0fdb", // 2911 + "d232b11216a972af00d9792c641530a7728ee2d2", // 2912 + "4ebe8ad7161a79fb8fb65ced19d35f75d29b8645", // 2913 + "a501c18fa0ed003b43092100c8f0aae658a13ae9", // 2914 + "bedd8a35c66c585816aafc585866d487db8e4d5d", // 2915 + "f0d64355b491a3bde772958fc3e6366299f37298", // 2916 + "d40522438894cc02e1b8776d92cbea14ac422b39", // 2917 + "dcf569948b70cfa3c02558c84bf4b8f7bf7aeb74", // 2918 + "3086a84d247f7f3b05945ef4e00b107d981b926b", // 2919 + "0f9e0de1280f1cf45d46bdd9597c48a576e359bb", // 2920 + "6dbe240d360b102b95627ece61e7ce8835179af0", // 2921 + "44286e982c9ac930c0627a2ab8850996c634b155", // 2922 + "b506f67a233dbb95ae85304ccb803ea671eb4c11", // 2923 + "b66ab52b87011cfdbc67c880b1cfd3db3e5f7626", // 2924 + "49eac31023d989a693c8fdce8ebfd4381fe98447", // 2925 + "a6627b1be008e629cd615cbd15ad4dc4ff230bf3", // 2926 + "de7f734dbbe0697e99533baf912c7cb200427c1b", // 2927 + "1d9040a0683c08c68f469d11af1f4ad30634e33f", // 2928 + "e2c739da546c10dde1d61c134946ae73ebf4ae77", // 2929 + "82679da987d9272bae9125b1a78f6790ca666ade", // 2930 + "2df26d12539f9ae6ec15829eacf3031be1f13484", // 2931 + "f523c6f18ff5ce288e10c04eeca42a6f7670ea54", // 2932 + "f32d30cbe8c1147db3cf63e276f0036e90f3f074", // 2933 + "0b5a6784966b8be51f268777be06aa90582c4d16", // 2934 + "2fc69f77a838fb907c21b4a3d1d1757f56c702ee", // 2935 + "1474fc637e53ece109e0a903af00d71c3f12fac0", // 2936 + "1243aa8892c5932bb4075ced09b0cadc40180ac5", // 2937 + "f0855d337ce00cffd4820f32568826038d6d6db8", // 2938 + "90c75f198ae312d4a2f8ab5a96bb2a30c00afc40", // 2939 + "86a8dbbae6a4ca93cd1b238e7a05c241e1b67235", // 2940 + "2346e36eec0070fa609fe9e394b264f3411943b4", // 2941 + "d6708ec8a1d61912a9dcb40a13c89f5b407096fa", // 2942 + "065ff69d9d438e374e3d17b99579123d09e26081", // 2943 + "17b7e51fd3b32487bfd2b3c2b58b4968a65fff3d", // 2944 + "15b96135d3f8e72d441f79dcdc9fae17590636ff", // 2945 + "bb34de21dfb483db5c57a95c6fc75c4a746c8a0e", // 2946 + "5f6205e8bfca35fb9cd917d2be584a6d0f97c674", // 2947 + "eaf4508b4df293455c642ceec0aa0c95cf944e90", // 2948 + "8e57677b79839db837128614abbee7307ddc7268", // 2949 + "324cc9f104c1186f97692068b12734125232139b", // 2950 + "8e1ee5aba76db759017ba4339eb2213d6173d34d", // 2951 + "346f34b0abc0e2f6518dab0a5bd9ddd5aa87b07d", // 2952 + "e303cf6f91d7d313cd6ac0ae1c40f5b9b5dc2ae2", // 2953 + "5f64ec025330c2c62c80c139b0dde7e2eca396da", // 2954 + "decefcb20ad97e116c270182637eb1578f24b2af", // 2955 + "319b10b0cc728a5c221ecdd1f9347c8d88928aaf", // 2956 + "704f15b5973f853bb52f3d95365b0658beb9128a", // 2957 + "174e634eb43d3a468165a62a130c485a02b53878", // 2958 + "50a03727d6df2c6ce4b1952bc74da4cd56dd114e", // 2959 + "b85a0a1675d2106ac73861b6612d42fba76f4eec", // 2960 + "2c33430ff8d2c3116b2d39b13ed26cdbb56650e0", // 2961 + "0f533e4af2192c8f38515b048f4d78757151e7e8", // 2962 + "df5207eb7925873cd1db184d89287c06dc7cf3cd", // 2963 + "1a4f1a0149fc1f1bb538945eb9eb05c7bb5b8a21", // 2964 + "6b4754c590ba88c0f2d2bd22dfdc3ae6ad8eb5a8", // 2965 + "9be7e71d28c11e69ab0e23e6a1fe7c831a0ceace", // 2966 + "a5621c0c85434e77f1ef5c470883442763eb4442", // 2967 + "ec38726c8d8ff60d786c486fffa22de63d6f6251", // 2968 + "431a9e84e1df8d6458f4d74295be17bd5c35356f", // 2969 + "8e8f84c7232eeb4cab1a5ba4b544b62c64d60a21", // 2970 + "d0f2d18f5393b17c01088dbf5b5fad8862a620d3", // 2971 + "62c8fcece68a193bc7796eb3488273e2ac3b47ae", // 2972 + "83e36268a706342b602436637d71bfd70ba648d8", // 2973 + "2a1ff16e795ee076c48f632bfd228b356090aa1b", // 2974 + "417c2b908bd2db1a56e74be5ed26d1d5cd6b86c4", // 2975 + "bc1cd858e377ab3e8cfc9cbb0bc0da1e58c7c46e", // 2976 + "6a13106c6a8a18bb49e1ca52d2bccb40c8d4a096", // 2977 + "19c8deb9dfd4c25e1a533901907187dc03ab3f96", // 2978 + "cee9c55903f3dced1b01742b67bace1a9aaca304", // 2979 + "c48b9f171831dff5f2c2685ad1be0d3a1489001b", // 2980 + "6518252273bae167da07a22aedaccffd045852bc", // 2981 + "8bdcbfbc96105f0742caba2518974809fdb9e57e", // 2982 + "ff401367a00e27f97dade25ca8155b31d9791dd2", // 2983 + "c8b934f4c6b8eed80dc182c5c136b4f02238b374", // 2984 + "f024c5f2989d3a7ae93f2807bb6c3963dc1a6d37", // 2985 + "3382429d86bb44778eb3acc44b948a1ce553ad5c", // 2986 + "3bea727e59285a45dc6f8841c64f52b4fcbd7262", // 2987 + "e4e298f496ca3cf229ed260615b11ed79725c39b", // 2988 + "d81ea212fce636c61dffca61f6c1ead2d97adc61", // 2989 + "0ccbebe9516b19a49bbb6066f5c316d0a526fa74", // 2990 + "e1fe20e1c9bca112fa03336a1decb5f7341f8e31", // 2991 + "955e4708d87227f60426504eac3e9f7f7d4ce8d6", // 2992 + "5eb2e0e5eb51eea5f9edf035633f44c3e347dc8f", // 2993 + "45a099f7f3ef98d4844bf3d86bb8097e16cb5f03", // 2994 + "2338970274e4bd0f0709556e8376bb967b74b724", // 2995 + "c3c1382048fe38130b09827a76373a55d06091fe", // 2996 + "f1cb59cc41e45e4131fa3a0fc5a8d2021ab7ab7e", // 2997 + "156150b6fcef5185f61b8663a7e302e4afc3eca2", // 2998 + "8a4c514977209a06bd0ac8aa4cc1f9c5f6279d4a", // 2999 + "00dc3a91d5ec3983f907020d265e10bb036a1ba2", // 3000 + "f9a2604af47d7320bef3545059bb102705ce7dd7", // 3001 + "59ec09c955cd1516594d98d91231d30fb9b5dd9a", // 3002 + "ea5a8244edf7877065326db7ea05a1022da9aae8", // 3003 + "6c07a0562a13317ad74ac6a199cde45d5d5af9f2", // 3004 + "9c426b8e46904a5ae93ea28e1e351abfd32cb689", // 3005 + "ed50a90b952b9054303e631cac4c8eea5308d72d", // 3006 + "043352109ea90a2c5814319cd6ee6f6fdde18db0", // 3007 + "e3e9f5de4bf1e329bb6d718de5b114f1679250dd", // 3008 + "222b99241720f4f842a2333644eb2f38dada02b6", // 3009 + "8c38c43981e664fc07cbab3a4cdacdd1597b05bc", // 3010 + "8a5cb2500f524339b9e67d7aaa9b20f85811bbf2", // 3011 + "9e003f495e0826cf302890e92f8ee3f12e952678", // 3012 + "b08d7a94e3c1513685296ce8b70a782ac76225c0", // 3013 + "2b0dd18dc277532f4a05bbecb4c2ce154d00765c", // 3014 + "39adc186cfb20ccfdbc79a859de9e228eff01b53", // 3015 + "6a2210f22b2fef54793bae664de92801dc7774e9", // 3016 + "c3d5111ca7f5b5c68f9d6a344b72ec19b90868c0", // 3017 + "47dafdaa9df1b85687c90ee6f1f4d10aba2009b1", // 3018 + "e96e698a2e10a3608b9a70f4741968cac3bd5c39", // 3019 + "fdb13f1f6ca85c44dfe74b48f763eb875cfe09d4", // 3020 + "bc3e78ee6755974a9731a3833a2cecb8565c63c1", // 3021 + "fab7c4106543d1686302d90717bbc9e30d9e7ed1", // 3022 + "9fc1888f29d581dd1321c8c7f489fa3ae6b6b63a", // 3023 + "2f5c490a29c5e9ec706a81fa1ea5741aedd58529", // 3024 + "76bd45cdf38820b11badd740fb20fa819b79173b", // 3025 + "acab84d5782fdbf8aef87f1dccb528394291bc21", // 3026 + "2b69259cd0b83be675d353f77c3d61abbcc8d5bb", // 3027 + "ac564c431ed86704789eacd52a53f7b97bbea206", // 3028 + "2ca2529bf4620cd00b5ece0143625cfb6d9a29de", // 3029 + "e819d6e61c91b8e87e804cbc0ea389948b39bb62", // 3030 + "0faec8bc352cf78a2a269fbb24383816c92d7c7c", // 3031 + "913bc537e38569081355522f3226930d13ef6133", // 3032 + "114c1b8ca1076242bfdb93f1e5b9abb1f95e3a03", // 3033 + "fe495316bc996638b7d408ca25535538bbbb4abd", // 3034 + "1c0023ba03ed24d2a45a920b8b824c36cefb49ea", // 3035 + "4ff06db204a0e4722aba1fc5a91876df90c78099", // 3036 + "4dd31e8b4bddb2351785ea695908241b26312815", // 3037 + "f39088dabd699d3721666a666b5a0c0ff62917da", // 3038 + "f780907698b8c0ac361945d4d1cbea9827bed460", // 3039 + "4b46835ec90a006c1120fa41684319febcd683db", // 3040 + "6762087204314e3c8d82dec82c6c14eba1dded4b", // 3041 + "1c196c1571af055a56538e85efbcc1e187fbd58b", // 3042 + "07c886efb880b157dbd4cb67669f7166703d394f", // 3043 + "c36f4e6c1d12a341821c2d072a77dabc9686057e", // 3044 + "481e712f48d610e70ee94a31e8c44951d8fcc132", // 3045 + "c57f1eabe6bc59a0f0145c48d1e23fc5e2efd2d3", // 3046 + "2779a2cfea068234befa1c378cb3a4f097f7b617", // 3047 + "06423edaa73ec02512caef81ec3593d30335ac81", // 3048 + "d4f1ff96cddcf777024531a0270dc7729188db03", // 3049 + "afa62c490697863383ad1a2c89617d924d236497", // 3050 + "e4a4283f236498f68a3c62f0278450e3bb854263", // 3051 + "a1736ee5dd6625c6f93e023a04a032843b93248a", // 3052 + "487d9fad3dfc84f482b6ce7873c3f9e31f1a26dd", // 3053 + "f9765a36ee96b33f7ef50fe5934fe3ccc92a30d9", // 3054 + "20d8a9d8677683395abbcefe132b219f0e9316b8", // 3055 + "937027951ac6c996db0fa4ec5083a70573eb4b1e", // 3056 + "59c2ebef2acade69537652c03c38335d631dd354", // 3057 + "adbf16d76004ec4687dbe8142cb45c69756f3a30", // 3058 + "9e8f55dfb1357d19a3fd09709bc762680a117745", // 3059 + "c3f42831d7a0105644d5c89a11a0bfe24993f539", // 3060 + "d7a842ebfbcf940397e739fd0fa8c692a62848fc", // 3061 + "b29ccdd9c64ff2b8e2a8ee87b8038ceb03fb7dba", // 3062 + "39211ea3b7298f7d514bff9aed6ea3145dd6eb32", // 3063 + "b36a64fe9782bf5d4eaccd2cfba012d90e3c5d46", // 3064 + "09fb1e18c4b8ed686078a8f12dd2a6138cc35965", // 3065 + "91df8568a6e22c1a2c644cbde685b8c99e2dfcca", // 3066 + "1cd0049aae40de2479e8a14d588eb7f869b89b58", // 3067 + "2341a29152315a413aafd41b87581cbc6f271cdb", // 3068 + "60ce813e1500cd71bfd05b32d4668a079e7e44bf", // 3069 + "addaef4a9421f2840b0fdd18dbdc1cd9aebdfaf3", // 3070 + "bef0ba3263097c8593cac9a03c3a1693fc73795f", // 3071 + "8c7265d2f74f24d69fa71e348e17d059faab020a", // 3072 + "30c5310796520367c0a14215aac35aa923d61bee", // 3073 + "8cc81c8ed17e64e7cc47ee3a73614d6135b3dc65", // 3074 + "1e06dd6c6998dfa8d0b83e0d9f134acf9d38b5a7", // 3075 + "c9e4e803eb564dc43a05948fa82254f064c1b53a", // 3076 + "19b92a5148ee44c16f5c97c5babc5617260294d8", // 3077 + "8ca2e10895d52c5c46a7805be09f31528f2afc3f", // 3078 + "914e3ce49a7eeba6a841d03d9204d77b5be94fd9", // 3079 + "e5754c7b950597cd4fb91c219a652a843ebe4d21", // 3080 + "fc7f2b6ad435075d329a6d2ada7cfdb5ce487ad6", // 3081 + "95e07c735d5e20c5eb8d30e314e01a28c6942140", // 3082 + "a6de558976e51dd9ac2a365b14fe95b1ece6dd30", // 3083 + "e00a2414d230053d2e52bcc9ca330933a5dcc059", // 3084 + "cdaca58735ab24bf18cce6d923121a2e7d94e973", // 3085 + "dd42fdca2cfa3f90e0f5fd94080634fc087924ae", // 3086 + "69c5aa9a5761e45f454d4cd24752821b913f2e00", // 3087 + "ebae72bbbcb7f109f3cb7207db5306f02824098a", // 3088 + "027d6d8e4d4d376f16a6decf1934d1bedd17e004", // 3089 + "1c2b78dd031134d948c2623da2b0e45907f0a939", // 3090 + "5980db4e32d1c09c1e4ff8601f2bbcfa49e11997", // 3091 + "5ec19139a19344b65484a79e762e7e0a03304d33", // 3092 + "e2669f5d47314095c318b15c53d3ce57013adfe6", // 3093 + "3be20c6c1322d6c6df143e16da28f3c82a07ff9a", // 3094 + "63cd01338bf9ef7bb588eb7436372acde1b56e4f", // 3095 + "41e23d7d1be4a976ddbbd4877b6c26027b8c0093", // 3096 + "9abb843950e602d619025e469a6fb037a6d3912d", // 3097 + "0ce9eff7734031017858741e8d19409ca6a4ce95", // 3098 + "0ddc1cb0b9e63dd232c32d346a4c451150b919b4", // 3099 + "bf58768e36538346b0fc0484d4b88e911a8d5b56", // 3100 + "c7f38ec668bd8b0573377a1cd57212b37531dd9e", // 3101 + "9341ac7b89b9ddf3b9b770977253e13ee2eef6c5", // 3102 + "1bf7342e0ff808fc14b0ac24a7295fc4679691da", // 3103 + "a36bc75ee54bdf28b42f1d5deb6ee9cd28bce59d", // 3104 + "83cd226bd0119ef1057a01e2194d3bd4dfd94886", // 3105 + "1df009efa3ef813b290b70b82eb56ce87c6a293f", // 3106 + "bd6ddaf38314dcca7c5a69f54c9deb8cda01e424", // 3107 + "6bdd5b3d878394e9a8af9d7c6534c8324ca27f93", // 3108 + "f2d994ecaeed4a83ada8a5f5e571d084f96eadfb", // 3109 + "2dacb97f2c414918b7b6c8382154631f63c3e7b9", // 3110 + "101e604cb5ab692cafa98856bf34e4e57bb39c8e", // 3111 + "711fbbbe5e7e11c2ea440419fe819ec781073324", // 3112 + "ecfb9e51966f45f69d0e8a2f94da12722d25149b", // 3113 + "389329ab19e7ea5d42b46b0716a2fd20801972f7", // 3114 + "1da4232e59c44d1dbaf9212773428a0ebfab9203", // 3115 + "aa545527b09ade30ea2900e48723bdbd3670ff6d", // 3116 + "298f0557d54068be56f4b61b2d285668409e5464", // 3117 + "f46b8dab540d04065990c16da6f67641649f62ed", // 3118 + "94de4f6169801b46c830cc19453fd4ef3d4e7724", // 3119 + "ae3eaa4c6e60e85ceaeb3886ed1cc99470d8ab0e", // 3120 + "5ca464c72ed9ece588f2fd1d2ea353253595bcc2", // 3121 + "f44ac976affd962a8c6b441f4daaa2df8b995ace", // 3122 + "e845819d6e676a3981cbfdb767772f26244f6081", // 3123 + "e6fa2d8d78c5541b52e70e18c5303aaebe64ca0f", // 3124 + "d53b97aeb45317f3b14240b9e4a8ad8e33f3ff21", // 3125 + "7f986fa0336f532911f4f0357d3e488e7e473c40", // 3126 + "903fbd5de390ee9b5e925716d518e765e75f75e0", // 3127 + "31e9300d5a231c663d13d6e5a65ef5825e297089", // 3128 + "a0e6a9263d19b4faf68240198e0aa725ed1c0a59", // 3129 + "0610666f195f37669772e7fb9e5bea8f5a50deac", // 3130 + "c78ffb4f4a71bee20fe64e1178680edf6cd3906e", // 3131 + "4d930f792064602061731e355be18241eaeff9c1", // 3132 + "a9861856381ad70fdba7fa5bdbcab54caf43c440", // 3133 + "1ed1888587a5ec756e755c94a14dc57b6441e096", // 3134 + "9cdfcc1d8637971cb778cb6e1c7b252f87037d01", // 3135 + "dfdfc418b4ac12f240ceeb5ea0298e239bcaba6a", // 3136 + "92d7e08fbfcb64b98613d2659846ecabbe6b2ab8", // 3137 + "a56f7c3b88fe3391e1a254173efcab689a1072a8", // 3138 + "71946864df1c1a7b7458bc7907b19c84123f9b6c", // 3139 + "76378f089efecbabcd05f4734fca49e25408791d", // 3140 + "3fa5aa62dfff7cdfd22d042fe86b44af61815c91", // 3141 + "993bc8f39856499be529f031c48f8ba6810f67ad", // 3142 + "41715bcd5b6d0ffd094d625c1019292ddbe48765", // 3143 + "3d18ae3d692dec0dd5103863dc89c2a160a2cce4", // 3144 + "1b6eba2f3b788bc0d5d01ad3ddcd73907cf9bb63", // 3145 + "90053cbeaf4e85e40cc8e1ed6f410a976dbe4d92", // 3146 + "1710f3d5dfe3f884c341fd2acfb1cc16e9ecd5cd", // 3147 + "4901598805857b3481c75ea43751406eb7c65f0d", // 3148 + "3ef6fe1bd1a7b69b86d90ab019b4f01b41cc9bca", // 3149 + "efab949538df94d3320936d4982d1a518a016b06", // 3150 + "1edb916f9dc81d776532d28ce1264f1f8c4c0ba5", // 3151 + "4e335f0881b12e6d2e531b1669f9a40d8e7ff337", // 3152 + "535d6faef543018b88041ca97eb4a3ada87e0d02", // 3153 + "953d6b37bb3ead7632410c4ebbe1af27c29c2a5b", // 3154 + "78cf2606975d9de845b52787ab1569759f667140", // 3155 + "389e95369e6decd7ecd0f2a051e2938f32ebdec8", // 3156 + "7636241c44bce6d53d7469020618eb8a7d21b352", // 3157 + "8c784ead1ddda36543d34c3da534f4bc4a36bf27", // 3158 + "2e61320bc750625feeaa6f1fd93036572fbcd370", // 3159 + "b63efcaf7e450296e5297249755d57e248fbb9b2", // 3160 + "bb5cfa09f922a1f0ccda53df87760aa4a4a57d7c", // 3161 + "a475306411fd27e06aad00f0c7829d32d5fe3dac", // 3162 + "c96ae0fbc0ab7e5b1af255a79ed1ece61b63e692", // 3163 + "73c25df338a9dfb5c6f7a01ca33f24fdbf88678a", // 3164 + "dee9e7d37109e0a5db01cd83901b6a61384e3382", // 3165 + "1136f3fc821686d193eb02d009d5327b88cab93e", // 3166 + "60ce8d70ecbac9ebdf1bec29cebcd364e6a225d9", // 3167 + "1097f41d8c376f3c41328dd56323e417b7275c46", // 3168 + "f4b586922d56777e94735de4147dc1b0271847c2", // 3169 + "80d28b126b226fcd7cf493d0fa9adc7748ff7b29", // 3170 + "ebd3ddf4f0fc2c4090a7b0805b3e5de5da851f02", // 3171 + "fd22ef92d140f57f351fb7b0d52ac60ba29d543f", // 3172 + "6c63cdaf2af9abba86ffe4464a41b908d4d3d61a", // 3173 + "fc4f93d30605c0b6342d9e46cd1114da4f993644", // 3174 + "9c17d7a9101959e4680d7c86150a17346773b03a", // 3175 + "e1e303369d4ec552fc140b4292b07de78d159517", // 3176 + "b8f625be65606784805bc5e5016675e2176693c0", // 3177 + "93ee1847ade7ebc0c8785fc458447800297b1339", // 3178 + "f3d92e69440d0013c47e4e9ee037be2cd4479452", // 3179 + "ef9e6c0d01d03535baf5b373d65d399a8fb8ecca", // 3180 + "56ebee168c6a95b659287bddf6e47351e14df974", // 3181 + "887d931a01a28ea6f436fa0396d5682521e08054", // 3182 + "3275eba5722a0a67044c47b194b91259b0d9f97a", // 3183 + "62cd288d8bb7f2b68779d45ee7d63e785700728c", // 3184 + "39d6b6c58a43be1cbd13d3eb8a63a96d17a25473", // 3185 + "c00ac7a96b5cbb9526c6b5dcb9b57ebb616caae1", // 3186 + "b75e039f7f7af578efc99e50a543c63e62d22bf8", // 3187 + "3c4205c2a6406ec1c0d7e72ee5cf32ea431e7632", // 3188 + "cc068325084c060349a35c731963e692c505ebf0", // 3189 + "6df056e639e20647b2edb354518f8c15b7164d43", // 3190 + "ed9d0d1fe48dea63da958f5d3efbe734deb13652", // 3191 + "bb80121f9891e16a073a10cdad7142227a6f5671", // 3192 + "d8dad69525cae734bff5a0af4974ccf2c3ade5a9", // 3193 + "ade908f0964c308b87138b7b9c56037758f54016", // 3194 + "1399cb47e9cd4019a3cf30bd9a585dbec4ddea91", // 3195 + "2d5269902145bd4e6721bef1074d646b2b5d3dd9", // 3196 + "f2deb39938c8bc6a7399cde8ac6bc4f4a6bcfb98", // 3197 + "8861c7e85410a05e6a0506ef163880830b312e9c", // 3198 + "06179af342c8bbf16339efdccad5804874339951", // 3199 + "3f455096de548ed1cf443ae6027a59fe2293a4e5", // 3200 + "38286e6534756245451e870f1dcffddb883af385", // 3201 + "c3a656978835ad7e62b789250226b675c328e3de", // 3202 + "a3fa6456d521b286ed7581d68055619ada28f009", // 3203 + "1c13d059cf6caacb113b641e43cd1e0e472a1ff7", // 3204 + "cbee1a00c2df4b958ada0e31ba970489302f89c1", // 3205 + "de8ee7cf5d91dc854ca412948ee3123aaefc7443", // 3206 + "19093a86ee7b144bd32adbe30545bfa1c4c5e717", // 3207 + "40f5478943700d96de88c282677f46bbd75886a7", // 3208 + "83c3788eb3ffbd668c301755709de6e5bfaa7049", // 3209 + "fbaa4d19c72900319b29d423ab54d722f7c09f3d", // 3210 + "4cdefa6dfb069577de55a0f40575c51e2bbb480a", // 3211 + "162c69967df243160bc3d608536b48d6f2f74e7a", // 3212 + "4ccf52b02cfd296a37b4039470b0deebe3e91d1d", // 3213 + "d912290b9a96ffd2ae0404fade48141ea0781d3d", // 3214 + "6b49384dc03d9673d333bef2613fbc982e709211", // 3215 + "de0fc2596d27f2dfe7b733d3590043adfa53c6e5", // 3216 + "93f0272298ec606226ee9d4fe5adfc766feb0be8", // 3217 + "3a6b71f5bcd99a79931abe507b63455e76388d9a", // 3218 + "082cc21b1bd8443aadb117345d240f10c4a38758", // 3219 + "f54603f2b744df83187ff10507e21c724ba3b60d", // 3220 + "a9c2d038c178cec81635415dc8fe164aef76dcbd", // 3221 + "64060271b580889befc0cf112608f090608e9f97", // 3222 + "6ae643bbcdf6ee6d96d18d5d7a277320c7330b0e", // 3223 + "262325be520b24f524581fb3ca088c6f27add090", // 3224 + "2ee304185f2d93e1d0cd676de5702bd79cbbe84f", // 3225 + "6cd1ee6f8b50e7512230c111107e5058c7942a77", // 3226 + "991b8943275540afff0d801b2b605968a9ad789e", // 3227 + "f8ebd8ed9d85662010975e5a6ed44f0e10bd4eac", // 3228 + "b5386fc988c1140d5b8679b76360210bbbac935b", // 3229 + "d49bc73357b03cb27a5f283829fbacc6668c90dc", // 3230 + "aa95b06f6f459bbe2dd29933579bf0ba715724eb", // 3231 + "148c7d9461a0e4545a84fbc0d37e1d23c9a1be56", // 3232 + "086b210fdc290cea30e8e5cf20a0e320a9aa5d08", // 3233 + "839bf457be732152a028c478f6606262e90ca0f6", // 3234 + "583ce7cfb0242ac17e40fd5f9074b46d77688bed", // 3235 + "252c4d5e83a574bcdf63467099410c2d6763b8f4", // 3236 + "d69fb2dbe443339e719dddc1a66371993cc82a41", // 3237 + "91892c0cfa69c8b8a0ad26263edea57554481683", // 3238 + "dc360050c2f00c3ae8055d04ac8d6007da20adfd", // 3239 + "88c9f18a96d7c3e99cb81940faa2b76384059777", // 3240 + "913ca547938d9f420b286963bcf784412d8c9b1d", // 3241 + "ec6ed1413bb54dbcfb70735e2353ef60e95ff305", // 3242 + "14645734b34ba39d4e25ba326143b6234b310d58", // 3243 + "264cebee54ee0dfeff00b4fc759098b3af0d52eb", // 3244 + "d7832a1f0fdfa8536405c4c732c0c19f21c27b44", // 3245 + "bd5e6f8d0f97d7244e92d654aac22d39764ea8d3", // 3246 + "7972ea56605b7af97353b0e2cbab816d497f5389", // 3247 + "539016a8b498849c108d9f21ea133af56a0bbcfa", // 3248 + "2a0e195d6201714e03921eb418240304fd2db2f4", // 3249 + "8f4e43a7922d9f0d984bc1e2facd70b75799c1c6", // 3250 + "33dced224c1d93a89341461feb423dff215a6930", // 3251 + "de95ad6006498982d71dbf17054c226e3d7e2db6", // 3252 + "59d7b72273831e08f9346a0e4321e1d06944a65e", // 3253 + "4336b209c4b7d48dd7ad4b2b9a4e3485bd932409", // 3254 + "219ec18f8f999e7fcfeaa534e909567beaaeb49a", // 3255 + "caca52da5f0596f436127d038768adfcd5d42f1a", // 3256 + "e471bcc45a835d3801092bdbb643c2a36eca9838", // 3257 + "b05d7d16e1df5d324b60c5c9de4d5a7913433781", // 3258 + "95c83788b7cf0f053497f370a8f21bb28c0df4a4", // 3259 + "7f6d6d4809183e271f4354f09ac18fd237d5b081", // 3260 + "1674e092df43250785070320982e2fc687a2df9f", // 3261 + "449bf6773da19eb8f712e8c1d91ea0d7bca5c762", // 3262 + "01e7dfb42b6cfb93988dbf79795187ed8c69fd94", // 3263 + "b432c1fa3854a25fa4cb2a4619123c2bbba8b58c", // 3264 + "d082e6ef629afd9b1638c62780f90e7308b11a8d", // 3265 + "bf9dcf33381f0e9522053428720244f044cf7e06", // 3266 + "a8daabd109513464e5491bfe3ec14ee045aefcc3", // 3267 + "bb36b7e8dfaf2a9bfc10964d8cd32a6b09fe9cfc", // 3268 + "75d405ff6d86ab86821f8a59f3c7eb8d3d4644a0", // 3269 + "a1a0aa9765c73870c1b5bfe9fab2925a31f509c4", // 3270 + "956093a147840ff923d64c0528f03f41f805c671", // 3271 + "0d0b0fbd1105488a8fb14092f32d4595926c1e71", // 3272 + "0c9c8868741fe17f5cc972cbbbd96149d7e60a97", // 3273 + "9e1496fab431a50f58df9354904c0ad96c999276", // 3274 + "4e18c3e89ba1094f3d1c061981e59226fd1981ca", // 3275 + "50bebe3a450944355ac54101b204f699a31f8cb1", // 3276 + "82624b88c1c8208fee2bee16bbe805645d5cafa0", // 3277 + "55ff4450bb7009da15f354b4545d1a2e5c8df13d", // 3278 + "ee750c1192c4db14898ee8ddf56d8a16d20efe32", // 3279 + "094a8723da18e81211cc2a68c14bf98399d27bba", // 3280 + "4b36fa761d1b02394f82866b8ee5d4c83b32f7b4", // 3281 + "d3bae2df9681c7b6c1e583414324969a6481da4d", // 3282 + "7e320af41b118682b3d931842d805d5efdf2b308", // 3283 + "58f8ba0d683821d092555859f06ec1f5758a6a1f", // 3284 + "d1ec89d57bcb44d9a741e7164366f56a481913ce", // 3285 + "62a6d275c9a8f94255b2ceb6f335e1662fa1a52b", // 3286 + "36d6f446f612785342218fe8084bbeb840089288", // 3287 + "a628c6a9fc0465e8886f4f5e078739a728b8c671", // 3288 + "28718d8c67ffcdadb28a28ba740fad90fb687d07", // 3289 + "cede40f9a110d4ea1e9184389d0135d18da2d41d", // 3290 + "84a44f847a0ddbf533a8f8b30fb90bdd8dae3ada", // 3291 + "c0c6a2bb64be14ca4365ee7203aad56c22170aef", // 3292 + "e30ba3ce9e71d75b5824e5d5b3e41cd9e147876a", // 3293 + "29404e883be3f11fda171ee5a43a4d5b4e8d6647", // 3294 + "f7f2257219a8a370263f8c0aee8d9ae8eaa1e8cb", // 3295 + "5c53455e09a58a76b054258c5b7f0dbf1800ab40", // 3296 + "900b60ca512a566aa47f2d128e0645ea8b5e1c01", // 3297 + "734507b0142c417d58cd5205a8017d44033164ef", // 3298 + "df73d86745fbbabb53964ad74928054442471b0f", // 3299 + "a1b7cf8b18bfd92419f21595878dc34d0ee8e533", // 3300 + "b7a27c51de4eb887dc553a724b2ff01b0acf72ab", // 3301 + "581e1898c14ba0478dd45d0d91ab06e269411daf", // 3302 + "2872ef4c5f4a0b35aceb35b2b2e855a7251ae8d5", // 3303 + "02587d0c467e8e78f94e0d4fcc94453199fdd0d4", // 3304 + "c4250ce7ad09594fe58df7561247975a0c3990f8", // 3305 + "e19ea5d151f2fa0609a2c987d63cf688c66f7f44", // 3306 + "4132c2067ff0a04f8a01143bffb8a0d2b951fd9c", // 3307 + "c5c9c340561114c03feb775e84cd494f7eb32f5e", // 3308 + "f19daf5e2afc1b37dd9033aaa9b2ddee5723061f", // 3309 + "eff95a3d85c09679b6f3c5aa16ce0ef389488f10", // 3310 + "288bea45f8dd87948a328e3ac4869f86db08ca7a", // 3311 + "2113481aa0f3be33ab38e3c4077cd3ac7ccab9c9", // 3312 + "b17b5abd68b8cec997fddd6662605ee16d2344c9", // 3313 + "7dbae7d429284d42be748c83c62f435665b30bff", // 3314 + "f4dace85d2eb93664d55ca38cb8cb633e3e3e363", // 3315 + "512e358d9aab92ee363bcf681f24ec0c423dbb35", // 3316 + "392043401bc68cc7461941d52588b5e7177da483", // 3317 + "574a9c17e048025e1ca8cfefcc2f36b4f450bf30", // 3318 + "8fa3956a871c696cc62360495fbdd8da8579f4eb", // 3319 + "362a7899d5c1e94ac1c3a4dfb3b196dbbd243d62", // 3320 + "ea570b96c0912e8fd066964a7e1431aa579a0656", // 3321 + "20377affdc92f258f42da778dff4cf1dedfdaaec", // 3322 + "7b9b57c554f9a2f46c8925961f9f175180cf3ebf", // 3323 + "aa1ee6a4f742d89acf832c83db9945f43b679c25", // 3324 + "341d7bb29e28c40a14607eff3b1bbc5a86bdb557", // 3325 + "00fa91d477266b2c9129eb235aae32e21a5e57a6", // 3326 + "bf4703bc0542bc182fb1e2e5ad0e85b296a34d37", // 3327 + "e8b34fac749f8e120012a7c098ecc44f2d70d221", // 3328 + "3765dab4e09efbdaf33d8c3f4a1ff41be048ace0", // 3329 + "4921457d8b8ddbcd922cbed105819c067d56ef72", // 3330 + "37573644a26631bb54de5060e4d19b383757d4f1", // 3331 + "ce918946d42ea601d61d643b9ffedae88b314e45", // 3332 + "9e126d2c1cc7a09aae6f52a889dc05b0c0740220", // 3333 + "3c20776622a9ab6f4cf184c63289b0ecae7bc8f0", // 3334 + "9aea1366adf827fd3ed6ba21545f02ca42adf0b1", // 3335 + "1c5c43bf81369df68cad10bd2f233af571714acb", // 3336 + "55abc6f485e73a11467f3ee51c1f7cb68f7eb4d4", // 3337 + "f7c4f0a3b0e25522a2521279c9778ea6988c6924", // 3338 + "65e551d1f17d8083b9872c640c6af465466add63", // 3339 + "e36fe01d30862c02669b2566ad3509f299ec1d96", // 3340 + "f00ba78a1bd1fffc293b4a22343226d5cf15e119", // 3341 + "d0c682b99477b134baa1f9855eb7d80638ef356c", // 3342 + "efd9a01712108813ec1c29629ffb7d64fa87dceb", // 3343 + "b4c5d19285ac638f60e16e9a9465109f53e09242", // 3344 + "a6a3cdd347d1687d0c6c5902e401443c9f92ca26", // 3345 + "4db8771f0967cbc3db2de568abc7347c84eb9632", // 3346 + "4feb1420421d930847d30c14c1bb2735b5a9da66", // 3347 + "bc103f2e28aba0eb01f7dd9633cdde264d756f44", // 3348 + "0ac19308608aa82bcaa7c574f5f64cfc6e7ec0b6", // 3349 + "a258b20f356f68c144dc74497f4c605eed83ad11", // 3350 + "4d6990deded2fd530c578f0a70d05e552e63a3d5", // 3351 + "6afca5f2c294bbe11f469c0cf5373cb74970c4e2", // 3352 + "b2adbfe05d2df137fbc4ccb7d9afc5f7820363a7", // 3353 + "c39dd46d44fc3787a8d63e3f53fea97a02cba67a", // 3354 + "dcd2dd42ee25e8bedfe8dbe50cbe62d210703279", // 3355 + "2eb1f7b1ea9fc19af3f61af4229fe2858b97ea23", // 3356 + "ca09a06f4953b5fe7fc430dbdebc3383174cf713", // 3357 + "64bc456120a60b00827e3a0f882d0c9c79f0e87b", // 3358 + "d70482ee5031c79d853e453c0b78d5437e8e9923", // 3359 + "ed22c2fa32be7a8c7d3c32daaa793d04ada58b2d", // 3360 + "9f667ef6d00149f551b7634b0fc711cfc78f47ba", // 3361 + "b61ee61cb39184448921a46364ce4c6a668f3169", // 3362 + "b883b3f4a6c35f42ca9fcacfdab7790cee773441", // 3363 + "7ffdb411219cb7eb4bd4d89d06da63168e908334", // 3364 + "3b2999d4b2582cda845fbd4e3bce71a5e9846dba", // 3365 + "ac804ce87c1be552f29feaff6429c4cb2a96d792", // 3366 + "445e6571255234132d2eb92008a2f7349d32978f", // 3367 + "ab6eb1a10c0607b8a7c7bfa5d0b070ebe9badd2c", // 3368 + "cf96c3607c844fa67cf90e22f4ac284f32be7d36", // 3369 + "b67f55614bab0efa3a7f5a660936f1d35d1df50e", // 3370 + "0c47dd37511e6e56f4b746a8569302879c0d31a0", // 3371 + "e005c1e82c8f30f6a6207f448f590e724303c7d3", // 3372 + "850d28aaa62101a0710f2d7d384739cf63c3f1d5", // 3373 + "b71fdfc1dca00a21fb111eac6f0307d520a8e5f5", // 3374 + "c6086da2b588b94a0815906dd1f5340a2ab94939", // 3375 + "fb46517cf27e6a68b45990089f79fe5cc2d13a9f", // 3376 + "79d66a8f7f297ada6f87e8a8ea1087a717f3cef5", // 3377 + "5ee8af544910de3de81d893f7cc1cc35942c60c1", // 3378 + "29a46da51b4e9a833a73447f7c6257776e956d3f", // 3379 + "462d64076e310cb738e838b8426c120fe1d301c3", // 3380 + "61602ab2954830927c4bac173c1cdf6ff90e6b3a", // 3381 + "ac52bd8d6790ec2edbc6a71bfca3b2defe2d5946", // 3382 + "16190e907283cb2102ff255f5c22fd38e29255eb", // 3383 + "615ffc5fd7077a020e7329ecd4d10e62ad6618f0", // 3384 + "6ca027f27cfa5e1fe70273958b30def7efbeba49", // 3385 + "22fa71321f8fa7cf68402cd03b2cc733b88b2a05", // 3386 + "35588e8bbf98645b9a3fa7bcd356c5e9e7c06f44", // 3387 + "2759805570a86f0cac4b8290e302d754fb7a0c23", // 3388 + "949beef201e9261f00bb1e8053fc3e7279eb5a3c", // 3389 + "13eea2e36275e000038df9c091040dfe9c9980a7", // 3390 + "558d210ae0b34af2d2dc593df9c35edfb7c4dc35", // 3391 + "1c8374ba14839c3e911f16d0d766d25e485a4143", // 3392 + "7d62d89c91d8caf25f57177829be12b142a516a1", // 3393 + "0eadccfae0ed9ea47a5a172015f93bebdafdeb57", // 3394 + "92354c87c39e87085962fcf1459fa9ea5eeb41fd", // 3395 + "a92669c59426eec17baf113874192eb53863facf", // 3396 + "a47722d030a496890aca8c02d1485a2aa1bf8973", // 3397 + "7a4da57e2d8c6ffd1ddea02d4b21f1d3a0b25856", // 3398 + "4d0758b14e3c1c83379fe3a42926e44b6636e7e1", // 3399 + "cff92d5adff72f65f75a1488c17660d5520a3a69", // 3400 + "50fb2af2382ac5e84d165ce58dfe095bacfc69b1", // 3401 + "09b6a71061936c19cf2caf0323a0e33a0e7a20c5", // 3402 + "59a735953e425555f36ff3a4d5f312c246514095", // 3403 + "bf7ec285700b2ef9d9f4ed2db1db79374033bdc4", // 3404 + "869b623c3fb99501e6c8949dcd9024a2e9ffedfe", // 3405 + "2b693f43409c80d53a5328b45460c1d56ccf05d7", // 3406 + "b637e109993c1da2d3b31b5bc211e30b664bebf5", // 3407 + "025f7d573f8de5c5e5b70e2b8a123d334ee67cc8", // 3408 + "f7f2278b8d2d1803da5664bfb06c9899e1faa9e0", // 3409 + "a1205a1427a3ecd30379a404971b076fc0f5a508", // 3410 + "e6357300ff993b50822e55ceac7366b35ebb860a", // 3411 + "ae0ac78d43b9cae20d55786d1a4bb3b250151e7d", // 3412 + "1a3af10b031859a3cee6e42dda8f04d1ae2641ee", // 3413 + "8c1476b50f8516446e8b168db06949c536d7ced9", // 3414 + "91ef8918bae68cd205b07e89e66358b6988e34a7", // 3415 + "54adb57e9ef41656b17fb157b6db01e1806ff10b", // 3416 + "bf545241a926d5e88f547979febf857a633f091a", // 3417 + "fd54e2efccbe8ca139c71df9411bc536558551a4", // 3418 + "01dfdd33ff5a296079d7656e2afd099a69d8fcf3", // 3419 + "1fcb79e36c7286405e9e04361f84f7616a6beba6", // 3420 + "c0d5a3baa3238021908a446686b3e50c4daf8205", // 3421 + "7d2718da87a94f9dac3a29531616d56464684ccd", // 3422 + "6e362f5dd2033f555651be9ae71a8c105c5eb1a4", // 3423 + "907ef9779cff179f4413c986fc279bdad98e3068", // 3424 + "42c55d89fc1076a46185b971923f6bdf91f4702d", // 3425 + "3aaf79b8d03e02668b7c3d2cd963bce0f33970c2", // 3426 + "6d00471c2dca902bee64021ec6bbe0d72edd706a", // 3427 + "61e1bdf0b2eb225256bc48fd6a75a524e3d41c0d", // 3428 + "c3f9270e9104f60156b9c3c3b58d17805e4b9ef9", // 3429 + "dc574c32e2585c5ac21bfa534ffc7c63c81544a3", // 3430 + "86be2f99de742c3ea99af0fbe05f3f4385ad0a59", // 3431 + "d14196c5f0018e1c6e5289fc81a54f089028fa60", // 3432 + "badfd8bc404b3851efe09d3d0924a87df6c93c53", // 3433 + "2a983d7ed298118506dc2708b39e3c2fadf919bb", // 3434 + "b9662501791a9c69d44c4f70e0e186291dc4a74f", // 3435 + "1f3ce8bb44677a8627c7d1f4d092738de905b398", // 3436 + "a908023afdc1235745edc2f5008029b42635dc91", // 3437 + "6c5a26f011ab12dd2c58e928b5ff1c6d31846b79", // 3438 + "65ce65310f7b74d683041d1c05b285160f69032d", // 3439 + "418f65d443b075918254b02b8d079af347ae444c", // 3440 + "abbcb5585124a800f7bca7a3986280f7b32e82e3", // 3441 + "cfc711add1a3c0af75e7d9b8ee4da87c2062ab10", // 3442 + "33681f73a5a5a4a0c918ed8ad64ff257607020fa", // 3443 + "ddb1b7df48ea7eb67145b9c41d1e39afecde2e93", // 3444 + "5945ae778c36794a7ce801c219994d0e5a7762ca", // 3445 + "dff67f9df303052c55fb7a566b142f57fdbc4b86", // 3446 + "9596398c563468391419f6df0886a2ff72dbfdd5", // 3447 + "eb5d5f34616a185d9a81e5413e55d80cbb04073d", // 3448 + "b6d85c255bdbe7481b2b773e066fd0c49153be26", // 3449 + "be926fefba695324f360e24124403450ec914ca0", // 3450 + "b5d49af85a2665d9fb650ebc144772f999421bea", // 3451 + "d6edbabdf880425cb9fbee1600de6ba73d39f6d6", // 3452 + "660eebc427167d6e4d45499479a89ebf96d73e91", // 3453 + "2ced86763672bb26eb6661570ef4a9ba610da1c6", // 3454 + "d61a62be438549eb9ed5474e5ef6d7cff7e79c11", // 3455 + "27a8d04f317a6b7687d6f9214e630cd46ba437eb", // 3456 + "15445b4ed90cce65eba337bd1dbe8c5868671139", // 3457 + "6ffcbc65f73c6b4dc4d8bd360e6102e6f6f574ae", // 3458 + "72463918ac3f7c79af9f38cd6ab33ccee40ba009", // 3459 + "19743f0c4a9fce771d5b6e992390b98ec24e3537", // 3460 + "cc807a200db51922eed01d5abaf637198197befe", // 3461 + "e42067ca277dd918d908d5505cd9a80df99fe869", // 3462 + "30321742aa02a4937d9dfdfcce5c4596a9f8b7e0", // 3463 + "ced6a745a71b78c7d19940eb4989e21177349705", // 3464 + "3dfa924ed6d823c711e1bb10cd43a5c1d843b912", // 3465 + "98d95ce742728493b0658e2de5add1e850690b2e", // 3466 + "a1dd87ec3720c3734ddc0edc441e2b3de395521a", // 3467 + "64b7514c24c836bbef30880e556f9f9752e35de1", // 3468 + "1ec3f5f29b624223d99d439c9fa4abe0e6b512cb", // 3469 + "a7c18fbf0d85453639e6f43724e7150b15b2811b", // 3470 + "5864be1c85c1328fcce1e4896fbdc9e38c4f1bb9", // 3471 + "b25fc89d339fdb03fb1589ae8e07730a5c3f96f3", // 3472 + "7dbea4f94557307fa8edb220be1a752dd2e742d3", // 3473 + "bca3c4a61ff2f5f6e3139a0ac7db687058c5df01", // 3474 + "c9d74e7c58b4bda27a63ea3ccfb550346a2e7997", // 3475 + "df1bcbe4a1aa698b937f12602814a95c21bc8162", // 3476 + "3b27c5d8fe21c02cc62c86d8b5fd1be74b6411fe", // 3477 + "f0fe232676377858f76ad8cf93a186508bb309c6", // 3478 + "67b3136012c6240b8bf806a830e9a890a2c757aa", // 3479 + "b838c3c929bc742dcac042cb9e85923413e156a6", // 3480 + "3eddb0e1a3b62289eb13bf66163af2cda96ba603", // 3481 + "d0d7ebd9d9b2097e96980c23558186efd134af0c", // 3482 + "a8fb11e0a72b857a5c2328b264ee42f60261fee8", // 3483 + "39563f6f9a808063fd946189bd482e045ace35c2", // 3484 + "b3aaaeefe2ea6da31b5d16bb65ca6037a87598bb", // 3485 + "0c7d98ff8e9a886282bc440529f288048f3c1c97", // 3486 + "6672a201a676e38154e5ddd0153def52147b29cd", // 3487 + "416dc71581a33ffe11a6abea80ac38a5babb0521", // 3488 + "50ebbeacfcb41c14922d98aab905c74edabca65c", // 3489 + "828bd294afe05e16598b1cfb765ed706aa31fb45", // 3490 + "18e8468b7d250d5abaa4d33226cf9cdd572f3905", // 3491 + "3bde788e0b01ab8f3beeabf00693a430e8e5d520", // 3492 + "cd1b02a99a7773f5b77cb0f2f2cb4bfba79fdc87", // 3493 + "aedc2c7c244d45d83b71e1049e967d23258e777c", // 3494 + "cdf44a488ac9246a818d9a9e3d8e07c8ab359967", // 3495 + "374726b26131a70f228761503f7527012768c62c", // 3496 + "03d9f94e7bd030953769cb4e2f07c30a466e8380", // 3497 + "1b3494dd16249e7ac1a78946bdf7a543284fdf9e", // 3498 + "820d32436a80562917f5124b260598f1059c2a57", // 3499 + "72c55a1d191f95d682e4992e1c705dc9a9892ea3", // 3500 + "eed97fe8ac5ab583951bad3b6d3aa44abfe4c212", // 3501 + "6ee61e87988f5e7385c4af579b3895217dc15ff9", // 3502 + "6a485c9f08343f96767f485e15ff5b4923085cdf", // 3503 + "a761ef7a55220c9f4b70ff6964b4f7458d9b6eb7", // 3504 + "4fb985371829cfd8a908284917600848c38b9ca7", // 3505 + "5909d5a9a0cba6439706407e9432661f9088e53a", // 3506 + "f4ab0a6128bc67b78e132eb821490844159b1e46", // 3507 + "92753f6872055e5c8f061fc543e8f0498e0f9c61", // 3508 + "648573aaab1327f9b5aa142dda4acf23faf757b5", // 3509 + "72b325715ed198b8242178d121e75da5dac646fb", // 3510 + "4943fc9d27cb9e2a5a43ef428c315916416e9ddc", // 3511 + "c2d0bf7670db5d7ed8a9ecafbee5d9b73f111dfe", // 3512 + "72c9f4120225fd22020380b91d6b04764a3ecfdc", // 3513 + "2d77cceac29ec6456a33a0194588a5db4cd97b4d", // 3514 + "7b9c77867f7392bcf6d418693b5ed0c7edd5f752", // 3515 + "e457a4eaf1e2e48cc1597f99e970b3f810922d67", // 3516 + "90f326b5bd0e26e7ca1088721e95a1b19409b070", // 3517 + "e29c7a6f56f92d8f027004d6f893ae8138c80969", // 3518 + "7925060fe18cb4838a433736083c06a19d0b93ca", // 3519 + "11634953dbe8292ba832129af6e547844c55d0b8", // 3520 + "839bd99b67ec478d5d7145cf8b15c1b4022abe05", // 3521 + "0dbcee2998a254062cf8a6f04cb4e522f7cca244", // 3522 + "b615caa94255035b1ffc7466b6afa12a61b5aa5d", // 3523 + "b162a6f4b8f30ab95e38e540449a5f383ef0417a", // 3524 + "1c62d4b27a6742dfb397c8827d7cc8429d9593e0", // 3525 + "6c920b4132d82325350de2ddaad49d2405d5a0e6", // 3526 + "bf5a64b7c23b5e71c18e7c8fda309a1de0ef2a7d", // 3527 + "9fa2c525e1c72eea2b8f4c877ae36f740e648f98", // 3528 + "69b1895a57bc3bfb789a430c59d2bd977a45c7de", // 3529 + "1b7451b26d2cf564edee266228a26e95e73d8d5d", // 3530 + "c43e4bad9c8277d1d02e1ebdca885eec6574f0ee", // 3531 + "6e78efce546c51ec77bbf63f0e52fe78185fbbfc", // 3532 + "00936796b0866a1a48af2122eac48212344ee2b8", // 3533 + "de85a0efa0711f8c1e718bb58ebff33a92908e25", // 3534 + "2d838e7df94d174138b6f32384ff52464834069c", // 3535 + "57d7a74a2d7d4fbc7665c3972d79c263b67b9b72", // 3536 + "7b1ef1dc934b6d0384f740625ce6fdf277be088a", // 3537 + "2de047d99b0fac19abe870fb7e5d3586d3a29e0a", // 3538 + "e0f2ad26026808f81205500abab5eada956bc391", // 3539 + "582d2bacdabc847c2f9beacd4d6ef81ef76f8209", // 3540 + "c78231f1226b5b261f6aef9ce8643987f211c175", // 3541 + "c43c95b15746c3a4f1d6e6cb12a4deedbbba5be0", // 3542 + "e92eb50af68cfaae6a0b7c30f392da773c49aa5d", // 3543 + "a7e551d3a0d64627c35f36d9c57110fb127e4572", // 3544 + "3562b8b75a7799d9162d109420d31ef04f2926b7", // 3545 + "bcd85b6a2e282caea13b3b2d893bf4aa16270ff3", // 3546 + "23e334a55155981c2a80c7041afa99bcc8ab7213", // 3547 + "31ec133166afb84c6c168195573bbb1488c8d7fb", // 3548 + "d989fd7182c94bbe789d6d4f843c8c1dc785e76b", // 3549 + "9dce12db34a7cfc97469d5fd8a022a8b408784e0", // 3550 + "fcb145765396fad02d11a1ec475e731689c265ca", // 3551 + "ee7d294aa93d862f91d560b8d4ce1d011a63e7ad", // 3552 + "7ec6393dfebecf97b777be93d8d0c1043f63a579", // 3553 + "495919fc6bf420dd6d35f83ff7b6dca75f550aff", // 3554 + "88a2e49610d64a3684e6463100cdbb24ad45e083", // 3555 + "0ddc746447b389b096df129fe5385b0b950919e3", // 3556 + "c679a3eec79daa6616cd4f40dc78f1e2afc5b518", // 3557 + "3dfda8fe113dd09a88af5ac40f87c1094face0ed", // 3558 + "3e9fa686efcedfc08ef541931f62a2e62a29c1ee", // 3559 + "7f13b1d401375809cb07ca9d3986e9791bf6910f", // 3560 + "e70f4593cf57479bcc97d2e5a7d1852454f75c58", // 3561 + "86ba27b81e232a2106be7c41755a96c4539e5ef0", // 3562 + "deeb1579e3f6888aac1845fb876a66f3ab7b1bb7", // 3563 + "2199033d2c6a9be337acfd3aeff9c1e93835fde3", // 3564 + "37ed1c46132b5e0bfcfc2d4987ca185e5a6a220a", // 3565 + "790f0c3ac16403d5e9206946ff37b98fc96c53f4", // 3566 + "76ae304e3d3b4920a686030432122d01b08e5de3", // 3567 + "238ad385873859e3c5e71ea0410489cac34e5523", // 3568 + "5c254938f2aa076c87437049ed76be80d1a4c652", // 3569 + "4f726db9e4744c60ef2737755f4617248a90d282", // 3570 + "baa9755c479d6fed1d53ace72ebb051e2e133ffc", // 3571 + "d5902a66156c40e9acff53f501f2cc4429fa601d", // 3572 + "b567481a211ca7e7cf30d97d5587f6b649a140fb", // 3573 + "9bfda29d8f59d9999b906545d368c3893a30fb86", // 3574 + "c813e1479201af2c6851ecf05f690a3c7e2e169d", // 3575 + "781b3422c6ea1ba6c37ea5c64a18ba4d9b0e2d7d", // 3576 + "7854476c72cba8586ed3d4abf2210e7473b312ab", // 3577 + "bcc7a973ff0c7f25895de6dc8f26ae40c8300fd3", // 3578 + "e7166161e5b26d325c341ab7d8af3b94f5c22115", // 3579 + "23af250d7c6fecf7c3cd7043abea1ebf82e76808", // 3580 + "e5e708a40ebc6296ac262ea724ddf25584d8d530", // 3581 + "163343fa4a592430f550d9aa30d70f02a3494cd2", // 3582 + "9ecb15da938b309654a8d76c0cc15fdb6f8345ba", // 3583 + "ee907da383845b39d13eafcdab41a066d9100b41", // 3584 + "3a2726e49784b37e5fa126fe350f8a21172be7ba", // 3585 + "e3903ccbee3edbafb0507651fd03f0e59c81ae55", // 3586 + "ffa593651f9cee94117de55ab3220056a77048cb", // 3587 + "5a7facdfead0c0b65f0e10ad7dc5fb30b3b69c2b", // 3588 + "f111c95bbf0280056c7320e429167c6da606dd1d", // 3589 + "54c46855c8a78d56d7fe32ed86c034405b8170a2", // 3590 + "c836517d1f02381e56968ee4233d800d06f60ff1", // 3591 + "81adfd4e96d68ccee7181f894cddb2258ab04fc5", // 3592 + "12fe1e72d884ebea2233ce58b5e821fdb31fba12", // 3593 + "c260250f2931874bca91673e7a677873a3ee4d06", // 3594 + "fdf497bf068ddb1a5bf4e97631385796fc6175b4", // 3595 + "6a1c2375266dc9fa67d89cc3335ac6669dba2a0a", // 3596 + "865ef8fad3694b8b5d817459c26d91f80db70641", // 3597 + "769e1b567e916a99b4c8cbf6c4fa06ec7e5c6102", // 3598 + "7367a1e7d8fc1baaee415d193d8d5483c5dfba70", // 3599 + "4e990dc34400d009a33b14390f7a9b06d7fc6fe7", // 3600 + "f4c0b56a5b08b6617c59a1f2fe97de598c477add", // 3601 + "626fcaa702233052f72aa2e4dc9d0f5bcfbb7594", // 3602 + "41e4b788e82faee3b7fcc48bff3e0be866483a81", // 3603 + "d8ebc2b80e3132c5c30c73b7bb58acacc6ea2c63", // 3604 + "c3ddaf9e4c3ce3e14a9302799d8b3c7de8177d2b", // 3605 + "4ec00a6f182a25d007664569ffc4412b3153ec3f", // 3606 + "d8f0693a8ba8c4b09a33029636a8a4fa9dba81ba", // 3607 + "126df006049629c578304fd03090f2e765ce3e6b", // 3608 + "f766450874e181b36673a2085046c284f38f64d8", // 3609 + "1ad2404948241258b031d9e919f2f9243672aae3", // 3610 + "93b779cc69c402b0b383482c0c10a77f962b6620", // 3611 + "4e796dcb77c50e45bca39ad16c5963685c25b278", // 3612 + "d32e5dcc3fa5f7327cd60a82dcee490d78902b5a", // 3613 + "fac1fdbbca092d19516e774c5ca1689150f8fc0e", // 3614 + "fbad926f1c1c52ad6ac47852f2437ffc7ae330e0", // 3615 + "6c8964dd8a32a82b5817e845ce3be033d64534ca", // 3616 + "13248233f82cf3eef1fdcc3a4445c58e96309411", // 3617 + "758557559459f64907b7917c9dd19b9057e91a54", // 3618 + "e83044f53435b647968f0f60e65b1d9df39f725d", // 3619 + "689c5092604f3571d240b9bf66de02f390a8cb64", // 3620 + "ae788d64b3bec2037a738ead075265b85a5318b6", // 3621 + "c4994acdfb45c8f32f5db254ebdfc67792761f3d", // 3622 + "5f66e9b828645ca44c94011e62a6beefd6d25d78", // 3623 + "6c1a7858400dbdd237661b12be7230e9cb51c4f1", // 3624 + "25c0f65245fdaa0cb319bf2292e0b41a2ec69b6b", // 3625 + "c47e253b7da3bf6a0828a678d5d3cb1335b3b4a8", // 3626 + "b2cb23b732b66ab46ed636491cb66bd3326d93e0", // 3627 + "7854cc873b53ae8e8e8e2d837a0b5be0612e4c06", // 3628 + "81aa57c564203ad76a4695f381d861565e852d95", // 3629 + "a1ada164339c50d8a6636c327e3bbec2852620ce", // 3630 + "1215ccad834aa244d17973f4d85ae1197975bcb4", // 3631 + "c88c21b88c63909bfa77b20edc02ddd9c9f3f93c", // 3632 + "8f59f2e6e27792110dd561df96a7dbcb37e561eb", // 3633 + "9a3a04484773ec93e1fe1f6e437b31ba15a260fa", // 3634 + "611fbe4f774b517b0765034e63f8b7a1222d5ca7", // 3635 + "07c99bbb92e91a1055cf72bbde54940d4c1d6ecb", // 3636 + "d2166e82e3f1c5ee36a2760cc05f7bef7fa5fabd", // 3637 + "acba5c1d92f08856cebb14ae4d55779eb69c7675", // 3638 + "b7ad2d062a501066aa97560bc082f5412e989af1", // 3639 + "ffa26317a156c9930ad7d29367702db547f5cad4", // 3640 + "60446da34da57af3d7b327819ea785537d2aeb89", // 3641 + "21281856ec2ea40563e7aa918b9b2dcfdf47c868", // 3642 + "0a42587db48e10ac290d98040aee512470a8a006", // 3643 + "f5d3172721b9fa5b832926809b119cd668ec5263", // 3644 + "d6cbd21ee4f41bc409b08254b41dd12baa4ff565", // 3645 + "f461bef6c0cf55f3bb33dd9f788409a82fbe1315", // 3646 + "25bc3293b7d5dd1d71b36055453e56d3f6c039eb", // 3647 + "2b69dc85c977860754daf55afb66469acdd363e1", // 3648 + "9fd85351df2251970be1689dab96faa31cf7a630", // 3649 + "bda2e55bf76941354e0e10b66d3b7c545e0a1ef2", // 3650 + "1dac056f7fc2614bf79dbea2ed1686b0a21f7033", // 3651 + "edfd05fd71fe512d0f5725b8b70d21dde952dd14", // 3652 + "23605abe73023a5905a24a149c7c45efa9543333", // 3653 + "f983a876000b07b0e641eba7b61630c2ef9cc007", // 3654 + "d8aec441e9def630a6fa89395e078a2c72dbbdb2", // 3655 + "0a734d5a2ef8e1fbaef94937f4887f85c315ab3e", // 3656 + "e400c9a174c5aec60736eec357347c9906755dd3", // 3657 + "5673a2072e08e3da624465bed79d255126dc6251", // 3658 + "78de292cf9bd6c1d1dd671d2431aa48b887f2053", // 3659 + "01b651a346444caa349961a11a4adcaed1691200", // 3660 + "dd64182819a575ea98cbee978cb8412fab27b370", // 3661 + "b3ad32f14ffbba28c9fe14f4b972cc7de4732722", // 3662 + "376c1d36cf1f13b7e8c5c63b8fab175873270892", // 3663 + "542a3e994094b65257b891d35e60c29a95e27fed", // 3664 + "cb7f3c622721d2196a2dfe16d36a3d01051c157f", // 3665 + "4e665c590934f9a60ebf19f2be23bff8b7c1bc95", // 3666 + "1eaf7671e6da9b40638b4fbeda669d959d9344dc", // 3667 + "89c90362791051241ac8d7e31b93b9b12de7fbb5", // 3668 + "afefecaaf011b04723b445a5835a627f3d636740", // 3669 + "a7d1880dfd88875f192e208fe33ebb5c2cb9f3be", // 3670 + "b33984f1cd5cdcadf1969942a3047a6b11f8ff83", // 3671 + "50120b830325d23e51126bee92f6f002f7803612", // 3672 + "f33eae8ddedd92c0fafc07774e136c523e2d8f01", // 3673 + "7521cf2b4d65026bf66d92f5de880c8cac83309d", // 3674 + "41d4ed55f55dbbf2b78d6eceed1867f392060f54", // 3675 + "62d794a9ce582c2812d2098447eb885e18b70ce5", // 3676 + "0425dc514c5b363f6e76e7b6f4ce7ca886c4e2de", // 3677 + "21f680e135d7c11365980bd5c7bd5b1f1d930e47", // 3678 + "e76aba1869a370e16f31326901413111e171d756", // 3679 + "c70ddcf5dbc900923e3350b826ddc08fc1635f09", // 3680 + "76e6821b8aa48ae4e6bd3901a05d1ca4ee0455bd", // 3681 + "6f071c2d441b09c8d1f283a7ba90efa859f0b424", // 3682 + "d3b3f59d89086346d0f3942fab1dc4323dc676cb", // 3683 + "c8b58aaae9960708664e6b9e415a1747f624dac0", // 3684 + "e95f8f5dba8970989b5025ec65a7029df6b62514", // 3685 + "4dc37532893dad35b5d7ca63eb7da270cdf37072", // 3686 + "bb382b92e0e849af32723382a9d1cc6ba891445d", // 3687 + "c596f6848cc2c4a274a64d0d8ae0d63075c29503", // 3688 + "fc135022af0fb9289ffd0ce3acb8eb6a9b0f8a9b", // 3689 + "9c34c8c47bfe64e3a14323eac432fe35c1b11d30", // 3690 + "b5706a17f6765ef986bb78323a615c4817ab8d88", // 3691 + "379934d77ebef7f441b6ef501f83a7ee8577a94e", // 3692 + "7bca42b2604a8f0288a3277cc03b9e4bcdfb8d0f", // 3693 + "60d60da9375f056c68f980610d440e06ab49b854", // 3694 + "f30deffc9fde5533b7510b46e4a5633ef7df98ca", // 3695 + "5ab24d5c029c984b6ae51b167d5943cc288f39bb", // 3696 + "5da7a3019bf789c482abc5d858263c38d839c223", // 3697 + "210cba54df2b33a92c16b1a2a1dcd3176e801369", // 3698 + "fb7ca8270911cf59efb868a1d3781a95d0cb6222", // 3699 + "5ea52f5dbf80d8b81dc2bf5d2b699123ea33f34a", // 3700 + "556e4495061e87d9a46cb3c62e1c65a6a2c97866", // 3701 + "5555c50db23ce22cc5d8f8ca37076b675bf851cc", // 3702 + "428fda7216706db64f2d79082187ba624f43d55e", // 3703 + "a6c6c428004d6e54496ef0671e389fb241fb4296", // 3704 + "0757235b59b4e542eb88153d660444d38f060871", // 3705 + "8f6bf2e705e75fb208e62e80b8bbb49d6472273e", // 3706 + "c9bc1a337f01e32b0cd6f77e57020ed63296d7c6", // 3707 + "1e154b9e2959b0934882f95360ad9bbc1c43496c", // 3708 + "3edec199eb5c3513dfccc00d2f13ee3159b16742", // 3709 + "fea6751c6c4e930de79bbf87cc3f9cf0b437ec1b", // 3710 + "a5eac2aa9cd769144489d44331bd3937ac17361b", // 3711 + "9e7b18020a412c4f0b3017a5e6bdab6e9208c0e6", // 3712 + "2563c8a9fb80fc2eedf2f1dd4c653510d090f8bf", // 3713 + "5a9f634535615f6431cb6450016e7b224afbc950", // 3714 + "64cbccfcbe00bb210d8686ab3222fa7a018b1fdd", // 3715 + "72881caf1038539eeb598fc702e26af494dcaabf", // 3716 + "6f8e5c427b46f6ffa3e19b658067873310827ecd", // 3717 + "1e96b0f44715d2eda94258d027551c262ed1c553", // 3718 + "e49c14b7004c22f03a4dae57fa957a3664b5f2e1", // 3719 + "73e07d68cf35044f9190c72f63e6b8769d0c4753", // 3720 + "e8d08084706cd15fa80f571f2c2ee890def27987", // 3721 + "3b8fc56e80e7dd02bb475814e673f2854a37656c", // 3722 + "113484fa54bd47ecd1c1b18b453d72f45b03d696", // 3723 + "dc5ad30843b8df6f809890860d61f5b85b3b5273", // 3724 + "777906e3bbe8a3a28af4679136729bba578219a7", // 3725 + "3108f31c9a5feb0d0abe14418119a66a1b9b3895", // 3726 + "58a95d0ed170765ac160af82881763077287ce87", // 3727 + "c1ef3c2b4b4dfae91eeef2c3fc40852038cd4500", // 3728 + "bcb4a5745578c0b3bf4aa79c986d11318c30f2e9", // 3729 + "66226198343e48e17028fd4c6ce3b6df86ba1615", // 3730 + "7e8883855740c1c71e4d0865c42b43e23de3ffa1", // 3731 + "f76d14654a5c6278aec7acef9a1b672073980855", // 3732 + "a164bee887fc4a156d22d26f72875844277d3834", // 3733 + "8a583b017b24b898e07b98073085e7fa69fc9f9d", // 3734 + "a4c0f5e1383ee6dab70ca7261dce23d05ad85c3a", // 3735 + "da20ad9a6acb7c9e2734c900167d451fdb553ec0", // 3736 + "1ae468063b849f1d38c2144b680023ef9d5c366b", // 3737 + "2c8b67fd3515ea453728379fa4892831e2ad2b83", // 3738 + "2a5eab27ecd46e14a73f54d2966ae8ac3317454a", // 3739 + "374bc7d7a90cf763ec9bbd867c09fe2894245c46", // 3740 + "7ddbbdf2c7af55a21d87a0c1eef7b52699b74403", // 3741 + "262bf71e9addc9eb9e512e541cf32c3acf417e91", // 3742 + "c6d02c58e9c12a9bb09f9f766870753e50a85d94", // 3743 + "1ebc17e68e7fc3042a3b8fed838cbd409f56f542", // 3744 + "e40dc183635dda210acebe50d4a9ed915928c4f2", // 3745 + "45f404b5a0731c4ca10e8242ddb0afbd5b79db6f", // 3746 + "e07101d0ff222c3771db8661da6f2d19c0a5a25c", // 3747 + "755f7049fcdea97a2d7e1aa65a8fbada8c501f08", // 3748 + "ae41ac13394970e4cae34ee245d2c295af8222bf", // 3749 + "654cb01151a6986e065b07027865eea46007dd9f", // 3750 + "fea0f23e0055e06fefa3c71ec7cc31b33a1eea07", // 3751 + "1e32f707c143b6eea43fb8f3760fcf8f153043d2", // 3752 + "9cd1a8902c490be4173629d1a314aec7c7e09310", // 3753 + "db13b8e0546208d707ef9f232c6672560320666e", // 3754 + "c8604683fb7760440f6a494fd91bbe739fbfdcba", // 3755 + "54d8604171933733b6b6564e0d227528859b6adc", // 3756 + "dac142f797b9badc9d6cd9117960d3549f95c92d", // 3757 + "572641e00288cd2e9d7c87b8a7d82357c0f43b6b", // 3758 + "151f4f18acc9726d2cbd959888d29d564dee71ed", // 3759 + "9979647f39a19eb7aa54a06c6a12f548e3d7ef39", // 3760 + "de2da8231119578060ef793b157f450f770b1b92", // 3761 + "eaae46a96bba17720514ee6ecf78072085c3eed2", // 3762 + "b5942ab891598c419ed675d338669af1903a4ae7", // 3763 + "218437eb160094b2ee2590bdc1a3f7e5ef939a9c", // 3764 + "bbe89048c37e14f9c8582d9b74d3b53db1110ad0", // 3765 + "3f4cf0e402de0e401c1cf0c9dbd58502a35b94f0", // 3766 + "45ef4c55ce3e07e9df0553db0566d3f5e80bc7f0", // 3767 + "d7a298d967c812038f8ea5447aae41e8da586f99", // 3768 + "e57a8925dce21b05449b03ec564ef96d5175d5f1", // 3769 + "7461c412ecb91450cf78395e902557846fbe310a", // 3770 + "584be5d64da13a3f5a08cdb3f9c086f5927a91ad", // 3771 + "11cf3aa6aceb502251cdd285077a62e26490c957", // 3772 + "e3c6ccc7583c99711e2257759c5244947f6d2935", // 3773 + "7cc2d6cffa26027795f0d6b05bde642940bc38a8", // 3774 + "1489a0401d7c602b120ca29a3e4fc92268ecf217", // 3775 + "c24c103d3a66dd297b4aa0388886a54cd6933cf7", // 3776 + "b4d78b6af7baba46ecb8c89b8caf60545268c2cb", // 3777 + "1bfd1f4f6468eb957023d5e426f314307d99d99e", // 3778 + "2cffa258a3b77e1f6824bb439fb23f2ee66b88b5", // 3779 + "daa20a241b8857af94b5186627d7ff54881b137a", // 3780 + "f56f7737337c8435f88efc543831d8bfdbb8d9db", // 3781 + "a714fc5492d9c5c94c245d5a828bc7b6952f5b31", // 3782 + "2f2e18df37c2f6954dbe98f3ef926db358194bca", // 3783 + "51ac250a4eea295bdb5e357a69e044fe42fe8a33", // 3784 + "0a6c8c67da743a26cbde5e314db6c5cb27a4d479", // 3785 + "04405e5a3d8a92fa13b2af8a2dbcd545de04389c", // 3786 + "74d7d3d704a5d69f49342b906a1da63011c6d8de", // 3787 + "d22d9b5c9a5324e6c51628558b0496d91653d997", // 3788 + "3c8b2f6e7c59f1865d136c7a239694332784fbbf", // 3789 + "7b940fc49a08265bce0cb01062f6cdbe94bc7ff5", // 3790 + "c0e641551ee935deb0ec2d1eb5502145b384ba61", // 3791 + "4180dcb8e5ede98597199bd46f0db79bb0f0db41", // 3792 + "31bb59b9603ed9da3f272072b5e4f10011cdb633", // 3793 + "04a01106775a069ddff9a06af62918aabe8a4c2b", // 3794 + "6be6450db5f9312c9a16987097f50711cf233af7", // 3795 + "53419f761ed69adf064bb83a811ce54d17f8bd18", // 3796 + "88b048d3c9e9f17525ca7015b3a9f586c5263416", // 3797 + "1148abf57bfc66714ba75b3e4eb8672bf076fa3c", // 3798 + "b9de32964740fb93dd4c3a942ae9b755b9733cd9", // 3799 + "c6a37605244d629586638f7bcd12a22a4d46b433", // 3800 + "9d1ccc3bde84deccd038fe731e235a6ff6add6ad", // 3801 + "09023b0fc081ed66d52adfc272abfd78bdaa3c96", // 3802 + "f928dcb7a0c075e0beec33946c2ca0c97b3a1d51", // 3803 + "db3a6b3ef0743feeaa83a5db9e5bec20147cfd4b", // 3804 + "550fda72f46290177c46a06fb04e824ae4e470ee", // 3805 + "fecc2c6473b7500252b9c563ecb92ed21edf50c0", // 3806 + "6736c0454cb859693353ed2d40206b15a69f8493", // 3807 + "a1b338f9c4d55e3d6a92a0e1eec0bc6cfe88170c", // 3808 + "25e4fb8b7886592dfcec11eda420d70582929ae8", // 3809 + "3657d4391927a47a9d3214df1f732adaa93d3e21", // 3810 + "acfbee6c785888e520eab428d1870dc27f5d0d45", // 3811 + "757fa4074946ad8534bf9ccbdb0a4f1e3eadc4ef", // 3812 + "0c16233179e42b497605dd896f6c2808206835a8", // 3813 + "0859ab2fbc52acb3b9d517155dceb423f2c74176", // 3814 + "595f89f488ea62d94c67b7dbab063181b097e519", // 3815 + "e4ad2ed4209bdc4cbb18e49e1b214210462bd885", // 3816 + "6a5c1dcdefce1d806e63adac1105ab4eb7d72bcb", // 3817 + "15fbcf7dfea6cbfae229fc1e9794161ed8add3ae", // 3818 + "67946850004da60416a5b6739221bce2d70939d5", // 3819 + "b4b14e40ba4fbb5c1341eaaaa532bf5b1849f441", // 3820 + "c73de3810a381bfa2b92b9fbe76babbfed31ce4e", // 3821 + "3c03fc7efb156f170c0954ef037e0e09d15d30af", // 3822 + "07a2a44684deb72eae01d4af315d58c50ec4b761", // 3823 + "11c57d732283191d378d0c7e5fb335f5a13957a7", // 3824 + "a429c52aba8bc46a146c3264c3d2f1505746711e", // 3825 + "e66fec15cb3360f19430d6487bfced21e9b63e11", // 3826 + "64846e9ba3e70fa7f538211edd89ae3c690c1bc7", // 3827 + "7e3ff2a51df9fa6dd186420f821e1e34705c302b", // 3828 + "891976e75a8a6aec6a68c50975dd5076a066eacd", // 3829 + "7d012e85beb08811844fbb34557f73af16790e5c", // 3830 + "9050834b155a366147b239451b2833f0f5b38c19", // 3831 + "2bd71e70b7a1faf65323bbfae99c56379bbf68c1", // 3832 + "956ffd360242e9846eb32ff792a0a505b2af96c5", // 3833 + "8d75b743c37bfc5f570b2307b81593f1d1439c22", // 3834 + "6c16f0cb848e258f3f186cebe66012d667005c5b", // 3835 + "334f1382bdcda74f0ec264949749c4a1f8e5db22", // 3836 + "e9a9d436f00cc784718ae07b382a74510b6a8c68", // 3837 + "3a38ebfbfaf2356f6df443deee7e0438a8a602bd", // 3838 + "44cbd08040e4ba2b6d51d6510648bfa18156ea65", // 3839 + "67d200e4524dffe4f21ea0d8ee56ac1c82fa0edd", // 3840 + "498ea0888950f1c1de0c78b07eb69371197713f7", // 3841 + "0196449570aef3ed2bbed89c9c461e1c1a9f7f08", // 3842 + "8c03fa49f8603235875be4a220d91d434de0fc5c", // 3843 + "891a5b1f97e5cffd49821e0bdb79d86c8055099f", // 3844 + "1a881e6919041e57deb051eaa320285da5d8c52d", // 3845 + "2711e14e695a9d5567476675a0f6cba15b323f2a", // 3846 + "7f074802624d261ae44b92236692f11d67fc0dea", // 3847 + "20d13bc3db76066cf1c3a9980bf3f2e3f28f5cda", // 3848 + "de1a043aaee198faa81110c3c83ca3123681f70a", // 3849 + "84f05bc27ebe2f47a46a5681c84f1acdac760753", // 3850 + "04a02a875f7b5f504fbf08471a3fb0b06d0bc601", // 3851 + "83a2dee15738273adc1cd205c0fa7606e7621fe0", // 3852 + "2a90b15c4220c828947456fc7b0a0b3dcafe07a4", // 3853 + "eb384d1558aa6324a4b7e7a9e1e8f6e375497974", // 3854 + "6288df4c3af40553554a6435b16392b92c825a50", // 3855 + "121fe49e9917fda13875a78d4e1b1b9c4edaa266", // 3856 + "dd0e3ab1900c15bb47b7617126027cec39341675", // 3857 + "67aa149165e6767515525e0fea65ab693cb5b919", // 3858 + "4e8245a470159ab6206e99bdfadd11c726ff5c26", // 3859 + "3b1e1f415433d5386e8c7c2454b1f956e916ba79", // 3860 + "ae593c82b5a220901cb9d6ec7ed87453f6ac8078", // 3861 + "1099c8536023d2182271daf1c3a32bb721acfd20", // 3862 + "2ad3f96b746b4f6591ad7b920e5645a1cbb6f822", // 3863 + "ca8d088db2d5b9117497b3c2b8d46b5c9a7c044e", // 3864 + "a23307e9759a5b3c87454c0a5f687e4795bdceaf", // 3865 + "5b66b76b2e0141cf2dbc98b1a0ce08ab56c1d3e5", // 3866 + "1027947ea0158625652d781b265003fc68621a64", // 3867 + "e563153bbb246a6f79cc3b0b01faf712529899d6", // 3868 + "50fe6a026ad9b3e3c3150daa64436525072e4865", // 3869 + "e32225a9962b657bf08d2995ac5e296f18cc443a", // 3870 + "8ea9b7cde441be66bd214c9a6912d1321d4afcf2", // 3871 + "8d5882585df3d4f7b9cbcfc46dd342bb48d0884e", // 3872 + "d45dbb6a7688abbcef8795af70eaffd7dd4486d0", // 3873 + "f3a91dacadf0acf3bdcc1a4b10bff66ae0b1ed39", // 3874 + "5091f04b82d961bca638f3d03ac90374bb7b76e0", // 3875 + "a849e882f2046a21f2a4a135102ca2cb27f78710", // 3876 + "f3d28306202935543d36a5956029bdbf6b71583f", // 3877 + "810aaad6cbfc34d8adb170b548915c75eab14ced", // 3878 + "dcc2e7fbfafe0e098a8604f64f3525ecb096b376", // 3879 + "cbc2e5e0f55e65076911aab1937867da495ba2df", // 3880 + "36536e7b252a9849d95c33571f9d370a2ceb49d4", // 3881 + "9309fade94464edc79b2f2553a87eacbfba1da57", // 3882 + "2ebd673bda3d49ea91ed4878af6320db48764224", // 3883 + "8eb54f5ff7953109a227f49a53eaa82df9df894c", // 3884 + "28797404dbcd27985abd7bc2ec572cba6aa9c4d6", // 3885 + "3917029f3cef29b0623405f5d7f45fb8e33dfbd9", // 3886 + "93b3a1902536122b7f5bb45f65ac2efdb7339297", // 3887 + "29492d83472bc914b38a2aa8c4e445e05c7b7600", // 3888 + "17ab8e6a55f5607f76dcac615d1fb0fe8b973a75", // 3889 + "171681e8edc666b1dbe8fe78e32d8eb00bb5a45f", // 3890 + "1cdd74b65879b74856055aa16d3be1a1e6c1cb07", // 3891 + "3853839a70a8d5d1f026ec424b41246ac685e76c", // 3892 + "0ab680301bcdc84f7b2ad8c7eda8afeca82137c3", // 3893 + "71763b503a9502818a43146e236dccc0d6b51040", // 3894 + "d23481af4a72422ad2027905df40ff99a77f6b36", // 3895 + "46e3d352641de249c583e0aa76cb79fdd22f3f14", // 3896 + "ef20a96a5619408b05f4c1c5bf5c30492e8a8339", // 3897 + "b2cd293f262f48270120eafee67dad1b3704314b", // 3898 + "32da0a0f60b2f8b4d327fc05a648b79dd27af169", // 3899 + "f27cfd6fb142fe545503d35a49ec9991840ed40f", // 3900 + "b956ed57e02e96c38bca51ae2173a2ecf40fec22", // 3901 + "4d4c68809740a24069774cf51daced4bd3005f61", // 3902 + "4f57fedd27a2e593ab3d0402a7ca1d8781386d7c", // 3903 + "2381b1d379537c758da2b2e1b08c71a36f9ecb3c", // 3904 + "466824f1056a72d4dc7f251877567fde4ebeb561", // 3905 + "bb8afc70ba55d5062dc89dc7d8bafe3eed0255ec", // 3906 + "1ae4da671c959865537d62aeef3b88ecdd5b68c6", // 3907 + "5673db7016dc43bfaf68c12c81015e458bdbfb9f", // 3908 + "3f4daddbaef6c070dac14d7edf7e2cb4c7f10a71", // 3909 + "5315f386a4001fbbee98845883d453f2909e07bd", // 3910 + "9f2369fcc8caf86b063b4f9992abcfd586eb3bbd", // 3911 + "cfa4e290a4e31c0eea3bd86ac90a2754afac8bd4", // 3912 + "6483ee2375c8569fb1689ebf2b680a720b1accb2", // 3913 + "cce6626e7aeea9c40120f251c1177c65a75df1ef", // 3914 + "97f9c6373fd67441f44d76ae926c0cff6f375400", // 3915 + "a8e8cfa177174b6eda15ab34fd02d932b351c371", // 3916 + "180bf358a6af6568ea389e81310de324c71ae68f", // 3917 + "277f7d79e3366627cc25a1cabf4a7195f5535dd3", // 3918 + "0627f8819619fbbf79e79feb038cc445203a9c0f", // 3919 + "ecfc8d456a9400d231c08cee1e7fa06a68deec5a", // 3920 + "2b99d642cd0121cf1970dfe0d22fd9613a28782e", // 3921 + "e8d5e064202001fd865d892d08aac35cbadaa111", // 3922 + "893e63a529a6058107346318885a52dfc5c53b60", // 3923 + "cac2c2b27697e727ebdc76c1174fd2be666a8fd6", // 3924 + "5e6bfca4f1dc5a982d6e2b63a5fddaeabdcc5762", // 3925 + "6bb8be0916fb44bea30e56f45eecb3faa38fe0dc", // 3926 + "ede9f6ee76cd445cc00e8a46c584d11720a32a9f", // 3927 + "daccccaef9cca59ed71a88d0e3495ec0a3b5af6f", // 3928 + "9037f6a93ed48a3385bc048b8ed4fa6556d84af2", // 3929 + "e7b63c89f886d827ccce00675b62f42e620b2e13", // 3930 + "4dfce274a054d9d4d5c0115c62cd4e0e2b78db5c", // 3931 + "eb3629c5762c73799cd25351fd23edb24c815170", // 3932 + "f63814123c5f42169d7247448040c7b798af0148", // 3933 + "6f0e0cb61bbc548c25d4032abe22e6a0bc10360d", // 3934 + "8d5e6ef609e6c7d93840185f31927d383625723e", // 3935 + "0665729fddc441aaab46343bf2d5afa4bf49c3e7", // 3936 + "95d48651d55509ce322888e3ad44a39986a01685", // 3937 + "9b7f45304b2220ff49b90ef59833041c7f7612ed", // 3938 + "99419b51be6160c5ac6703c90b89594d8e52250e", // 3939 + "f13f689d31388e834116dbca718a25a20f24006e", // 3940 + "bce174227f2bd3e34c4b8360a2b100e7b3ed5213", // 3941 + "06ea531a3708ed7dfebb51d3a4af3465bcae8618", // 3942 + "40fc80bf619999cc52922cd8cf0a576d328c1983", // 3943 + "6af8166422d54ce6492a92df6de4fd132d8a9006", // 3944 + "51716ce3bf58a7944f6c1bf3f4c231f53d55eb2b", // 3945 + "b9beaf43c80f79e38c2c1f7c4453984e78112cbe", // 3946 + "bb026bab0298ff7f2223e6212c9972ba1737bce5", // 3947 + "b945265384c1f48d9cfff0c2226c9a053533b9a2", // 3948 + "2a88497ae8c323b036c9f294fdb1bd4187f64077", // 3949 + "3f016b11101733b905596ed0a824f38cf2ee6869", // 3950 + "76427b8f7a608ee3cfdf05d0224ded2fe27fc2b4", // 3951 + "aaaccbd023c9fbbec7b33c9a262f55fc09d826f2", // 3952 + "5daebde9b0897a58d2b54354ef2b6a4172abea75", // 3953 + "78fcb6238fb24b99d519374b9fc8882edfbbdd18", // 3954 + "c1cac88d4f5ffa22a6427f0430201f32dcb18906", // 3955 + "6605e2d04819fbca44238bb127ada83255ecf8fd", // 3956 + "ae81f362daf9c4a6960a460f591a3a6b48d219f2", // 3957 + "c396c8f24b5c68929016c386cde5d8aeb1e8681c", // 3958 + "6e522c20703b14f80501e22100782d4fbfde5d76", // 3959 + "5f04431b7bed056aeb8e0e9233c87318c383b083", // 3960 + "f72e3c7fb93495f03575b730a8d508f235d90b14", // 3961 + "cd63817e19e99ec037b68df097ee88f7809860af", // 3962 + "a2427e66fd8c52170cc37060507609a537cbb9bf", // 3963 + "9e6c2a1acfa45de210f217afe52f38e5420ed3af", // 3964 + "abd3d9821c2605392af5bfe171a0239a3e9936ce", // 3965 + "b24f0a80800e896251fe0fcf8cb89657d112e72a", // 3966 + "6bb816c9189eb778a901bb6d17f33343c3d0b47b", // 3967 + "db3d35eb9d52be7c927221f236044dda245b30b0", // 3968 + "c092a4f874644bcbc661ce884b678c2e7f1c9d4c", // 3969 + "9fe7a15331ada1601d9947d9b07b58240dd08381", // 3970 + "f3c20cf83efd9e7fdf5aedefadd85f60964cdc40", // 3971 + "b64571fffa628b95da4fc2ce87de069e0abfbaf9", // 3972 + "7cb3a4227b3835c7a3828750d423d160de957d90", // 3973 + "d9f61e0daf7b3c144ed76bd5a865ad030a4b3627", // 3974 + "7cd4998ddb9906720e10d9a0680de2d9c582025c", // 3975 + "eab87ded76ae49e88f84f2e21883e01f8cfec59d", // 3976 + "96bfb0b9362611986f2a279b9c64e63b70cdb638", // 3977 + "2bc21bb77c2e38f772c22946689f17dc2e8a23db", // 3978 + "a8e848522bd0937ddc4cb8504fa1fd23862510bc", // 3979 + "29a27fe1ce82df0af89bd6aba49565acfcf91781", // 3980 + "b83d1f11e80021e97cd338484af1b22320a531eb", // 3981 + "a223e1089883fb3b89a563ab552be2b752858e13", // 3982 + "02cf4d9ad98ceb041b5bed810e12a72531ab5b9e", // 3983 + "084e8233b1f527608ecf34a19cf7a7c1bed3eb96", // 3984 + "acccea7c13fb0b22c297c9456d018236666a753d", // 3985 + "2d79b8c56680184cb1302ccec3235dbdfd34fe86", // 3986 + "230690b12a4f12ce0ecff6742ae70ab87ae43d34", // 3987 + "0219dc3e46ec002805907affffd24e878bf9140e", // 3988 + "2644b9742fa2e8fe33798adb22ae89cb4eebf18b", // 3989 + "17e1c214968e1e33a0e0ca1625f4be402e6540b6", // 3990 + "2672be9b46c81984bdfda12eb387cf9ad1c0093c", // 3991 + "a96ce54c5d77cf5b44cd9da9681575faf1970c93", // 3992 + "7ec099a55c3bd6ecf70ae8f4c72205851fc2b469", // 3993 + "79b8614ee79fbb10df9843439ce9027ac7a5bdc7", // 3994 + "1d665e040b9b5f63a72d4a01b930aacbeb421d91", // 3995 + "b55fa9d80b375539119b9fd02260be0b64c484e1", // 3996 + "454d4ce155e11dd3e7346d7ea9be22d0a47ee23b", // 3997 + "d4eaed375598018792bf614bb40b71c7211e8c04", // 3998 + "797a1beacd69b3e5397f0611e44c36ef382b7471", // 3999 + "38e4f3c0c14b64b1e112b7f4dc370fd962ad31de", // 4000 + "f2d865dc809dc8b1acd5e9a1a9b32e399c38cb87", // 4001 + "89b77ce3a2cf5b1f5f250fff0a90aa3d52899aa5", // 4002 + "77555aeecaa52f0e7b6f47b13e7e2eb41ef7b857", // 4003 + "5322983a74be316addaf061938864e5a7f53d857", // 4004 + "9d28d432684e2530e31c5fdb4988bfe765709780", // 4005 + "a6dc639907c18265824b93c65a0c542478236493", // 4006 + "e6dd5d8eb2e1fd35d0c8f9fd1c71145f6ffa675c", // 4007 + "4c37126045b8fd406aac377d739de8fb1bd9156b", // 4008 + "e697b9c7730d448b1b1fc735d627a19a485c9c7b", // 4009 + "1252e803d16c6c52f9818d2995471a8eb313217d", // 4010 + "5dc742cb6b764911ec1d3bb194eab697062ba3aa", // 4011 + "b174addb20d6e690e0878bd608b974c21ce876b0", // 4012 + "769bd785e066b57a66e42b9556ece10cc73f33c9", // 4013 + "3135a167b51e215a5134b174fe7c9cf3b415a2bd", // 4014 + "ffd09b996b85ed3e1a61fcc49277063c546636cf", // 4015 + "0036f971c26f1d8fb4d9ace9e5e1a898618021b1", // 4016 + "8e288f3f4068a5e633cb80e67626d685b962b64e", // 4017 + "95a9b0985c8610aa952f22a1635b9150d49d08d8", // 4018 + "bc0bbfe726fdf7769185bcb35e34e7554c90f02e", // 4019 + "81b7a6629ced062cdbe6d8be44c761772521a623", // 4020 + "5e7f39834165ebff8a08439dbaf589a1fb56bc63", // 4021 + "a7d3a6ae8c9af283806ea9f919c371a48662817e", // 4022 + "d23677307547f0957767c3921654cf068ba84af6", // 4023 + "3da78d8c59f4e36d0d47b213404d3491cd586a82", // 4024 + "926fcdfc567e04ca569697abd1b1b7374e70715c", // 4025 + "27d696c53c8f92236ed62bbf0d10fe0131345d3b", // 4026 + "d7d7e4cdbff5d9889c851afde72788ba21de891e", // 4027 + "aaf439b24a88cf391dc17418af636f07485c87f5", // 4028 + "e5aa73644503ee68a71acba2250626b5db82827c", // 4029 + "4cddc8dfdc6e0e06ce2734b9035613e48bfc1abc", // 4030 + "485454400ff5d3b2aa0be8a213a2c00cdebb8d3d", // 4031 + "ad1fc525193a00b45f79bc085277eff1ffac98eb", // 4032 + "c45c81e7f5aca1b4421fd781fb1f243a53efb033", // 4033 + "2a1ec741c9f0fef5db570385301f358b7285a791", // 4034 + "7da79c4f9c541140700e08088acec25877741808", // 4035 + "0daef0a296141b833f6d4e88d3ce893a6fae6684", // 4036 + "88011f5f76b010e3551d2bf19e8384d6abedd95f", // 4037 + "15844a9a29c0c6e7b462e6f5c85680ad6dab012c", // 4038 + "4910e756e0a33386ed7783ed6e719dc6145e8905", // 4039 + "fe035232f8b4a0bd5e9944a4ab86c2d9c5e81448", // 4040 + "b9a406ede5bab740e10303e6578f33f6ed3876f0", // 4041 + "41dbc9e0642a31d36b003b997fb7cf817c4872b2", // 4042 + "84f4a04ccfa24c36dbbe29ee2086d45a6fb8c500", // 4043 + "21f6604c077e079e97578c6cf3ff2d66ac1c7494", // 4044 + "680c8430900b7b4560182def1c74d98cce671394", // 4045 + "6789ca56fa7418f0aeae4cf3744d06d1d25bcf69", // 4046 + "2900162b964d42c5e8d3c513d25f592a3dccba2d", // 4047 + "86c10cfd5937180da16595727525d3371073a375", // 4048 + "ea6ce93cad9c7e6bde6fc8ac8062520ec6c7603f", // 4049 + "a9cedd548ecbb427d2dac63c8896e66ae92c9d37", // 4050 + "a25aa4e8c28b551d71a19ec31c67c9a70e6cab31", // 4051 + "51bf97c7ba410e35e17e6e21ce39db869d97296e", // 4052 + "9bca50a1988b0de2a47c2056096b14ec5d4dac41", // 4053 + "e52e559a539e86245356846b3ba164e5a71591dd", // 4054 + "fb214beed5fc3bc367d62046b07ae7300c248e80", // 4055 + "8ccc091f088896333ec6e7da0b8fa8b1c13ea16f", // 4056 + "775490c38969b1a7715319954aa4e0fcdaa83e1d", // 4057 + "b4690ebe23f41d1e03b979ba6afaf390b1a03459", // 4058 + "a980a38041f8aa289b7e94e17edf5878d7705878", // 4059 + "2481b5adac081a98e747d0b242f73ca4d5cea919", // 4060 + "98cccfdbfd5dbe672ca84522cfa73905076644e1", // 4061 + "6fc60edbd1a608cf17021ad395a2579047933473", // 4062 + "98cb758300cad5a40be5d4925572a796d5891ef3", // 4063 + "f93919d3caa5a01c044e307389ce74aeb7acc351", // 4064 + "d3b921c894949575b0de748175b743e41839e509", // 4065 + "2113f7a004ca15c5bcb61437db737bcfdf1c5d56", // 4066 + "ebec08312119f9173e802926f178d176a8f1bde7", // 4067 + "3f080268b5c4a772270557b17f0f00fbcf8c5440", // 4068 + "5f3c60a511786ebe9c211ac286a6a46d576e3fce", // 4069 + "70183eafa7a6506a0847dda2cadf877001db3411", // 4070 + "f7be985d57be00912e72ab43c10df258c3c22fd6", // 4071 + "b16cf86f6d158199f6017c7f0dd75c91115568c6", // 4072 + "6d2e3f5cdf85f54fc658cfdc1d4c71d4edfa131b", // 4073 + "1f85dbc1bb0dead4ea0557de6d77b290bb508fd5", // 4074 + "4935cefd5237c63e864af4d2e76d506d3fa6149f", // 4075 + "8f7410ffd5601e4f0f22a5c9fc9ebe9443823be3", // 4076 + "4d84fefafa4a67b5f33e02557cd4c2aed3f98696", // 4077 + "34e26bb5c5069ff74d2ade7788555596c36efe19", // 4078 + "39234cebb1e10d187c262ae19d4b933476490fb6", // 4079 + "4834c5d9e0b3b3df371df3200ef6406daa627062", // 4080 + "96814ced895aff2034da8ab3f305d8760312fb78", // 4081 + "60dc767da6298f3b36fd465dd53a3e0227fada6c", // 4082 + "07ebf70ae3e6d7f81b9c8a3b353cf252946c3485", // 4083 + "d75a9d16adccabc779c58eb70d6afa335803a2e6", // 4084 + "08883d07d9a1cc4993b6064eced02c70c747ea80", // 4085 + "f0ae74eb105ab4ee10d022665c26ad745a0f103e", // 4086 + "c5a7ebe66056fd0556c83f6bebdae0b5911130ce", // 4087 + "f5543395fda7b9e03af043f748ed45b15d631474", // 4088 + "04748608fb456d09c05e20944b396704dee174c4", // 4089 + "a1b194e96a61b0be26dba0366e483c214701b1da", // 4090 + "5dea0de352cd8cba84035271b11495be5f1a5199", // 4091 + "6b682d6320a248913b0f340241c86f42d924b9f3", // 4092 + "eec5aaa00c041af494231dd504e81e6ee0d043ff", // 4093 + "a0f9bdf05623fc8735868d26741a8aa4dac7e0da", // 4094 + "10236568a284fb3733bd87c15280af95bd528839", // 4095 + "8c51fb6a0b587ec95ca74acfa43df7539b486297", // 4096 + "32344e25e91c0b07d5216de49628ee243813c0ed", // 4097 + NULL +}; + +static void test_4097(void) +{ + // this miserable test just checks each possible string length from + // 0 to 4097 characters long of the letter 'a' and compares the sha1 + // against a known good implemention's output + char buf[4098]; + int i; + + for (i = 0; i < 4098; i++) { + buf[i] = 'a'; + _checksha(buf, i, known_sha1s[i]); + } +} + diff --git a/doc/README.cyrusdb.md b/doc/README.cyrusdb.md index 646a7ddd8b..d2d630324e 100644 --- a/doc/README.cyrusdb.md +++ b/doc/README.cyrusdb.md @@ -44,7 +44,7 @@ There are also some tools to work with and support Cyrus databases: Performs maintenance on the cyrusdb subsystem. This is called in two places: - START: `ctl_cyrusdb -r` (recovery). This is the *ONLY PLACE* that code is guaranteed to be run at startup on every Cyrus installation, so you'll find quite a lot of detritus has built up in this codepath over the years. - - EVENTS: `ctl_cyrusdb -c` (checkpoint). This is run regularly (`period=180` at FastMail, examples in the codebase have `period=5` or `period=30`). Both this codepath and `cyr_expire` tend to run periodically on Cyrus systems, and cleanup code is spread between those two locations. + - EVENTS: `ctl_cyrusdb -c` (checkpoint). This is run regularly (`period=180` at Fastmail, examples in the codebase have `period=5` or `period=30`). Both this codepath and `cyr_expire` tend to run periodically on Cyrus systems, and cleanup code is spread between those two locations. ### `imap/cvt_cyrusdb` diff --git a/doc/README.xapian b/doc/README.xapian index 71aaeed04c..4d7d9aeadb 100644 --- a/doc/README.xapian +++ b/doc/README.xapian @@ -51,7 +51,7 @@ startup: cyrus.conf: -START { +DAEMON { # run a rolling squatter squatter cmd="squatter -R" } @@ -61,4 +61,4 @@ you'll probably want to read: http://lists.tartarus.org/pipermail/xapian-discuss/2014-October/009112.html -And see how we do it at FastMail. +And see how we do it at Fastmail. diff --git a/doc/examples/cyrus_conf/cmu-backend.conf b/doc/examples/cyrus_conf/cmu-backend.conf index c280fc4ae0..66987fe782 100644 --- a/doc/examples/cyrus_conf/cmu-backend.conf +++ b/doc/examples/cyrus_conf/cmu-backend.conf @@ -8,14 +8,13 @@ START { mupdatepush cmd="ctl_mboxlist -m" } -# UNIX sockets start with a slash and are put into /var/imap/sockets +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # add or remove based on preferences imap cmd="imapd" listen="imap" prefork=5 imaps cmd="imapd -s" listen="imaps" prefork=1 pop3 cmd="pop3d" listen="pop3" prefork=0 pop3s cmd="pop3d -s" listen="pop3s" prefork=0 - kpop cmd="pop3d -k" listen="kpop" prefork=0 sieve cmd="timsieved" listen="sieve" prefork=0 # fud diff --git a/doc/examples/cyrus_conf/cmu-frontend.conf b/doc/examples/cyrus_conf/cmu-frontend.conf index 7dc818a5c3..0ee3934e1e 100644 --- a/doc/examples/cyrus_conf/cmu-frontend.conf +++ b/doc/examples/cyrus_conf/cmu-frontend.conf @@ -4,7 +4,7 @@ START { mboxlist cmd="ctl_cyrusdb -r" } -# UNIX sockets start with a slash and are put into /var/imap/sockets +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # mupdate database service - must prefork atleast 1 mupdate cmd="/usr/cyrus/bin/mupdate" listen=2004 prefork=1 @@ -14,7 +14,6 @@ SERVICES { imaps cmd="proxyd -s" listen="imaps" prefork=1 pop3 cmd="pop3d" listen="pop3" prefork=0 pop3s cmd="pop3d -s" listen="pop3s" prefork=0 - kpop cmd="pop3d -k" listen="kpop" prefork=0 sieve cmd="timsieved" listen="sieve" prefork=0 # fud diff --git a/doc/examples/cyrus_conf/murder-backend.conf b/doc/examples/cyrus_conf/murder-backend.conf index f3ca86c1ee..cfe97d4252 100644 --- a/doc/examples/cyrus_conf/murder-backend.conf +++ b/doc/examples/cyrus_conf/murder-backend.conf @@ -10,7 +10,7 @@ START { mupdatepush cmd="ctl_mboxlist -m" } -# UNIX sockets start with a slash and are put into /var/imap/socket +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # add or remove based on preferences imap cmd="imapd" listen="imap" prefork=0 @@ -29,10 +29,10 @@ SERVICES { # at least one LMTP is required for delivery # lmtp cmd="lmtpd" listen="lmtp" prefork=0 - lmtpunix cmd="lmtpd" listen="/var/imap/socket/lmtp" prefork=0 + lmtpunix cmd="lmtpd" listen="/run/cyrus/socket/lmtp" prefork=0 # this is required if using notifications -# notify cmd="notifyd" listen="/var/imap/socket/notify" proto="udp" prefork=1 +# notify cmd="notifyd" listen="/run/cyrus/socket/notify" proto="udp" prefork=1 } EVENTS { diff --git a/doc/examples/cyrus_conf/murder-frontend.conf b/doc/examples/cyrus_conf/murder-frontend.conf index 5474771176..33e6988bae 100644 --- a/doc/examples/cyrus_conf/murder-frontend.conf +++ b/doc/examples/cyrus_conf/murder-frontend.conf @@ -5,7 +5,7 @@ START { recover cmd="ctl_cyrusdb -r" } -# UNIX sockets start with a slash and are put into /var/imap/socket +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # proxies that will connect to the backends, add or remove based on # site preferences (lmtp below) @@ -32,7 +32,7 @@ SERVICES { mupdate cmd="mupdate" listen="mupdate" prefork=1 # this is required if using notifications -# notify cmd="notifyd" listen="/var/imap/socket/notify" proto="udp" prefork=1 +# notify cmd="notifyd" listen="/run/cyrus/socket/notify" proto="udp" prefork=1 } EVENTS { diff --git a/doc/examples/cyrus_conf/normal-master.conf b/doc/examples/cyrus_conf/normal-master.conf index b6421ba9c1..3db8c13a5a 100644 --- a/doc/examples/cyrus_conf/normal-master.conf +++ b/doc/examples/cyrus_conf/normal-master.conf @@ -10,7 +10,7 @@ START { offsitesync cmd="sync_client -r -n offsite" } -# UNIX sockets start with a slash and are put into /var/imap/socket +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # add or remove based on preferences imap cmd="imapd" listen="imap" prefork=0 @@ -29,13 +29,13 @@ SERVICES { # at least one LMTP is required for delivery # lmtp cmd="lmtpd" listen="lmtp" prefork=0 - lmtpunix cmd="lmtpd" listen="/var/imap/socket/lmtp" prefork=0 + lmtpunix cmd="lmtpd" listen="/run/cyrus/socket/lmtp" prefork=0 # this is requied if using socketmap -# smmap cmd="smmapd" listen="/var/imap/socket/smmap" prefork=0 +# smmap cmd="smmapd" listen="/run/cyrus/socket/smmap" prefork=0 # this is required if using notifications -# notify cmd="notifyd" listen="/var/imap/socket/notify" proto="udp" prefork=1 +# notify cmd="notifyd" listen="/run/cyrus/socket/notify" proto="udp" prefork=1 } EVENTS { diff --git a/doc/examples/cyrus_conf/normal-replica.conf b/doc/examples/cyrus_conf/normal-replica.conf index 9c46c0e7a4..1ea933ed25 100644 --- a/doc/examples/cyrus_conf/normal-replica.conf +++ b/doc/examples/cyrus_conf/normal-replica.conf @@ -5,7 +5,7 @@ START { recover cmd="ctl_cyrusdb -r" } -# UNIX sockets start with a slash and are put into /var/imap/socket +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # add or remove based on preferences imap cmd="imapd" listen="imap" prefork=0 @@ -24,10 +24,10 @@ SERVICES { # at least one LMTP is required for delivery # lmtp cmd="lmtpd" listen="lmtp" prefork=0 - lmtpunix cmd="lmtpd" listen="/var/imap/socket/lmtp" prefork=0 + lmtpunix cmd="lmtpd" listen="/run/cyrus/socket/lmtp" prefork=0 # this is requied if using socketmap -# smmap cmd="smmapd" listen="/var/imap/socket/smmap" prefork=0 +# smmap cmd="smmapd" listen="/run/cyrus/socket/smmap" prefork=0 # Synchronization for remote replication. # Note: This usage is deprecated. Modern (post 3.0) Cyrus supports @@ -35,7 +35,7 @@ SERVICES { # syncserver cmd="sync_server" listen="csync" # this is required if using notifications -# notify cmd="notifyd" listen="/var/imap/socket/notify" proto="udp" prefork=1 +# notify cmd="notifyd" listen="/run/cyrus/socket/notify" proto="udp" prefork=1 } EVENTS { diff --git a/doc/examples/cyrus_conf/normal.conf b/doc/examples/cyrus_conf/normal.conf index 61f88876e8..9af28cbecf 100644 --- a/doc/examples/cyrus_conf/normal.conf +++ b/doc/examples/cyrus_conf/normal.conf @@ -5,7 +5,7 @@ START { recover cmd="ctl_cyrusdb -r" } -# UNIX sockets start with a slash and are put into /var/imap/socket +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # add or remove based on preferences imap cmd="imapd" listen="imap" prefork=0 @@ -24,13 +24,13 @@ SERVICES { # at least one LMTP is required for delivery # lmtp cmd="lmtpd" listen="lmtp" prefork=0 - lmtpunix cmd="lmtpd" listen="/var/imap/socket/lmtp" prefork=0 + lmtpunix cmd="lmtpd" listen="/run/cyrus/socket/lmtp" prefork=0 # this is requied if using socketmap -# smmap cmd="smmapd" listen="/var/imap/socket/smmap" prefork=0 +# smmap cmd="smmapd" listen="/run/cyrus/socket/smmap" prefork=0 # this is required if using notifications -# notify cmd="notifyd" listen="/var/imap/socket/notify" proto="udp" prefork=1 +# notify cmd="notifyd" listen="/run/cyrus/socket/notify" proto="udp" prefork=1 } EVENTS { diff --git a/doc/examples/cyrus_conf/prefork.conf b/doc/examples/cyrus_conf/prefork.conf index 53dadf49da..186fe6629b 100644 --- a/doc/examples/cyrus_conf/prefork.conf +++ b/doc/examples/cyrus_conf/prefork.conf @@ -5,7 +5,7 @@ START { recover cmd="ctl_cyrusdb -r" } -# UNIX sockets start with a slash and are put into /var/imap/sockets +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # add or remove based on preferences imap cmd="imapd" listen="imap" prefork=5 @@ -24,13 +24,13 @@ SERVICES { # at least one LMTP is required for delivery # lmtp cmd="lmtpd" listen="lmtp" prefork=0 - lmtpunix cmd="lmtpd" listen="/var/imap/socket/lmtp" prefork=1 + lmtpunix cmd="lmtpd" listen="/run/cyrus/socket/lmtp" prefork=1 # this is requied if using socketmap -# smmap cmd="smmapd" listen="/var/imap/socket/smmap" prefork=1 +# smmap cmd="smmapd" listen="/run/cyrus/socket/smmap" prefork=1 # this is only necessary if using notifications -# notify cmd="notifyd" listen="/var/imap/socket/notify" proto="udp" prefork=1 +# notify cmd="notifyd" listen="/run/cyrus/socket/notify" proto="udp" prefork=1 } EVENTS { diff --git a/doc/examples/cyrus_conf/small.conf b/doc/examples/cyrus_conf/small.conf index 89726f8f47..d3d8b1e569 100644 --- a/doc/examples/cyrus_conf/small.conf +++ b/doc/examples/cyrus_conf/small.conf @@ -5,17 +5,17 @@ START { recover cmd="ctl_cyrusdb -r" } -# UNIX sockets start with a slash and are put into /var/imap/sockets +# UNIX sockets start with a slash and are put into /run/cyrus/socket SERVICES { # add or remove based on preferences imap cmd="imapd" listen="imap" prefork=0 pop3 cmd="pop3d" listen="pop3" prefork=0 # LMTP is required for delivery - lmtpunix cmd="lmtpd" listen="/var/imap/socket/lmtp" prefork=0 + lmtpunix cmd="lmtpd" listen="/run/cyrus/socket/lmtp" prefork=0 # this is only necessary if using notifications -# notify cmd="notifyd" listen="/var/imap/socket/notify" proto="udp" prefork=1 +# notify cmd="notifyd" listen="/run/cyrus/socket/notify" proto="udp" prefork=1 } EVENTS { diff --git a/doc/examples/imapd_conf/murder-backend.conf b/doc/examples/imapd_conf/murder-backend.conf index d3aea839f8..aa99edbfe7 100644 --- a/doc/examples/imapd_conf/murder-backend.conf +++ b/doc/examples/imapd_conf/murder-backend.conf @@ -9,7 +9,7 @@ admins: cyrus murder ## Cyrus Aggregation - Murder - backend configuration. ## This server will hold the actual mailboxes and may interact with ## clients, in addition to frontend proxies. For more information: -## http://cyrusimap.org/imap/reference/admin/murder/murder-installation.html +## http://www.cyrusimap.org/imap/reference/admin/murder/murder-installation.html ################################################################### servername: mailbox.example.org diff --git a/doc/examples/imapd_conf/murder-frontend.conf b/doc/examples/imapd_conf/murder-frontend.conf index d7340a6210..df566cfaa9 100644 --- a/doc/examples/imapd_conf/murder-frontend.conf +++ b/doc/examples/imapd_conf/murder-frontend.conf @@ -10,7 +10,7 @@ admins: cyrus murder ## This server will merely refer clients to the pertinent backend, ## or proxy requests for them, as dictated by local security regime. ## For more information: -## http://cyrusimap.org/imap/reference/admin/murder/murder-installation.html +## http://www.cyrusimap.org/imap/reference/admin/murder/murder-installation.html ################################################################### # How this host identifies itself to other members of the murder. diff --git a/doc/examples/imapd_conf/murder-mupdate.conf b/doc/examples/imapd_conf/murder-mupdate.conf index c5f2590e92..33e6a20833 100644 --- a/doc/examples/imapd_conf/murder-mupdate.conf +++ b/doc/examples/imapd_conf/murder-mupdate.conf @@ -6,7 +6,7 @@ admins: cyrus postman ################################################################### ## Mupdate-master configuration section. For more information: -## http://cyrusimap.org/imap/reference/admin/murder/murder-installation.html +## http://www.cyrusimap.org/imap/reference/admin/murder/murder-installation.html ################################################################### servername: postman.example.org allowallsubscribe: true diff --git a/doc/examples/imapd_conf/normal-master.conf b/doc/examples/imapd_conf/normal-master.conf index 1f42f5db8e..e7d66bf2b7 100644 --- a/doc/examples/imapd_conf/normal-master.conf +++ b/doc/examples/imapd_conf/normal-master.conf @@ -10,7 +10,7 @@ admins: cyrus ## This is how the Master (sync client) is defined. In this example, ## we define a pair of replicas, each with its own channel & shutdown ## file. For more details, please see: -## http://cyrusimap.org/imap/reference/admin/sop/replication.html +## http://www.cyrusimap.org/imap/reference/admin/sop/replication.html ################################################################### servername: mailbox.example.org # sync_authname **MUST** be an "admin" user on the replica. diff --git a/doc/examples/imapd_conf/normal-replica1.conf b/doc/examples/imapd_conf/normal-replica1.conf index 7147f97ca0..5b04ede272 100644 --- a/doc/examples/imapd_conf/normal-replica1.conf +++ b/doc/examples/imapd_conf/normal-replica1.conf @@ -9,7 +9,7 @@ admins: cyrus mailproxy ## Replication support ## The Replica (via imap) should identify itself as expected by the ## master. For more details, please see: -## http://cyrusimap.org/imap/reference/admin/sop/replication.html +## http://www.cyrusimap.org/imap/reference/admin/sop/replication.html ################################################################### servername: mailrepl1.example.org diff --git a/doc/internal/mailbox-api.html b/doc/internal/mailbox-api.html index e76bc34f7f..c78b6bcd6f 100644 --- a/doc/internal/mailbox-api.html +++ b/doc/internal/mailbox-api.html @@ -157,6 +157,7 @@

Creating, renaming and deleting

int mailbox_rename_copy(struct mailbox *oldmailbox, const char *newname, const char *newpart, const char *userid, int ignorequota, + int silent, struct mailbox **newmailboxptr); diff --git a/doc/internal/unit-tests.html b/doc/internal/unit-tests.html index 8658ca126d..ae02580ebe 100644 --- a/doc/internal/unit-tests.html +++ b/doc/internal/unit-tests.html @@ -458,7 +458,7 @@

Table of Contents

cunit/binhex.testc \ cunit/bitvector.testc \ cunit/buf.testc \ - cunit/byteorder64.testc \ + cunit/byteorder.testc \ cunit/charset.testc \ cunit/crc32.testc \ cunit/dlist.testc \ diff --git a/doc/legacy/ag.html b/doc/legacy/ag.html index 4e84fb48dd..bcc8be3c27 100644 --- a/doc/legacy/ag.html +++ b/doc/legacy/ag.html @@ -3,7 +3,6 @@ Cyrus IMAP Server: Cyrus Murder Concepts -

Cyrus IMAP Server: Cyrus Murder Concepts

@@ -65,7 +64,7 @@

1.0 Overview

href="#AB">Appendix B: IMAP Multiplexing).

We propose a new approach to overcome these problems. We call it -the the Cyrus IMAP Aggregator. The Cyrus aggregator takes a murder of IMAP servers and presents a server independent view to the clients. That is, all the mailboxes across all the IMAP servers are aggregated to a single image, thereby appearing @@ -279,7 +278,7 @@

3.6 COPY

mailbox.

In the case where the destination mailbox is on the same back end -server as the the source folder, the COPY command is issued +server as the source folder, the COPY command is issued to the back end server and the back end server takes care of the command.

@@ -328,7 +327,7 @@

3.7 Operations on the Mailbox List

by the proxies).
  • proxyd -> back end: duplicate CREATE command and verifies - that the CREATE does not create an inconsistency in + that the CREATE does not create an inconsistency in the mailbox list (i.e. the folder name is still unique).
  • @@ -602,8 +601,8 @@

    Appendix C: Definitions

    client
    A client is a process on a remote computer that communicates with -the set of servers distributing mail data, be they ACAP, IMAP, LDAP, -or IMSP servers. A client opens one or more connections to various +the set of servers distributing mail data, be they ACAP, IMAP, or LDAP +servers. A client opens one or more connections to various servers.
    diff --git a/doc/legacy/changes.html b/doc/legacy/changes.html index e0c20b2401..70d094367c 100644 --- a/doc/legacy/changes.html +++ b/doc/legacy/changes.html @@ -13,7 +13,7 @@

    Changes in 3.0.0-beta3

    • This document is no longer being maintained, and will be removed soon. Please refer to the release notes for the respective version, in the doc/html/ directory of the source distribution, or on the -Cyrus IMAP +Cyrus IMAP website.

    Changes in 3.0.0-beta2 since the 2.5.x series

    @@ -1798,7 +1798,7 @@

    Changes to the Cyrus IMAP Server since 2.0.16

  • altnamespace: it is now possible to display user mailboxes as siblings to the INBOX at the top-level (Ken Murchison)
  • -
  • unixhierarchysep: it is now possible possible to use slash as +
  • unixhierarchysep: it is now possible to use slash as the hierarchy separator, instead of a period. (Ken Murchison, inspired by David Fuchs, dfuchs@uniserve.com)
  • @@ -2078,7 +2078,7 @@

    Changes to the Cyrus IMAP Server since 2.0.6

    names in cyrus.conf. also logs even more verbosely (see bug #115.) -
  • libwrap_init() is now inside the loop, since i don't quite +
  • libwrap_init() is now inside the loop, since I don't quite understand the semantics of libwrap calls.
  • setquota in cyradm now behaves more sanely (and gives correct @@ -2089,7 +2089,7 @@

    Changes to the Cyrus IMAP Server since 2.0.6

  • small fixes in timsieved.
  • -
  • added a "make dist" target so i won't dread releases as +
  • added a "make dist" target so I won't dread releases as much.
  • @@ -2843,7 +2843,7 @@

    Changes to the Cyrus IMAP Server Since Version 1.4

    "--with-statedir=DIR" option, which defaults to "/var". -
  • Bug fix: by using an certain address form, one could deliver to +
  • Bug fix: by using a certain address form, one could deliver to a user's mailbox bypassing the ACL's.
  • Bug fix: un-fold header lines when parsing for the diff --git a/doc/legacy/faq.html b/doc/legacy/faq.html index ff7aa72f37..e0c5322766 100644 --- a/doc/legacy/faq.html +++ b/doc/legacy/faq.html @@ -197,24 +197,6 @@

    Troubleshooting

    details.

    -
    Q: My KPOP client is complaining about TLS keys. What -should I do?
    - -
    -

    A: Disable TLS for the kpop service. Either set -tls_pop3_cert_file to disabled in -imapd.conf (which will also disable SSL/TLS for pop3), or -use a separate config file for kpop. For example, change the kpop -service in cyrus.conf to something like:

    - -
    -kpop    cmd="pop3d -k -C /etc/kpopd.conf" listen="kpop"
    -
    - -

    then copy /etc/imapd.conf to /etc/kpopd.conf and -remove the tls_* options.

    -
    -
    Q: Eudora 5.x can't connect using STARTTLS ("SSL Neogotiation Failed"). What should I do?
    diff --git a/doc/legacy/index.html b/doc/legacy/index.html index 1702bef719..0a8fa8f7bc 100644 --- a/doc/legacy/index.html +++ b/doc/legacy/index.html @@ -19,7 +19,7 @@

    Cyrus IMAP Server, version 3.0

    sealed servers, where normal users are not permitted to log in. The mailbox database is stored in parts of the filesystem that are private to the Cyrus IMAP system. All user access to messages is -through the IMAP, POP3, KPOP or NNTP protocols.

    +through the IMAP, POP3, JMAP or NNTP protocols.

    The private mailbox database design gives the server large advantages in efficiency, scalability, and administrability. diff --git a/doc/legacy/install-configure.html b/doc/legacy/install-configure.html index ce7413921b..8552174429 100644 --- a/doc/legacy/install-configure.html +++ b/doc/legacy/install-configure.html @@ -188,12 +188,10 @@

    Installing and configuring the IMAP Server

    pop3 110/tcp nntp 119/tcp imap 143/tcp - imsp 406/tcp nntps 563/tcp acap 674/tcp imaps 993/tcp pop3s 995/tcp - kpop 1109/tcp lmtp 2003/tcp sieve 4190/tcp fud 4201/udp @@ -201,7 +199,7 @@

    Installing and configuring the IMAP Server

  • Remove "/etc/[x]inetd.conf" entries. Any imap, imaps, pop3, pop3s, -kpop, lmtp and sieve lines need to be +lmtp and sieve lines need to be removed from /etc/[x]inetd.conf and [x]inetd needs to be restarted. @@ -257,7 +255,7 @@

    Configuring the Master Process

    master process has gone away or a suitable time has expired (maybe 10 seconds). You can then send a SIGTERM if the process still exists. -

    At FastMail the following snippet of perl is used (warning: Linux +

    At Fastmail the following snippet of perl is used (warning: Linux specific signal numbers - check your own system before using this):

    diff --git a/doc/legacy/install-murder.html b/doc/legacy/install-murder.html
    index bb05450fcb..6b426720a1 100644
    --- a/doc/legacy/install-murder.html
    +++ b/doc/legacy/install-murder.html
    @@ -181,7 +181,6 @@ 

    Configuring the frontends

    imaps cmd="proxyd -s" listen="imaps" prefork=1 pop3 cmd="pop3d" listen="pop3" prefork=0 pop3s cmd="pop3d -s" listen="pop3s" prefork=0 - kpop cmd="pop3d -k" listen="kpop" prefork=0 nntp cmd="nntpd" listen="nntp" prefork=0 nntps cmd="nntpd -s" listen="nntps" prefork=0 sieve cmd="timsieved" listen="sieve" prefork=0 diff --git a/doc/legacy/install-sieve.html b/doc/legacy/install-sieve.html index 84eec77c89..49dff9d722 100644 --- a/doc/legacy/install-sieve.html +++ b/doc/legacy/install-sieve.html @@ -63,9 +63,12 @@

    Testing the sieve server

    Trying 128.2.10.192... Connected to foobar.andrew.cmu.edu. Escape character is '^]'. - "IMPLEMENTATION" "Cyrus timsieved v1.1.0" + "IMPLEMENTATION" "Cyrus timsieved v3.8.3" + "VERSION" "1.0" "SASL" "ANONYMOUS PLAIN KERBEROS_V4 GSSAPI" "SIEVE" "fileinto reject envelope vacation imapflags notify subaddress regex" + "NOTIFY" "mailto" + "UNAUTHENTICATE" OK
    diff --git a/doc/legacy/install-upgrade.html b/doc/legacy/install-upgrade.html index c3212dd627..5c6eb704d2 100644 --- a/doc/legacy/install-upgrade.html +++ b/doc/legacy/install-upgrade.html @@ -309,7 +309,7 @@

    Upgrading from 2.3.9

    for replication has been changed to be the SHA1 hash of the messages. If you wish to upgrade the existing GUIDs in particular mailbox(es) or the entire server, perform the following steps in the listed order. -Note that is is NOT REQUIRED that existing GUIDs be upgraded. +Note that it is NOT REQUIRED that existing GUIDs be upgraded.
    1. Zero GUIDs on the replica (reconstruct -g)
    2. Regenerate GUIDs on the master (reconstruct -G)
    3. @@ -706,7 +706,7 @@

      Upgrading from 1.6.22 or 1.6.24

    4. cyrus.seen conversion. The cyrus.seen file will be automatically upgraded as users read mail. After some time, you might want to -delete the cyrus.seen file in each mailbox; it is superceded by the +delete the cyrus.seen file in each mailbox; it is superseded by the user/joe.seen file.
    5. cyrus.index conversion. The cyrus.index file will be diff --git a/doc/legacy/overview.html b/doc/legacy/overview.html index 40727523b2..03018af40d 100644 --- a/doc/legacy/overview.html +++ b/doc/legacy/overview.html @@ -21,7 +21,7 @@

      Cyrus IMAP Server: Overview and Concepts

      where users are not normally permitted to log in. The mailbox database is stored in parts of the filesystem that are private to the Cyrus IMAP system. All user access to mail is through software -using the IMAP, IMAPS, POP3, POP3S, or KPOP protocols. +using the IMAP, IMAPS, POP3, POP3S, or JMAP protocols.

      The private mailbox database design gives the server large advantages in efficiency, scalability, and administrability. Multiple @@ -525,7 +525,8 @@

      Quota Warnings Upon Select When User Has "d to or over the limit and the user has "d" rights on the mailbox, the server will issue an alert notifying the user that usage is close to or over the limit. The threshold of usage at which the server will -issue quota warnings is set by the "quotawarn" configuration option. +issue quota warnings is set by the "quotawarnpercent" configuration +option.

      The server only issues warnings when the user has "d" rights because only users with "d" rights are capable of @@ -617,8 +618,6 @@

      POP3 Server

      "imap.host@realm", where "host" is the first component of the server's host name and "realm" is the server's Kerberos realm. -When the POP3 server is invoked with the "-k" switch, the -server exports MIT's KPOP protocol instead of generic POP3.

      The syslog facility

      diff --git a/doc/legacy/readme.html b/doc/legacy/readme.html index 758234927e..daf8468260 100644 --- a/doc/legacy/readme.html +++ b/doc/legacy/readme.html @@ -13,7 +13,7 @@

      Cyrus IMAP Server Release Information

      is generally intended to be run on "sealed" servers, where normal users are not permitted to log in. The mailbox database is stored in parts of the filesystem that are private to the Cyrus IMAP system. -All user access to mail is through the IMAP, NNTP, POP3, or KPOP protocols. +All user access to mail is through the IMAP, NNTP, POP3, or JMAP protocols.

      The private mailbox database design gives the server large advantages in efficiency, scalability, and administrability. diff --git a/doc/legacy/sieve.html b/doc/legacy/sieve.html index 31b263f172..6acbc956f5 100644 --- a/doc/legacy/sieve.html +++ b/doc/legacy/sieve.html @@ -6,7 +6,7 @@

      Sieve: A mail filtering language

      -Sieve is a Internet standards-track protocol for filtering messages on +Sieve is an Internet standards-track protocol for filtering messages on delivery. The Cyrus Sieve implementation supports filing messages into specific folders, forwarding messages, rejecting messages, and the standard vacation function. It can reply to messages based on diff --git a/docsrc/Makefile b/docsrc/Makefile index 49b28fd3a5..0bb157b3a8 100644 --- a/docsrc/Makefile +++ b/docsrc/Makefile @@ -143,7 +143,7 @@ text: init @echo "Build finished. The text files are in $(BUILDDIR)/text." man: init - $(SPHINXBUILD) -b cyrman $(ALLSPHINXOPTS) $(BUILDDIR)/man + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." diff --git a/docsrc/_static/favicon.ico b/docsrc/_static/favicon.ico deleted file mode 100644 index d1fb35c89f..0000000000 Binary files a/docsrc/_static/favicon.ico and /dev/null differ diff --git a/docsrc/_templates/layout.html b/docsrc/_templates/layout.html index cbb76e839c..86c16b3b66 100644 --- a/docsrc/_templates/layout.html +++ b/docsrc/_templates/layout.html @@ -13,7 +13,8 @@ diff --git a/docsrc/assets/cyrus-build-devpkg.rst b/docsrc/assets/cyrus-build-devpkg.rst index 61d479e530..5c0156bb64 100644 --- a/docsrc/assets/cyrus-build-devpkg.rst +++ b/docsrc/assets/cyrus-build-devpkg.rst @@ -6,10 +6,10 @@ automated test facility. sudo apt-get install -y autoconf automake autotools-dev bash-completion bison build-essential comerr-dev \ debhelper flex g++ git gperf groff heimdal-dev libbsd-resource-perl libclone-perl libconfig-inifiles-perl \ - libcunit1-dev libdatetime-perl libbsd-dev libdb-dev libdigest-sha-perl libencode-imaputf7-perl \ libfile-chdir-perl libglib2.0-dev libical-dev libio-socket-inet6-perl \ - libio-stringy-perl libjansson-dev libldap2-dev libmysqlclient-dev \ - libnet-server-perl libnews-nntpclient-perl libpam0g-dev libpcre3-dev libsasl2-dev \ - libsnmp-dev libsqlite3-dev libssl-dev libtest-unit-perl libtool libunix-syslog-perl liburi-perl \ + libcunit1-dev libdatetime-perl libbsd-dev libdigest-sha-perl libencode-imaputf7-perl \ libfile-chdir-perl libglib2.0-dev libical-dev libio-socket-inet6-perl \ + libio-stringy-perl libldap2-dev libmysqlclient-dev \ + libnet-server-perl libnews-nntpclient-perl libpam0g-dev libpcre2-dev libsasl2-dev \ + libsqlite3-dev libssl-dev libtest-unit-perl libtool libunix-syslog-perl liburi-perl \ libxapian-dev libxml-generator-perl libxml-xpath-perl libxml2-dev libwrap0-dev libzephyr-dev lsb-base \ net-tools perl php-cli php-curl pkg-config po-debconf tcl-dev \ transfig uuid-dev vim wamerican wget xutils-dev zlib1g-dev sasl2-bin rsyslog sudo acl telnet diff --git a/docsrc/assets/cyrus-build-reqpkg.rst b/docsrc/assets/cyrus-build-reqpkg.rst index a3a530cc39..1c8bb45951 100644 --- a/docsrc/assets/cyrus-build-reqpkg.rst +++ b/docsrc/assets/cyrus-build-reqpkg.rst @@ -2,6 +2,6 @@ sudo apt-get install git build-essential autoconf automake libtool \ pkg-config bison flex libssl-dev libjansson-dev libxml2-dev \ - libsqlite3-dev libical-dev libsasl2-dev libpcre3-dev uuid-dev \ + libsqlite3-dev libical-dev libsasl2-dev libpcre2-dev uuid-dev \ libicu-dev sudo apt-get -t jessie-backports install libxapian-dev diff --git a/docsrc/assets/man-imapdproxyd.rst b/docsrc/assets/man-imapdproxyd.rst index 16f10a6587..806db6aaef 100644 --- a/docsrc/assets/man-imapdproxyd.rst +++ b/docsrc/assets/man-imapdproxyd.rst @@ -8,7 +8,7 @@ Synopsis .. parsed-literal:: **imapd** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] - [ **-s** ] [ **-N** ] [ **-p** *ssf* ] + [ **-H** ] [ **-s** ] [ **-N** ] [ **-p** *ssf* ] Description =========== @@ -66,6 +66,10 @@ Options Run external debugger specified in debug_command. +.. option:: -H + + Tell **imapd** to expect a HAProxy protocol header from the sender. + .. option:: -s Serve IMAP over SSL (imaps). All data to and from **imapd** is diff --git a/docsrc/assets/man-imtest.rst b/docsrc/assets/man-imtest.rst index f9a47ab030..4d7d9a6977 100644 --- a/docsrc/assets/man-imtest.rst +++ b/docsrc/assets/man-imtest.rst @@ -7,49 +7,49 @@ Synopsis [ **-a** *userid* ] [ **-u** *userid* ] [ **-k** *num* ] [ **-l** *num* ] [ **-r** *realm* ] [ **-f** *file* ] [ **-n** *num* ] [ **-s** ] [ **-q** ] [ **-c** ] [ **-i** ] [ **-z** ] [ **-v** ] [ **-I** *file* ] [ **-x** *file* ] - [ **-X** *file* ] [ **-w** *passwd* ] [ **-o** *option*\ =\ *value* ] *hostname* + [ **-X** *file* ] [ **-w** *passwd* ] [ **-o** *option*\ =\ *value* ] + [ **-H** *client-ip* ] *hostname* Description =========== -**imtest** is a utility that allows you to authenticate to a IMAP or -IMSP server and interactively issue commands to it. Once authenticated -you may issue any IMAP or IMSP command by simply typing it in. It is -capable of multiple SASL authentication mechanisms and handles -encryption layers transparently. This utility is often used for testing -the operation of a imsp or imap server. Also those developing IMAP -clients find it useful. +**imtest** is a utility that allows you to authenticate to an IMAP server +and interactively issue commands to it. Once authenticated you may issue +any IMAP command by simply typing it in. It is capable of multiple SASL +authentication mechanisms and handles encryption layers transparently. +This utility is often used for testing the operation of an IMAP server. +Also those developing IMAP clients find it useful. Options ======= .. program:: imtest -.. option:: -t keyfile +.. option:: -t keyfile, --keyfile=keyfile Enable TLS. *keyfile* contains the TLS public and private keys. Specify **""** to negotiate a TLS encryption layer but not use TLS authentication. -.. option:: -p port +.. option:: -p port, --port=port Port to connect to. If left off this defaults to **imap** as defined in ``/etc/services``. -.. option:: -m mechanism +.. option:: -m mechanism, --mechanism=mechanism Force **imtest** to use *mechanism* for authentication. If not specified the strongest authentication mechanism supported by the server is chosen. Specify *login* to use the LOGIN command instead of AUTHENTICATE. -.. option:: -a userid +.. option:: -a userid, --authname=userid Userid to use for authentication; defaults to the current user. This is the userid whose password or credentials will be presented to the server for verification. -.. option:: -u userid +.. option:: -u userid, --username=userid Userid to use for authorization; defaults to the current user. This is the userid whose identity will be assumed after @@ -57,13 +57,13 @@ Options .. Note:: This is only used with SASL mechanisms that allow proxying - (e.g. PLAIN, DIGEST-MD5). + (e.g. PLAIN). -.. option:: -k num +.. option:: -k num, --minssf=num Minimum protection layer required. -.. option:: -l num +.. option:: -l num, --maxssf=num Maximum protection layer to use (**0**\ =none; **1**\ =integrity; etc). For example if you are using the KERBEROS_V4 authentication @@ -71,69 +71,75 @@ Options and specifying **1** will force it to use the integrity layer. By default the maximum supported protection layer will be used. -.. option:: -r realm +.. option:: -r realm, --realm=realm Specify the *realm* to use. Certain authentication mechanisms - (e.g. DIGEST-MD5) may require one to specify the realm. + may require one to specify the realm. -.. option:: -f file +.. option:: -f file, --input-filename=file Pipe *file* into connection after authentication. -.. option:: -n num +.. option:: -n num, --reauth-attempts=num Number of authentication attempts; default = 1. The client will - attempt to do SSL/TLS session reuse and/or fast reauth - (e.g. DIGEST-MD5), if possible. + attempt to do SSL/TLS session reuse and/or fast reauth if possible. -.. option:: -s +.. option:: -s, --require-tls Enable SSL over chosen protocol. -.. option:: -q +.. option:: -q, --require-compression Enable IMAP COMPRESSion (after authentication). -.. option:: -c +.. option:: -c, --do-challenge Enable challenge prompt callbacks. This will cause the OTP mechanism - to ask for the the one-time password instead of the secret pass-phrase + to ask for the one-time password instead of the secret pass-phrase (library generates the correct response). -.. option:: -i +.. option:: -i, --no-initial-response Don't send an initial client response for SASL mechanisms, even if the protocol supports it. -.. option:: -I file +.. option:: -I file, --pidfile=file Echo the PID of the running process into *file* (This can be useful with -X). -.. option:: -v +.. option:: -v, --verbose Verbose. Print out more information than usual. -.. option:: -z +.. option:: -z, --run-stress-test Timing test. -.. option:: -x file +.. option:: -x file, --output-socket=file Open the named socket for the interactive portion. -.. option:: -X file +.. option:: -X file Like -x, only close all file descriptors & daemonize the process. -.. option:: -w passwd +.. option:: -w password, --password=password Password to use (if not supplied, we will prompt). -.. option:: -o option=value +.. option:: -o option=value, --sasl-option=option=value Set the SASL *option* to *value*. +.. option:: -H client-ip + + Enable the HAProxy protocol and send the specified client IP + address in a v1 header. If the address is "unknown", a v1 header + with UNKNOWN protocol will be sent. If the address is "local", + a v2 header with LOCAL command will be sent. + Examples ======== diff --git a/docsrc/assets/man-lmtpd.rst b/docsrc/assets/man-lmtpd.rst index 2aed9fd4d5..6ed11bed45 100644 --- a/docsrc/assets/man-lmtpd.rst +++ b/docsrc/assets/man-lmtpd.rst @@ -3,7 +3,8 @@ Synopsis .. parsed-literal:: - **lmtpd** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] [ **-a** ] + **lmtpd** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] + [ **-H** ] [ **-a** ] Description =========== @@ -39,6 +40,10 @@ Options Run external debugger specified in debug_command. +.. option:: -H + + Tell **lmtpd** to expect a HAProxy protocol header from the sender. + .. option:: -a Preauthorize connections initiated on an internet socket, instead diff --git a/docsrc/assets/man-pop3d.rst b/docsrc/assets/man-pop3d.rst new file mode 100644 index 0000000000..e65e26932f --- /dev/null +++ b/docsrc/assets/man-pop3d.rst @@ -0,0 +1,92 @@ +Synopsis +======== + +.. parsed-literal:: + + **pop3d** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] + [ **-H** ] [ **-s** ] [ **-p** *ssf* ] + +Description +=========== + +**pop3d** is an POP3 server. It accepts commands on its standard +input and responds on its standard output. It MUST be invoked by +:cyrusman:`master(8)` with those descriptors attached to a remote client +connection. + +**pop3d** |default-conf-text| + +If the directory ``log``\/*user* exists under the directory specified in +the ``configdirectory`` configuration option, then **pop3d** will create +protocol telemetry logs for sessions authenticating as *user*. + +The telemetry logs will be stored in the ``log``/\ *user* directory with +a filename of the **pop3d** process-id. + +Options +======= + +.. program:: pop3d + +.. option:: -C config-file + + |cli-dash-c-text| + +.. option:: -U uses + + The maximum number of times that the process should be used for new + connections before shutting down. The default is 250. + +.. option:: -T timeout + + The number of seconds that the process will wait for a new + connection before shutting down. Note that a value of 0 (zero) + will disable the timeout. The default is 60. + +.. option:: -D + + Run external debugger specified in debug_command. + +.. option:: -H + + Tell **httpd** to expect a HAProxy protocol header from the sender. + +.. option:: -s + + Serve POP3 over SSL (pop3s). All data to and from **pop3d** is + encrypted using the Secure Sockets Layer. + +.. option:: -p ssf + + Tell **pop3d** that an external layer exists. An *SSF* (security + strength factor) of 1 means an integrity protection layer exists. + Any higher SSF implies some form of privacy protection. + +Examples +======== + +**pop3d** is commonly included in the SERVICES section of +:cyrusman:`cyrus.conf(5)` like so: + +.. parsed-literal:: + SERVICES { + imap cmd="imapd -U 30" listen="imap" prefork=0 + imaps cmd="imapd -s -U 30" listen="imaps" prefork=0 maxchild=100 + **pop3 cmd="pop3d -U 30" listen="pop3" prefork=0** + **pop3s cmd="pop3d -s -U 30" listen="pop3s" prefork=0 maxchild=100** + lmtpunix cmd="lmtpd" listen="/var/run/cyrus/socket/lmtp" prefork=0 maxchild=20 + sieve cmd="timsieved" listen="sieve" prefork=0 + notify cmd="notifyd" listen="/var/run/cyrus/socket/notify" proto="udp" prefork=1 + httpd cmd="httpd" listen=8080 prefork=1 maxchild=20 + } + +Files +===== + +/etc/imapd.conf + +See Also +======== + +:cyrusman:`imapd.conf(5)`, +:cyrusman:`master(8)` diff --git a/docsrc/assets/services.rst b/docsrc/assets/services.rst index ba5f75b5ce..b596a07133 100644 --- a/docsrc/assets/services.rst +++ b/docsrc/assets/services.rst @@ -8,11 +8,9 @@ are required for any host using the listed services: pop3 110/tcp # Post Office Protocol v3 nntp 119/tcp # Network News Transport Protocol imap 143/tcp # Internet Mail Access Protocol rev4 - imsp 406/tcp # Internet Message Support Protocol (deprecated) nntps 563/tcp # NNTP over TLS imaps 993/tcp # IMAP over TLS pop3s 995/tcp # POP3 over TLS - kpop 1109/tcp # Kerberized Post Office Protocol lmtp 2003/tcp # Lightweight Mail Transport Protocol service smmap 2004/tcp # Cyrus smmapd (quota check) service csync 2005/tcp # Cyrus replication service diff --git a/docsrc/assets/setup-postfix.rst b/docsrc/assets/setup-postfix.rst index 5b840e9cd5..9735e4832b 100644 --- a/docsrc/assets/setup-postfix.rst +++ b/docsrc/assets/setup-postfix.rst @@ -1,3 +1,5 @@ +Postfix +_______ Install Postfix ############### @@ -29,7 +31,7 @@ server and engineer delivery via LMTP. The following examples show the or, if you have enabled smmapd you can automatically track mailboxes with:: postconf -e "virtual_mailbox_domains=hash:/etc/postfix/virtual_recipient_domains" - postconf -e "virtual_mailbox_maps=socketmap:unix:/run/cyrus/socket/smmap" + postconf -e "virtual_mailbox_maps=socketmap:unix:/run/cyrus/socket/smmap:smmapd" 2. Optional: Set the concurrency and recipient limits for LMTP delivery to the ``virtual`` destination:: diff --git a/docsrc/assets/setup-sasl-sasldb.rst b/docsrc/assets/setup-sasl-sasldb.rst index 7b87c73d1d..befa9091e7 100644 --- a/docsrc/assets/setup-sasl-sasldb.rst +++ b/docsrc/assets/setup-sasl-sasldb.rst @@ -36,6 +36,6 @@ the user exists and is set up correctly: :: - testsaslauthd -u imapuser -p secret + testsaslauthd -u imapuser -p secret -f /var/run/saslauthd/mux You should get an ``0: OK "Success."`` message. diff --git a/docsrc/assets/setup-sendmail.rst b/docsrc/assets/setup-sendmail.rst index b34ff0544d..395e4a4bbf 100644 --- a/docsrc/assets/setup-sendmail.rst +++ b/docsrc/assets/setup-sendmail.rst @@ -1,39 +1,149 @@ -Install Sendmail -################ +Integration with Sendmail +_________________________ +Objectives +########## +This manual describes how to integrate Sendmail with Cyrus IMAP. `Open Sendmail `_ presents alternative approaches, but these do not integrate well with email addresses from virtual domains, which are hosted by Cyrus IMAP, but are not in the `virtuser` table. -We'll set up LMTP with the Sendmail SMTP server. +Cyrus IMAP can manage many domains. It has a default domain, and other, virtual domains. -:: +Sendmail can also manage many domains. Its primary domains are stored in the `w` class and are read from `/etc/mail/local-host-names`. The rewritings for these domains are modified using the aliases database. Sendmail handles unqualified email addresses and addresses from the domains in the `w` class the same. Sendmail in addition can manage further, virtual domains by defining the `VirtHost` class. The redirections for the virtual domains are controlled by `virtusertable`. - sudo apt-get install -y sendmail - -We need to make Sendmail aware of the fact we are using the Cyrus IMAP server: modify the ``/etc/mail/sendmail.mc`` file. Add this line before the ``MAILER_DEFINITIONS`` section: +This guide explains how to configure sendmail, so that it handles unqualified email addresses in the same way, as if they were in the default Cyrus IMAP domain. It assumes, that the default Cyrus domain is in the `w` class. At the end it will be possible to have destination addresses with domains in the `w` or `VirtHost` classes and these addresses will be delivered to Cyrus IMAP after aliases and `virtusertable` rewritings, if and only if Cyrus IMAP hosts them. -:: +Sendmail will be configured to verify using `smmapd` if Cyrus does have a mailbox, and reject the email during the SMTP dialog otherwise. This avoids sending bounces. Bounces reduce the IP reputation of a mail server. If a local for the server user does not have a Cyrus IMAP account, this user will not get its emails in a folder on the server. If `smmapd` does not respond, sendmail will accept emails for any address. - define(`confLOCAL_MAILER', `cyrusv2')dnl +If a virtual mailbox exists in Cyrus IMAP and `virtusertable` redirects the emails for that mailbox somewhere, the `virtusertable` takes precedence, like the aliases database has precedence in such cases. -And right below ``MAILER_DEFINITIONS``, add this: +The user database is not considered in this guide. -:: +Plus addressing works, when the destination folder does exist and is lowercased: If `user1` has folders `abc` and `mNp`, emails for `user1+abc` and `user1+aBc` will be accepted, emails for `user1+mNp` and `user1+mnp` will be rejected. The lowercase limitation comes from `smmapd`. Emails for `user1+def` will be rejected, if `user1` has no mailbox `def`, even if a Sieve script would place such mails in existing folders. - MAILER(`cyrusv2')dnl +Plus addressing does not survive aliases rewriting. If the aliases table contains `user2: user1`, emails for `user2+abc` will be rejected, while emails for `user2` or `user1+abc` will be accepted. After inserting `user2+abc: user1+abc` in the aliases table, emails for `user2+abc` will be accepted. -This enables the **cyrusv2** mailer for local mail delivery. This is a sendmail property that tells sendmail it's talking to Cyrus. (Cyrus 3.x works with this property, despite the naming confusion.) +Install Sendmail +################ -Next, we run a script that takes the ``/etc/mail/sendmail.mc`` file and and prepares it for use by Sendmail. This may take some time. +We'll set up LMTP with the Sendmail SMTP server. :: - sudo sendmailconfig + sudo apt-get install -y sendmail + +Add cf/feature/anfi_vcyrus.m4 +############################# +Create the file cf/feature/anfi_vcyrus.m4: + +.. code-block:: + + divert(-1) + dnl + dnl By using this file, you agree to the terms and conditions set + dnl forth in the LICENSE file which can be found at the top level of + dnl the sendmail distribution (sendmail-8.12). + dnl + dnl Contributed by Andrzej Filip and Dilyan Palauzov + LOCAL_CONFIG + # cyrus - map for checking cyrus mailbox presence + Kcyrus socket -T -a local:/var/imap/socket/smmapd + + LOCAL_RULESETS + SLocal_localaddr + R$+ $: $1 $| $(cyrus $1 $: $) + R$+ $| $#error $@ 5.1.1 $: "550 User unknown." + R$+ $| $* $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." + R$+ $| $* $#cyrusv2 $@ $: $1 + R$+ $| $* $: $1 + +Many spaces in a row stand for the tabulator character. + +Despite the naming confusion, Cyrus 3 works with the `cyrusv2` mailer. + +This file creates a map called `cyrus`, which connects to the Cyrus` `smmapd` service. + +The file extends the `localaddr=5` and `Local_localaddr` rulesets to verify whether an address is known to Cyrus IMAP. The rulesets are called from the `local` mailer, as the `local` mailer has the `5` mailer flag set. The ruleset changes the mailer to `cyrusv2` and the email is accepted if and only if the address is known to Cyrus IMAP. + +The temporary rejections do not work in practice. If `smmapd` is down, the email is queued instead of being rejected. The 451 line above is there to encourage discussion. + +Patching m4/proto.m4 +#################### +.. code-block:: diff + + diff --git a/cf/m4/proto.m4 b/cf/m4/proto.m4 + --- a/cf/m4/proto.m4 + +++ b/cf/m4/proto.m4 + @@ -1147,6 +1147,10 @@ dnl if no match, change marker to prevent a second @domain lookup + R<@> $+ + $* < @ $+ . > $: < $(virtuser @ $3 $@ $1 $@ $2 $@ +$2 $: ! $) > $1 + $2 < @ $3 . > + dnl without +detail + R<@> $+ < @ $+ . > $: < $(virtuser @ $2 $@ $1 $: @ $) > $1 < @ $2 . > + +dnl If a virtual address is not in the virtusertable, but cyrus knows about the address, deliver it. + +R< error : $-.$-.$- : $+ > $+ < @ $={VirtHost} . > $: < error : $1.$2.$3 : $4 > $5 < $6 . > $| $(cyrus $5@$6 $: $) + +R< error : $-.$-.$- : $+ > $* < $* . > $| $* $#cyrusv2 $@ $: $5@$6 + +R< error : $-.$-.$- : $+ > $* $| $* $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." + dnl no match + R<@> $+ $: $1 + dnl remove mark + +Where many spaces in a row stand for the tabulator key. + +If an address from a virtual domain is not found in the `virtusertable`, ask `smmapd` if the address is known to Cyrus IMAP. If it is known, deliver it to Cyrus IMAP. + +Patching mailer/cyrusv2.m4 +########################## + +.. code-block:: diff + + diff --git a/cf/mailer/cyrusv2.m4 b/cf/mailer/cyrusv2.m4 + --- a/cf/mailer/cyrusv2.m4 + +++ b/cf/mailer/cyrusv2.m4 + @@ -11,7 +11,7 @@ PUSHDIVERT(-1) + # + + _DEFIFNOT(`_DEF_CYRUSV2_MAILER_FLAGS', `lsDFMnqXz') + -_DEFIFNOT(`CYRUSV2_MAILER_FLAGS', `A@/:|m') + +_DEFIFNOT(`CYRUSV2_MAILER_FLAGS', `8m') + ifdef(`CYRUSV2_MAILER_ARGS',, `define(`CYRUSV2_MAILER_ARGS', `FILE /var/imap/socket/lmtp')') + define(`_CYRUSV2_QGRP', `ifelse(defn(`CYRUSV2_MAILER_QGRP'),`',`', ` Q=CYRUSV2_MAILER_QGRP,')')dnl + + +The `8` flag means, that Cyrus LMTPd can accept 8bit data and sendmail will not convert 8bit data to 7bit before passing it to Cyrus IMAP. The `A@/:|` functionality will be performed by the `local` mailer, before the `cyrusv2` mailer is called. The `cyrus2v` mailer is used only to pass data to Cyrus IMAP, after it is verified, that Cyrus IMAP hosts a particular mailbox. Thus the `cyrus2v` mailer does not call the `localaddr=5` rule set in order to avoid loops. (If the `cyrusv2` mailer calls the `localaddr=5` ruleset and the `localaddr=5` ruleset calls the `cyrusv2` mailer, there is an endless loop). + +The patch to `m4/proto.m4` also requires a mailer, which does not call the `localaddr=5` ruleset. Because of this, substituting the `local` mailer by `define(\`confLOCAL_MAILER', \`cyrusv2')dnl` will not work. The proposed setup needs one mailer calling the `localaddr=5` ruleset (here the `local` mailer) and one mailer not calling the `localaddr=5` ruleset (the `cyrusv2` mailer). Sendmail communication ###################### -One last thing we need to do for LMTP to work with Sendmail is to create a folder that will contain the UNIX socket used by Sendmail and Cyrus to deliver/receive emails: +For LMTP and SMMAP to work with Sendmail, it is necessary to create a folder that will contain the UNIX socket used by Sendmail and Cyrus to deliver/receive emails: :: sudo mkdir -p /var/run/cyrus/socket sudo chown cyrus:mail /var/run/cyrus/socket sudo chmod 750 /var/run/cyrus/socket + +Do the same for the `smmapd` socket. + +Adjustments for the `.mc` files +############################### +In your `.mc` files add:: + + FEATURE(`anfi_vcyrus')dnl + MAILER(`cyrusv2')dnl + +and recompile them, e.g. by calling `make file.cf` to convert `file.mc` to `file.cf`. Test with:: + + # ggg is unqualified address, which exists both in Cyrus’ default domain and in sendmails’ w class + $ sendmail -C file.cf -bv ggg + ggg... deliverable: mailer cyrusv2, user ggg + + # verify that ggg and ggg@your-primary-domain resolve in the same way, your-primary-domain is the default Cyrus IMAP domain + $ sendmail -C file.cf -bv ggg@your-primary-domain + ggg... deliverable: mailer cyrusv2, user ggg + + # as above, but here another-domain belongs to class `w` and it is not the default domain for Cyrus IMAP + $ sendmail -C file.cf -bv ggg@another-domain + ggg... deliverable: mailer cyrusv2, user ggg + + # for an address, which exists in Cyrus IMAP, and is not overwritten in virtusertable. + # domain1.org belongs to class VirtHost and does not belong to class w. + $ sendmail -C sendmail-mail.cf -bv zzz@domain1.org + zzz@domain1.org... deliverable: mailer cyrusv2, user zzz@domain1.org diff --git a/docsrc/conf.py b/docsrc/conf.py index 9a269a4bd6..5df7c4d7d1 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -16,6 +16,8 @@ import sys import os +import datetime + # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. @@ -25,6 +27,11 @@ # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. +# XXX The oldest version we need to support at present is 1.3.6, and the +# XXX oldest version we actually support is 1.3.2, but these versions +# XXX don't check the third field properly, so we can't bump this line to +# XXX match reality! See discussion from last time we tried to bump this +# XXX at https://github.com/cyrusimap/cyrus-imapd/pull/2868 needs_sphinx = '1.2' # Add any Sphinx extension module names here, as strings. They can be @@ -40,7 +47,6 @@ 'sphinx.ext.intersphinx', ] -extensions.append('sphinxlocal.builders.manpage') extensions.append('sphinxlocal.roles.cyrusman') extensions.append('sphinxlocal.builders.gitstamp') @@ -49,7 +55,7 @@ extensions.append('sphinxlocal.sitemap') # We publish master branch at /dev -# Other branches are available at multiple locations (3.0 is at 3.0 and stable and /). +# Other branches are available at multiple locations (3.10 is at 3.10 and stable and /). # Supply all webroots that this set of docs is available at. sitemap_website = ["https://www.cyrusimap.org/dev/"] @@ -76,7 +82,7 @@ # General information about the project. project = u'Cyrus IMAP' -copyright = u'1993-2018, The Cyrus Team' +copyright = u'1993–%s, The Cyrus Team' % datetime.date.today().year # The version info for the project you're documenting, acts as replacement for @@ -92,9 +98,9 @@ # May need to also update toplevel index.rst to point to other versions. # # The short X.Y version. -version = '3.1.6' +version = '3.11.0' # The full version, including alpha/beta/rc tags. -release = '3.1.6 (dev)' +release = '3.11.0-alpha0 (dev)' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -157,7 +163,7 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -html_theme_options = { 'travis_version': 'master'} +html_theme_options = { 'github_version': 'master'} # Add any paths that contain custom themes here, relative to this directory. @@ -177,7 +183,7 @@ # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -html_favicon = "_static/favicon.ico" +# html_favicon = "_static/favicon.ico" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -195,7 +201,8 @@ # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +html_use_smartypants = False +smartquotes = False # Custom sidebar templates, maps document names to template names. html_sidebars = {'**' : ['localtoc.html', 'searchbox.html', 'buildstatus.html']} @@ -301,7 +308,7 @@ for tuple in pathset: os.chdir(tuple[0]) for rstfile in glob.glob("*.rst"): - author = [("The Cyrus Team")] + authors = [("The Cyrus Team")] orphan = 'False'; with io.open(rstfile,'r',encoding="utf8") as f: for line in f: @@ -309,14 +316,14 @@ orphan = 'True'; break; if line.startswith('.. author: '): - author.append(line[11: len(line.strip())]) + authors.append(line[11: len(line.strip())]) f.close() if orphan == 'False': man_pages.append( (os.path.splitext(os.path.join(tuple[0],rstfile))[0], os.path.splitext(rstfile)[0], u'Cyrus IMAP documentation', - author, + authors, tuple[1]) ) @@ -424,11 +431,11 @@ # When this is updated, you may also need to update the version and release # definitions listed above to stay up to date. rst_prolog = """ -.. |imap_last_stable_version| replace:: 2.5.12 -.. |imap_last_stable_branch| replace:: `cyrus-imapd-2.5` -.. |imap_current_stable_version| replace:: 3.0.9 -.. |imap_current_stable_branch| replace:: `cyrus-imapd-3.0` -.. |imap_latest_development_version| replace:: 3.1.6 +.. |imap_last_stable_version| replace:: 3.8.4 +.. |imap_last_stable_branch| replace:: `cyrus-imapd-3.8` +.. |imap_current_stable_version| replace:: 3.10.0 +.. |imap_current_stable_branch| replace:: `cyrus-imapd-3.10` +.. |imap_latest_development_version| replace:: 3.11.0-alpha0 .. |imap_latest_development_branch| replace:: master .. |imap_tikanga_stock_version| replace:: 2.3.7 .. |imap_santiago_stock_version| replace:: 2.3.16 @@ -438,14 +445,14 @@ .. |imap_utopic_stock_version| replace:: 2.4.17+caldav~beta10-5 .. |imap_vivid_stock_version| replace:: 2.4.17+caldav~beta10-17 .. |imap_wily_stock_version| replace:: 2.4.17+caldav~beta10-17 -.. |sasl_current_stable_version| replace:: 2.1.27 +.. |sasl_current_stable_version| replace:: 2.1.28 .. |imap_stable_release_notes| raw:: html - 3.0.9 + 3.10.0 .. |imap_development_release_notes| raw:: html - 3.1.6 + 3.11.0-alpha0 """ @@ -516,12 +523,24 @@ #""" # Use this as :task:`18` +# XXX would be really nice to be able to have 'github-tarball' and 'github-sig' +# XXX handlers here, except that these urls require expanding the version string +# XXX twice, and the sphinx extlinks thingy currently only supports a single %s extlinks = { + 'draft':('https://tools.ietf.org/html/%s', ''), 'issue':('https://github.com/cyrusimap/cyrus-imapd/issues/%s', 'Issue #'), 'cyrus-2.5':('https://www.cyrusimap.org/2.5%s',None), 'cyrus-3.0':('https://www.cyrusimap.org/3.0%s',None), + 'cyrus-3.2':('https://www.cyrusimap.org/3.2%s',None), + 'cyrus-3.4':('https://www.cyrusimap.org/3.4%s',None), + 'cyrus-3.6':('https://www.cyrusimap.org/3.6%s',None), + 'cyrus-3.8':('https://www.cyrusimap.org/3.8%s',None), + 'cyrus-3.10':('https://www.cyrusimap.org/3.10%s',None), 'cyrus-dev':('https://www.cyrusimap.org/dev%s',None), - 'cyrus-stable': ('https://www.cyrusimap.org/3.0%s',None), + 'cyrus-stable': ('https://www.cyrusimap.org%s',None), + 'github-release': + ('https://github.com/cyrusimap/cyrus-imapd/releases/tag/cyrus-imapd-%s', + 'cyrus-imapd-'), } # Change this to whatever your output root is diff --git a/docsrc/developers.rst b/docsrc/developers.rst index 999ce89bb2..409b0534eb 100644 --- a/docsrc/developers.rst +++ b/docsrc/developers.rst @@ -5,7 +5,6 @@ Developers .. toctree:: :maxdepth: 2 - :glob: contribute Contribute docs diff --git a/docsrc/download.rst b/docsrc/download.rst index 400682f4a2..c42ba7dee9 100644 --- a/docsrc/download.rst +++ b/docsrc/download.rst @@ -1,6 +1,6 @@ -======== +-------- Download -======== +-------- .. toctree:: diff --git a/docsrc/exts/sphinxlocal/builders/manpage.py b/docsrc/exts/sphinxlocal/builders/manpage.py deleted file mode 100644 index a6281f799e..0000000000 --- a/docsrc/exts/sphinxlocal/builders/manpage.py +++ /dev/null @@ -1,97 +0,0 @@ -# -*- coding: utf-8 -*- -""" - sphinxlocal.builders.manpage - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - A replacement for the manpage builder which come bundled with Sphinx. - - :version: 0.1 - :author: Nic Bernstein - - :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS. - :license: BSD, see LICENSE for details. -""" - -from os import path - -from six import string_types -from docutils.io import FileOutput -from docutils.frontend import OptionParser - -from sphinx import addnodes -from sphinx.errors import SphinxError -from sphinx.builders import Builder -from sphinx.environment import NoUri -from sphinx.util.nodes import inline_all_toctrees -from sphinx.util.console import bold, darkgreen -from sphinx.writers.manpage import ManualPageWriter -from sphinx.builders.manpage import ManualPageBuilder - -## -# Import our customized version of the stock Writer, which has the -# Translater in it. -from sphinxlocal.writers.manpage import CyrusManualPageWriter - -class CyrusManualPageBuilder(ManualPageBuilder): - """ - Builds groff output in manual page format. - """ - name = 'cyrman' - format = 'man' - supported_image_types = [] - - #settings_spec = (u'No options defined.', u'', ()) - #settings_defaults = {} - - def init(self): - if not self.config.man_pages: - self.warn('no "man_pages" config value found; no manual pages ' - 'will be written') - - def write(self, *ignored): - # overwritten -- use our own version of the Writer - docwriter = CyrusManualPageWriter(self) - docsettings = OptionParser( - defaults=self.env.settings, - components=(docwriter,), - read_config_files=True).get_default_values() - - self.info(bold('writing... '), nonl=True) - - for info in self.config.man_pages: - docname, name, description, authors, section = info - if isinstance(authors, string_types): - if authors: - authors = [authors] - else: - authors = [] - - targetname = '%s.%s' % (name, section) - self.info(darkgreen(targetname) + ' { ', nonl=True) - destination = FileOutput( - destination_path=path.join(self.outdir, targetname), - encoding='utf-8') - - tree = self.env.get_doctree(docname) - docnames = set() - largetree = inline_all_toctrees(self, docnames, docname, tree, - darkgreen, [docname]) - self.info('} ', nonl=True) - self.env.resolve_references(largetree, docname, self) - # remove pending_xref nodes - for pendingnode in largetree.traverse(addnodes.pending_xref): - pendingnode.replace_self(pendingnode.children) - - largetree.settings = docsettings - largetree.settings.title = name - largetree.settings.subtitle = description - largetree.settings.authors = authors - largetree.settings.section = section - - docwriter.write(largetree, destination) - self.info() - -def setup(app): - app.add_builder(CyrusManualPageBuilder) - - return {'version': '0.1'} diff --git a/docsrc/exts/sphinxlocal/roles/cyrusman.py b/docsrc/exts/sphinxlocal/roles/cyrusman.py index 5d7eb6c65a..6f2e94a8ed 100644 --- a/docsrc/exts/sphinxlocal/roles/cyrusman.py +++ b/docsrc/exts/sphinxlocal/roles/cyrusman.py @@ -17,8 +17,17 @@ from string import Template import re +try: + from sphinx.util import logging + logger = logging.getLogger(__name__) +except: + logger = None + def setup(app): - app.info('Initializing cyrusman plugin') + global logger + if logger is None: + logger = app + logger.info('Initializing cyrusman plugin') app.add_crossref_type('cyrusman', 'cyrusman', '%s', nodes.generated) return diff --git a/docsrc/exts/sphinxlocal/sitemap.py b/docsrc/exts/sphinxlocal/sitemap.py index 4054896126..8be0d6f971 100644 --- a/docsrc/exts/sphinxlocal/sitemap.py +++ b/docsrc/exts/sphinxlocal/sitemap.py @@ -30,7 +30,7 @@ def generate_sitemap(app, exception): raise errors.ExtensionError("Cannot generate sitemap. Set 'sitemap_website' in conf.py with website hostname") env = app.builder.env - for page in env.found_docs: + for page in sorted(env.found_docs): for site in website: url = {} url["loc"] = "{}{}.html".format(site, page) diff --git a/docsrc/exts/sphinxlocal/writers/__init__.py b/docsrc/exts/sphinxlocal/writers/__init__.py deleted file mode 100644 index 6de61d24c7..0000000000 --- a/docsrc/exts/sphinxlocal/writers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -""" - sphinxlocal.writers - ~~~~~~~~~~~~~~ - - Custom docutils writers. - - :copyright: Copyright 2015 by Nic Bernstein - :license: BSD, see LICENSE for details. -""" diff --git a/docsrc/exts/sphinxlocal/writers/manpage.py b/docsrc/exts/sphinxlocal/writers/manpage.py deleted file mode 100644 index 13864e0d91..0000000000 --- a/docsrc/exts/sphinxlocal/writers/manpage.py +++ /dev/null @@ -1,91 +0,0 @@ -# -*- coding: utf-8 -*- -""" - sphinxlocal.writers.manpage - ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - A replacement for the manpage builder which come bundled with Sphinx. - - :version: 0.1 - :author: Nic Bernstein - - :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS. - :license: BSD, see LICENSE for details. -""" - -from docutils import nodes -from sphinx.writers.manpage import ( - MACRO_DEF, - ManualPageWriter, - ManualPageTranslator as BaseTranslator -) - - -from sphinx import addnodes -from sphinx.locale import admonitionlabels, _ -from sphinx.util.osutil import ustrftime - -class CyrusManualPageWriter(ManualPageWriter): - - #settings_spec = (u'No options defined.', u'', ()) - #settings_defaults = {} - - def __init__(self, builder): - ManualPageWriter.__init__(self, builder) - self.builder = builder - - def translate(self): - visitor = CyrusManualPageTranslator(self.builder, self.document) - self.visitor = visitor - self.document.walkabout(visitor) - self.output = visitor.astext() - - -class CyrusManualPageTranslator(BaseTranslator): - """ - Custom translator. - """ - - def __init__(self, builder, *args, **kwds): - BaseTranslator.__init__(self, builder, *args, **kwds) - self.builder = builder - - self.in_productionlist = 0 - - # first title is the manpage title - self.section_level = -1 - - # docinfo set by man_pages config value - self._docinfo['title'] = self.document.settings.title - self._docinfo['subtitle'] = self.document.settings.subtitle - if self.document.settings.authors: - # don't set it if no author given - self._docinfo['author'] = self.document.settings.authors - self._docinfo['manual_section'] = self.document.settings.section - - # docinfo set by other config values - self._docinfo['title_upper'] = self._docinfo['title'].upper() - if builder.config.today: - self._docinfo['date'] = builder.config.today - else: - self._docinfo['date'] = ustrftime(builder.config.today_fmt - or _('%B %d, %Y')) - self._docinfo['copyright'] = builder.config.copyright - self._docinfo['version'] = builder.config.version - self._docinfo['manual_group'] = builder.config.project - - # since self.append_header() is never called, need to do this here - self.body.append(MACRO_DEF) - - # overwritten -- don't wrap literal_block with font calls - self.defs['literal_block'] = ('.sp\n.nf\n', '\n.fi\n') - - - # overwritten -- don't assume indentation - def visit_literal_block(self, node): - self.body.append(self.defs['literal_block'][0]) - self._in_literal = True - - - def depart_literal_block(self, node): - self._in_literal = False - self.body.append(self.defs['literal_block'][1]) diff --git a/docsrc/exts/themes/cyrus/buildstatus.html b/docsrc/exts/themes/cyrus/buildstatus.html index faba9acf05..c9e9129309 100644 --- a/docsrc/exts/themes/cyrus/buildstatus.html +++ b/docsrc/exts/themes/cyrus/buildstatus.html @@ -1,5 +1,5 @@ {%- if builder != 'singlehtml' %} {%- endif %} diff --git a/docsrc/exts/themes/cyrus/layout.html b/docsrc/exts/themes/cyrus/layout.html index 5d55d59497..2ca853513a 100644 --- a/docsrc/exts/themes/cyrus/layout.html +++ b/docsrc/exts/themes/cyrus/layout.html @@ -100,10 +100,12 @@ {{ project }} {% endif %} #} - {# {% if logo %} #} + {% if logo %} {# Not strictly valid HTML, but it's the only way to display/scale it properly, without weird scripting or heaps of work #} - {# {% endif %} #} + {% else %} +

      {{ project }}

      + {% endif %}
      {% include "searchbox.html" %} diff --git a/docsrc/exts/themes/cyrus/theme.conf b/docsrc/exts/themes/cyrus/theme.conf index eb14e7d865..8e6e8c9ed8 100644 --- a/docsrc/exts/themes/cyrus/theme.conf +++ b/docsrc/exts/themes/cyrus/theme.conf @@ -7,4 +7,4 @@ typekit_id = hiw1hhg analytics_id = sticky_navigation = false logo_only = true -travis_version = master +github_version = master diff --git a/docsrc/imap/concepts/deployment/authentication_and_authorization.rst b/docsrc/imap/concepts/deployment/authentication_and_authorization.rst index 71df519a07..ffedd7f37d 100644 --- a/docsrc/imap/concepts/deployment/authentication_and_authorization.rst +++ b/docsrc/imap/concepts/deployment/authentication_and_authorization.rst @@ -30,9 +30,9 @@ The exchange and verification of identity information provided by a client, othe The most common set of credentials is a *username* and *password*, but other forms exist like Kerberos v5 ticket exchange (for which, to obtain such, most often a password is supplied), or certificate based authentication (the secret keys for which are most often locked with a passphrase). In any case, authentication works based on a shared secret, and/or a trusted source for verification. Kerberos v5 works based on shared secrets (keytab), and certificate based authentication works based on shared, trusted sources for verification. -In the case of usernames and passwords though, the exchange and verification of the credentials is at the basis of its security. Sending plain text usernames and passwords over the wire would not allow any application to verify the source of the credentials is actually the user — who is supposed to be the only party to know the unique combination of username and password. +In the case of usernames and passwords though, the exchange and verification of the credentials is at the basis of its security. Sending plain text usernames and passwords over the wire would not allow any application to verify the source of the credentials is actually the user --- who is supposed to be the only party to know the unique combination of username and password. -To obfuscate the login credentials, authentication can be encrypted with CRAM-MD5 or DIGEST-MD5, but this requires the server to have a copy of the original, plain text password. The password in this case becomes the shared secret. +To obfuscate the login credentials, authentication can be encrypted with SCRAM, but this requires the server to have a copy of the original, plain text password. The password in this case becomes the shared secret. Another method is to allow the plain text username and password to be transmitted over the wire, but ensure Transport Layer Security (TLS) or the more implicit Secure Socket Layer (SSL). The plain text password can now be used to compare it against a SQL database, bind to an LDAP database, attempt PAM authentication with, etc. @@ -45,7 +45,7 @@ The **user login credentials** that are associated with the user authentication For example, the user logs in with username ``john.doe@example.org`` and password ``verysecret``. -The **user's authentication entity** — with all attributes associated with it — can have one of those attributes be used to create the relationship between the user authentication entity on the one side, and the mailbox entity on the other side. +The **user's authentication entity** --- with all attributes associated with it --- can have one of those attributes be used to create the relationship between the user authentication entity on the one side, and the mailbox entity on the other side. For example, the user that authenticated as ``john.doe@example.org`` may have a mailbox named ``jdoe``. diff --git a/docsrc/imap/concepts/deployment/databases.rst b/docsrc/imap/concepts/deployment/databases.rst index 91244069a3..654ac8252d 100644 --- a/docsrc/imap/concepts/deployment/databases.rst +++ b/docsrc/imap/concepts/deployment/databases.rst @@ -46,40 +46,50 @@ One per user: * `Mailbox Keys (.mboxkey)`_ * `Seen State (.seen)`_ * `Subscriptions (.sub)`_ -* `Search Indexes (cyrus.squat, .xapianactive)`_ +* `Search Indexes (cyrus.squat, .xapianactive, cyrus.indexed.db)`_ .. _imap-concepts-deployment-db-mailboxes: Mailbox List (mailboxes.db) --------------------------- -This database contains the master list of all mailboxes on the system. The database is indexed by mailbox name and each data record contains the mailbox type, the partition on which the mailbox resides and the ACL on the mailbox. The format of each record is as follows:: +This database contains the master list of all mailboxes on the system. The +database is indexed by mailbox name and each data record contains the mailbox +type, the partition on which the mailbox resides and the ACL on the mailbox. +The format of each record is as follows:: Key: Data: SPSP -File type can be: `twoskip`_ (default), `flat`_, `skiplist`_, `sql`_, or `twoskip`_. +File type can be: `twoskip`_ (default), `flat`_, `skiplist`_, or `sql`_. .. _imap-concepts-deployment-db-annotations: Annotations (annotations.db) ---------------------------- -This database contains mailbox and server annotations. The database is indexed by mailbox name (empty for server annotations) + annotation name + userid (empty for shared annotations) and each data record contains the value size, value data, content-type of the data and timestamp of the record. The format is each record is as follows:: +This database contains mailbox and server annotations, including WebDAV +properties. The database is indexed by mailbox name (empty for server +annotations) + annotation name + userid (empty for shared annotations) and each +data record contains the value size, value data, content-type of the data and +timestamp of the record. The format is each record is as follows:: Key: \0\0\0 Data: \0\0 -File type can be `twoskip`_ (default), or `skiplist`_. +File type can be: `twoskip`_ (default) or `skiplist`_. .. _imap-concepts-deployment-db-quotas: Quotas (quotas.db) ------------------ -This database contains the master list of quotaroots on the system. The database is indexed by quota root and each data record contains the current usage of all mailboxes under the quota root and the limit of the quota root. The format of each record is as follows:: +This database contains the master list of quotaroots on the system. The +database is indexed by quota root and each data record contains the current +usage of all mailboxes under the quota root and the limit of the quota root. +The format of each record is as follows:: Key: @@ -90,21 +100,31 @@ File type can be: `quotalegacy`_ (default), `flat`_, `skiplist`_, `sql`_, or `tw **Legacy Quotas** -The legacy quota database uses a distributed system in which each quota root is stored in a separate file named by quota root and the contents had the following format in older versions:: +The legacy quota database uses a distributed system in which each quota root is +stored in a separate file named by quota root and the contents had the +following format in older versions:: \n \n -Newer versions are stored as a DList file with keys for each type of quota, and values with both usage and limit for each type. A limit value of -1 means no limit. +Newer versions are stored as a DList file with keys for each type of quota, and +values with both usage and limit for each type. A limit value of -1 means no +limit. -The translation to/from this data record format is handled by the quota_legacy cyrusdb backend. +The translation to/from this data record format is handled by the quota_legacy +cyrusdb backend. .. _imap-concepts-deployment-db-deliver: Duplicate Delivery (deliver.db) ------------------------------- -This database is used for duplicate delivery suppression, retrieving usenet articles by message-id, and tracking Sieve redirects and vacation responses. The database is indexed by message-id + recipient (either mailbox or email address) and each data record contains the timestamp of the record and the UID of the message within the mailbox (if delivered locally). The format of each record is as follows:: +This database is used for duplicate delivery suppression, retrieving usenet +articles by message-id, and tracking Sieve redirects and vacation responses. +The database is indexed by message-id + recipient (either mailbox or email +address) and each data record contains the timestamp of the record and the +UID of the message within the mailbox (if delivered locally). The format of +each record is as follows:: Key: \0\0 @@ -118,7 +138,11 @@ File type can be: `twoskip`_ (default), `skiplist`_, or `sql`_. TLS cache (tls_sessions.db) --------------------------- -This database caches SSL/TLS sessions so that subsequent connections using the same session-id can bypass the SSL/TLS handshaking, resulting is shorter connection times. The database is indexed by session-id and each data record contains the timestamp of the record and the ASN1 representation of the session data. The format of each record is as follows:: +This database caches SSL/TLS sessions so that subsequent connections using the +same session-id can bypass the SSL/TLS handshaking, resulting is shorter +connection times. The database is indexed by session-id and each data record +contains the timestamp of the record and the ASN1 representation of the session +data. The format of each record is as follows:: Key: @@ -132,13 +156,16 @@ File type can be: `twoskip`_ (default), `skiplist`_, or `sql`_. PTS cache (ptscache.db) ----------------------- -This database caches authentication state records, resulting in shorter authentication/canonicalization times. The database is indexed by userid and each data record contains an authentication state for the userid. The format of each record is as follows:: +This database caches authentication state records, resulting in shorter +authentication/canonicalization times. The database is indexed by userid and +each data record contains an authentication state for the userid. The format +of each record is as follows:: Key: Data: -File type can be: `twoskip`_ (default), or `skiplist`_. +File type can be: `twoskip`_ (default) or `skiplist`_. .. _imap-concepts-deployment-db-status: @@ -146,7 +173,15 @@ File type can be: `twoskip`_ (default), or `skiplist`_. STATUS cache (statuscache.db) ----------------------------- -This database caches IMAP STATUS information resulting in less I/O when the STATUS information hasn't changed (mailbox and \Seen state unchanged). The database is indexed by mailbox name + userid and each data record contains the database version number, a bitmask of the stored status items, the mtime, inode, and size of the cyrus.index file at the time the record was written, the total number of messages in the mailbox, the number of recent messages, the next UID value, the mailbox UID validity value, the number of unseen messages, and the highest modification sequence in the mailbox. The format of each record is as follows:: +This database caches IMAP STATUS information resulting in less I/O when the +STATUS information hasn't changed (mailbox and \Seen state unchanged). The +database is indexed by mailbox name + userid and each data record contains +the database version number, a bitmask of the stored status items, the mtime, +inode, and size of the cyrus.index file at the time the record was written, +the total number of messages in the mailbox, the number of recent messages, +the next UID value, the mailbox UID validity value, the number of unseen +messages, and the highest modification sequence in the mailbox. The format of +each record is as follows:: Key: \0\0 @@ -160,7 +195,13 @@ File type can be: `twoskip`_ (default), `skiplist`_, or `sql`_. User Access (user_deny.db) -------------------------- -This database contains a list of users that are denied access to Cyrus services. The database is indexed by userid and each data record contains the database version number (currently 2), a list of wildmat patterns specifying Cyrus services to be denied, and a text message to be displayed to the user upon denial. The service names to be matched are those as used in cyrus.conf(5). The format of each record is as follows:: +This database contains a list of users that are denied access to Cyrus +services. The database is indexed by userid and each data record contains the +database version number (currently 2), a list of "wildmat" patterns (per +:rfc:`3977#section-4`) specifying Cyrus services to be denied, and a text +message to be displayed to the user upon denial. The service names to be +matched are those as used in :cyrusman:`cyrus.conf(5)`. The format of each +record is as follows:: Key: @@ -173,8 +214,9 @@ File type can be: `flat`_ (default), `skiplist`_, `sql`_, or `twoskip`_. Backups (backups.db) -------------------- -This database maps userids to the location of their backup files. It only exists -on Cyrus Backup servers (compiled with the `--enable-backup` configure option). +This database maps userids to the location of their backup files. It only +exists on Cyrus Backup servers (compiled with the `--enable-backup` configure +option). File type can be: `twoskip`_ (default), `skiplist`_, `sql`_, or `twoskip`_. @@ -213,13 +255,19 @@ File format not selectable. .. _imap-concepts-deployment-db-search: -Search Indexes (cyrus.squat, .xapianactive) ---------------------------------------------------- +Search Indexes (cyrus.squat, .xapianactive, cyrus.indexed.db) +--------------------------------------------------------------------- -This is either cyrus.squat in each folder, or if you're using xapian a single -.xapianactive file listing active databases by tier name and number. +This is either cyrus.squat in each folder, or if you're using Xapian a single +.xapianactive file listing active databases with tier name and number. -File type can be: `twoskip`_ (default), `flat`_, or `skiplist`_. +cyrus.indexed.db is used by the Xapian search engine. Its file type +can be: `twoskip`_ (default), `flat`_, `skiplist`_, or ``zeroskip`` and is +determined by `search_indexed_db` in :cyrusman:`imapd.conf(5)`. + +The xapianactive file contains a space separated list of tiers and databases within +the tier. The first element is the active tier/database, to which new entries are +added by `squatter -R`. .. _imap-concepts-deployment-db-zoneinfo: @@ -244,7 +292,12 @@ File type can be: `twoskip`_ (default), `flat`_, or `skiplist`_. Seen State (.seen) -------------------------- -This database is a per-user database and maintains the list of messages that the user has read in each mailbox. The database is indexed by mailbox unique-id and each data record contains the database version number, the timestamp of when a message was last read, the message unique-id of the last read message, the timestamp of the last record change and a list of message unique-ids which have been read. The format of each record is as follows:: +This database is a per-user database and maintains the list of messages that +the user has read in each mailbox. The database is indexed by mailbox +unique-id and each data record contains the database version number, the +timestamp of when a message was last read, the message unique-id of the last +read message, the timestamp of the last record change and a list of message +unique-ids which have been read. The format of each record is as follows:: Key: @@ -257,7 +310,9 @@ File type can be: `twoskip`_ (default), `flat`_, or `skiplist`_. Subscriptions (.sub) ---------------------------- -This database is a per-user database and contains the list of mailboxes to which the user has subscribed. The database is indexed by mailbox name and each data record contains no data. The format of each record is follows:: +This database is a per-user database and contains the list of mailboxes to +which the user has subscribed. The database is indexed by mailbox name and +each data record contains no data. The format of each record is follows:: Key: @@ -278,13 +333,16 @@ TODO Mailbox Keys (.mboxkey) ------------------------------- -This database is a per-user database and contains the list of mailbox access keys which are used for generating URLAUTH-authorized URLs. The database is indexed by mailbox name and each data record contains the database version number and the associated access key. The format of each record is follows:: +This database is a per-user database and contains the list of mailbox access +keys which are used for generating URLAUTH-authorized URLs. The database is +indexed by mailbox name and each data record contains the database version +number and the associated access key. The format of each record is follows:: Key: Data: -File type can be: `twoskip`_ (default), or `skiplist`_. +File type can be: `twoskip`_ (default) or `skiplist`_. .. _imap-concepts-deployment-db-userdav: @@ -294,7 +352,7 @@ DAV Index (.dav) This embedded SQLite database is per-user and primarily maintains a mapping from DAV resource names (URLs) to the corresponding Cyrus mailboxes and IMAP message UIDs. The database is designed to have -one table per resource type (iCalendar, vCard, etc) with each table +one table per resource type (iCalendar, vCard, Sieve, etc) with each table containing metadata specific to that resource type. CalDAV @@ -356,6 +414,35 @@ The format of the vCard table used by CardDAV is as follows:: ); +Sieve +####### + +The format of the Sieve table used by JMAP and ManageSieve is as follows:: + + CREATE TABLE sieve_scripts ( + rowid INTEGER PRIMARY KEY, + creationdate INTEGER, + lastupdated INTEGER, + mailbox TEXT NOT NULL, + imap_uid INTEGER, + modseq INTEGER, + createdmodseq INTEGER, + id TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + isactive INTEGER, + alive INTEGER, + UNIQUE( mailbox, imap_uid ), + UNIQUE( id ) + ); + + +Because ManageSieve requires the server to locate a resource +by name, the Sieve table has an additional index as follows:: + + CREATE INDEX idx_sieve_name ON sieve_scripts ( name ); + + .. _storagetypes: Storage types diff --git a/docsrc/imap/concepts/deployment/deployment_scenarios.rst b/docsrc/imap/concepts/deployment/deployment_scenarios.rst index eacc8d53a3..a36224eb02 100644 --- a/docsrc/imap/concepts/deployment/deployment_scenarios.rst +++ b/docsrc/imap/concepts/deployment/deployment_scenarios.rst @@ -42,7 +42,7 @@ IMAP Proxy An IMAP proxy like NGINX could sit in front of a number of stand-alone Cyrus IMAP servers, proxying client connections through to the correct stand-alone Cyrus IMAP server for a user. -Note that in this type of setup, it is the user authentication that directs the proxy to the correct stand-alone Cyrus IMAP server. As such, shared mailboxes can only exist on the stand-alone Cyrus IMAP server to which the user is proxied –in other words, on which the user's own mailbox is supposed to exist. +Note that in this type of setup, it is the user authentication that directs the proxy to the correct stand-alone Cyrus IMAP server. As such, shared mailboxes can only exist on the stand-alone Cyrus IMAP server to which the user is proxied -- in other words, on which the user's own mailbox is supposed to exist. .. _cyrus_imap_murder: diff --git a/docsrc/imap/concepts/deployment/known_protocol_limitations.rst b/docsrc/imap/concepts/deployment/known_protocol_limitations.rst index 4c165960c3..1dead11e15 100644 --- a/docsrc/imap/concepts/deployment/known_protocol_limitations.rst +++ b/docsrc/imap/concepts/deployment/known_protocol_limitations.rst @@ -1,21 +1,30 @@ Known Protocol Limitations ========================== -This chapter lists known limitations to protocols commonly in use today, that may impact your deployment. +This chapter lists known limitations to protocols commonly in use today, that +may impact your deployment. POP3 and Mailbox Locking ------------------------ -POP3, as described in `RFC 1939 `__, requires a mailbox to be locked by a POP3 session. +POP3, as described in :rfc:`1939`, requires a mailbox to be locked by a POP3 +session. -As such, when POP3 is used simultaneously across multiple clients, and a common set of mailboxes, an error similar to the following would occur:: +As such, when POP3 is used simultaneously across multiple clients, and a common +set of mailboxes, an error similar to the following would occur:: Mailbox locked by POP server. -The exact error message may be subject to the specific error message a client application wishes to display. +The exact error message may be subject to the specific error message a client +application wishes to display. Cyrus IMAP POP3 Implementation ------------------------------ -The Cyrus IMAP POP3 server implementation does not have the aforementioned problem of POP3 sessions locking mailboxes. As of version 2.4.0, Cyrus IMAP allows multiple POP3 sessions to operate on a single mailbox by providing a - virtual - snapshot of the mailbox, and all operations are executed to this snapshot. A safety mechanism ensures no messages are deleted until after all existing operations have closed the mailbox - including IMAP, LMTP and POP. +The Cyrus IMAP POP3 server implementation does not have the aforementioned +problem of POP3 sessions locking mailboxes. As of version 2.4.0, Cyrus IMAP +allows multiple POP3 sessions to operate on a single mailbox by providing a +*virtual* snapshot of the mailbox, and all operations are executed to this +snapshot. A safety mechanism ensures no messages are deleted until after all +existing operations have closed the mailbox - including IMAP, LMTP and POP. diff --git a/docsrc/imap/concepts/deployment/performance_recommendations.rst b/docsrc/imap/concepts/deployment/performance_recommendations.rst index b75de01c81..4d6198e27e 100644 --- a/docsrc/imap/concepts/deployment/performance_recommendations.rst +++ b/docsrc/imap/concepts/deployment/performance_recommendations.rst @@ -19,3 +19,6 @@ In-memory filesystems are faster then disk filesystems, but are limited in space Cyrus IMAP requires the parent directories to exist, and be writeable by the POSIX user account Cyrus IMAP runs under, prior to starting the ``master`` process. +Certificates +------------ +Cyrus IMAP can be configured to provide as server two kind of certificates: EC and RSA. The EC certificates are only sent to the client, if the client supports them. EC certificates have the advantage of requiring less computational resources compared to RSA, while offering comparatively the same security. diff --git a/docsrc/imap/concepts/deployment/supported-platforms.rst b/docsrc/imap/concepts/deployment/supported-platforms.rst index 7a8fa07114..5fb0c25e17 100644 --- a/docsrc/imap/concepts/deployment/supported-platforms.rst +++ b/docsrc/imap/concepts/deployment/supported-platforms.rst @@ -5,7 +5,8 @@ Cyrus IMAP supports the following platforms; * FreeBSD -* All reasonably recent versions of Linux, including but not limited to the following distributions, in no particular order other than alphabetic; +* All reasonably recent versions of Linux, including but not limited to the + following distributions, in no particular order other than alphabetic; * `CentOS `__ * `Debian `__ @@ -15,21 +16,26 @@ Cyrus IMAP supports the following platforms; * `Red Hat Enterprise Linux `__ * `SUSE Linux `__ - Should your Linux distribution not be listed here, please refer to :ref:`support` for ways of contacting the Cyrus IMAP team. + Should your Linux distribution not be listed here, please refer to + :ref:`support` for ways of contacting the Cyrus IMAP team. * Solaris -By reasonably recent versions of Linux, we intend to indicate the Cyrus project can keep up with the latest distribution release earmarked stable. +By reasonably recent versions of Linux, we intend to indicate the Cyrus project +can keep up with the latest distribution release earmarked stable. Building Cyrus IMAP ------------------- -In this section, we only list the aspects of building Cyrus IMAP of particular interest to most common deployment scenarios. For more information on all ``configure`` options with full details, we refer you to ``./configure --help``. +In this section, we only list the aspects of building Cyrus IMAP of particular +interest to most common deployment scenarios. For more information on all +``configure`` options with full details, we refer you to ``./configure --help``. Required Software Components ---------------------------- -The following software components are required for Cyrus IMAP to build at all, with minimal functionality; +The following software components are required for Cyrus IMAP to build at all, +with minimal functionality; * ``autoconf`` * ``automake`` @@ -40,22 +46,27 @@ Obviously, the list is not complete Recommended Software Components ------------------------------- -We recommend you consider building Cyrus IMAP with the following software components included; +We recommend you consider building Cyrus IMAP with the following software +components included; Idled Support """"""""""""" -To enable near real-time client updates through IMAP IDLE (as described in `RFC 2177 `__), configure Cyrus IMAP with the ``--enable-idled`` option. +To enable near real-time client updates through IMAP IDLE (as described in +:rfc:`2177`), configure Cyrus IMAP with the ``--enable-idled`` option. Murder Support """""""""""""" -To enable horizontal scalability, Cyrus IMAP supports the distribution of mailboxes across Cyrus IMAP servers in a Murder setup. To enable murder support in Cyrus IMAP, configure Cyrus IMAP with the ``--enable-murder`` option. +To enable horizontal scalability, Cyrus IMAP supports the distribution of +mailboxes across Cyrus IMAP servers in a Murder setup. To enable murder support +in Cyrus IMAP, configure Cyrus IMAP with the ``--enable-murder`` option. Replication Support """"""""""""""""""" -To enable replication support in Cyrus IMAP, configure Cyrus IMAP with the ``--enable-replication`` option. +To enable replication support in Cyrus IMAP, configure Cyrus IMAP with the +``--enable-replication`` option. Obviously, the list is not complete @@ -65,18 +76,24 @@ Recommended Software Components Enabled by Default Sieve Support """"""""""""" -Without any additional effort, Sieve support is already enabled by default. To disable Sieve, use the ``--disable-sieve`` option to ``configure``. +Without any additional effort, Sieve support is already enabled by default. To +disable Sieve, use the ``--disable-sieve`` option to ``configure``. Optional Software Components """""""""""""""""""""""""""" -When including the following software components during the build process, and providing the options listed here, additional optional functionality can be implemented; +When including the following software components during the build process, +and providing the options listed here, additional optional functionality can +be implemented; **MySQL** (Development headers) -To enable using MySQL as a database server backend, include the MySQL development headers and make sure to configure Cyrus IMAP with ``--with-mysql``. +To enable using MySQL as a database server backend, include the MySQL +development headers and make sure to configure Cyrus IMAP with +``--with-mysql``. -Should MySQL - the client libraries or the development headers - be installed in a non-standard location, please consider using any of the following options; +Should MySQL - the client libraries or the development headers - be installed +in a non-standard location, please consider using any of the following options; :: @@ -86,9 +103,13 @@ Should MySQL - the client libraries or the development headers - be installed in **PostgreSQL** (Development headers) -To enable using PostgreSQL as a database server backend, include the PostgreSQL development headers and make sure to configure Cyrus IMAP with ``--with-pgsql``. +To enable using PostgreSQL as a database server backend, include the +PostgreSQL development headers and make sure to configure Cyrus IMAP with +``--with-pgsql``. -Should PostgreSQL - the client libraries or the development headers - be installed in a non-standard location, please consider using any of the following options; +Should PostgreSQL - the client libraries or the development headers - be +installed in a non-standard location, please consider using any of the +following options; :: diff --git a/docsrc/imap/concepts/features.rst b/docsrc/imap/concepts/features.rst index bc1116ed30..27b03f3c98 100644 --- a/docsrc/imap/concepts/features.rst +++ b/docsrc/imap/concepts/features.rst @@ -17,7 +17,7 @@ from other IMAP server implementations in that it is run on *sealed nodes*, where users are not normally permitted to log in. The mailbox database is stored in parts of the filesystem that are private to the Cyrus IMAP system. All user access to mail is through software using -the IMAP, IMAPS, POP3, POP3S, KPOP, CalDAV and/or CardDAV protocols. +the IMAP, IMAPS, POP3, POP3S, JMAP, CalDAV and/or CardDAV protocols. The private mailbox database design gives the Cyrus IMAP server large advantages in efficiency, scalability, and administrability. Multiple diff --git a/docsrc/imap/concepts/features/access-control.rst b/docsrc/imap/concepts/features/access-control.rst index 79006a1525..182a8a7d82 100644 --- a/docsrc/imap/concepts/features/access-control.rst +++ b/docsrc/imap/concepts/features/access-control.rst @@ -17,8 +17,4 @@ to information contained within the Cyrus IMAP mailspool. * :ref:`imap-admin-access-control-defaults` * :ref:`imap-admin-access-control-identifiers` -.. toctree:: - :hidden: - :glob: - Back to :ref:`imap-features` diff --git a/docsrc/imap/concepts/features/archiving.rst b/docsrc/imap/concepts/features/archiving.rst index eb1d428317..56fb1a80e1 100644 --- a/docsrc/imap/concepts/features/archiving.rst +++ b/docsrc/imap/concepts/features/archiving.rst @@ -52,8 +52,8 @@ And to control the criteria used to manage migration of data between partitions: .. include:: /imap/reference/manpages/configs/imapd.conf.rst - :start-after: startblob archive_days - :end-before: endblob archive_days + :start-after: startblob archive_after + :end-before: endblob archive_after .. include:: /imap/reference/manpages/configs/imapd.conf.rst :start-after: startblob archive_maxsize diff --git a/docsrc/imap/concepts/features/event-notifications.rst b/docsrc/imap/concepts/features/event-notifications.rst index e3be442faa..104ca0e74d 100644 --- a/docsrc/imap/concepts/features/event-notifications.rst +++ b/docsrc/imap/concepts/features/event-notifications.rst @@ -355,7 +355,7 @@ ApplePushService ---------------- While Cyrus supports the Apple Push Service, Apple has only licensed Apple Push -for mail to a couple of large mail providers: FastMail and Yahoo. If you own an +for mail to a couple of large mail providers: Fastmail and Yahoo. If you own an OS X Server license, you also get a key for personal use. But it's not a supported option for third party developers at this time. diff --git a/docsrc/imap/concepts/features/sealed-system.rst b/docsrc/imap/concepts/features/sealed-system.rst index 1b85177e31..2d9241637b 100644 --- a/docsrc/imap/concepts/features/sealed-system.rst +++ b/docsrc/imap/concepts/features/sealed-system.rst @@ -13,7 +13,7 @@ user. The message spool directory or directories are held privately by the Cyrus IMAP software, and can be accessed by user through IMAP, POP, NNTP -or KPOP protocols. +or JMAP protocols. This design concept vastly increases the efficiency, scalability and security of Cyrus IMAP, and makes it easier to configure, maintain, diff --git a/docsrc/imap/concepts/features/server-aggregation.rst b/docsrc/imap/concepts/features/server-aggregation.rst index b133270265..0c99fadd38 100644 --- a/docsrc/imap/concepts/features/server-aggregation.rst +++ b/docsrc/imap/concepts/features/server-aggregation.rst @@ -55,7 +55,7 @@ The client connection is therefore to be proxied to the appropriate It is not at all uncommon to (reverse) proxy client connections like this (a task that ``imap.example.org`` takes on in this example). -In the case of webservers for example, reverse proxying is an very +In the case of webservers for example, reverse proxying is a very common practice: .. graphviz:: diff --git a/docsrc/imap/concepts/features/virtual-domains.rst b/docsrc/imap/concepts/features/virtual-domains.rst index cf8552927d..7df262c8ef 100644 --- a/docsrc/imap/concepts/features/virtual-domains.rst +++ b/docsrc/imap/concepts/features/virtual-domains.rst @@ -16,19 +16,39 @@ There are two ways in which Cyrus can determine the domain: Fully qualified userid The client logs in with a userid containing the domain in which the user belongs (for example: - ``test@cyrusisgreat.org`` or ``test%ilovecyrus.com``) + ``test@cyrusisgreat.org`` or ``test@ilovecyrus.com``) IP address The server looks up the domain based on the IP address of the receiving interface (useful for servers with multiple NICs or those using IP aliasing) -If the ``virtdomains`` option is set to ``on`` (or ``yes``, ``1``, ``true``), -Cyrus uses both mechanisms to work out the domain (with the fully qualified userid -taking precedence). +If the ``virtdomains`` option is set to ``off`` (or ``no``, ``0``, ``false``), +Cyrus does not know or care about domains, and only ever considers the local +part of email addresses. This configuration is never recommended, but is +currently the default. If the ``virtdomains`` option is set to ``userid``, then only the -fully qualified userid is used. +fully qualified userid is used. This is the only recommended configuration +for new deployments, and in the future may become the default or only option. +Existing deployments should strongly consider migrating towards this +configuration. + +If the ``virtdomains`` option is set to ``on`` (or ``yes``, ``1``, ``true``), +Cyrus uses both mechanisms to work out the domain (with the fully qualified +userid taking precedence). This configuration is not recommended. + +.. note:: + If you are providing calendaring services, you MUST use the + ``virtdomains: userid`` configuration. Calendaring services require + a consistent single authoritative fully-qualified email address for + each user in order to function, and this is the only configuration + that provides it. + + The ``virtdomains: off`` and ``virtdomains: on`` configurations both + allow users' domains to be changed from outside of Cyrus without Cyrus + knowing about it, which fundamentally breaks calendaring. These + configurations are only suitable for IMAP-only deployments. Concepts ======== @@ -41,17 +61,17 @@ Names can be qualified Here are some examples: - * ``cyradm> create user.lukecage@example.net`` - create a user - * ``cyradm> create user.mercedesknight@example.net`` - create another user - * ``cyradm> setquota user.lukecage@example.net 50000`` - define a quota - * ``cyradm> setaclmailbox user.lukecage@example.net mercedesknight@example.net read`` - give Mercedes Knight read access to Luke Cage's mailbox + * ``cyradm> create user/lukecage@example.net`` - create a user + * ``cyradm> create user/mercedesknight@example.net`` - create another user + * ``cyradm> setquota user/lukecage@example.net 50000`` - define a quota + * ``cyradm> setaclmailbox user/lukecage@example.net mercedesknight@example.net read`` - give Mercedes Knight read access to Luke Cage's mailbox * ``cyradm> listmailbox *@example.net`` - list all mailboxes in the example.net domain Each mailbox exists in only one domain Domains are mutually exclusive Users only have access to mailboxes within their own domain (intra-domain). The following - example will not work: ``setacl user.mercedesknight@herdomain.com + example will not work: ``setacl user/mercedesknight@herdomain.com lukecage@hisdomain.com read``. Global and Domain admins @@ -71,7 +91,7 @@ MOST OF THIS SHOULD BE IN DEPLOYMENT GUIDE? Quick Start =========== -* Add ``virtdomains: yes`` to :cyrusman:`imapd.conf(5)` +* Add ``virtdomains: userid`` to :cyrusman:`imapd.conf(5)` * Add a ``defaultdomain`` entry to :cyrusman:`imapd.conf(5)` * Use cyradm (as a global or domain admin) to create mailboxes for each domain. @@ -193,11 +213,11 @@ specifying mailboxes outside of the ``defaultdomain``. Examples To create a new INBOX for user 'test' in ``defaultdomain``:: - cm user.test + cm user/test To create a new INBOX for user 'test' in domain 'example.com':: - cm user.test@example.com + cm user/test@example.com To list all mailboxes in domain 'example.com':: diff --git a/docsrc/imap/concepts/overview_and_concepts.rst b/docsrc/imap/concepts/overview_and_concepts.rst index dcf26d1d88..84752cc920 100644 --- a/docsrc/imap/concepts/overview_and_concepts.rst +++ b/docsrc/imap/concepts/overview_and_concepts.rst @@ -55,7 +55,7 @@ command in :cyrusman:`cyradm(8)`: .. parsed-literal:: - localhost> **listaclmamilbox tech/%** + localhost> **listaclmailbox tech/%** tech/Commits: group:tech lrswipkxtea anyone lrs @@ -69,7 +69,7 @@ command in :cyrusman:`cyradm(8)`: group:tech lrswipkxtecda anyone lrsp - localhost> **listaclmamilbox user/bovik/%** + localhost> **listaclmailbox user/bovik/%** user/bovik/Drafts: bovik lrswipkxtecda user/bovik/Sent: @@ -226,7 +226,7 @@ If the ``loginuseacl`` configuration option is turned on, than any Kerberos iden Shared Secrets Logins ===================== -Some mechanisms require the user and the server to share a secret (generally a password) that can be used for comparison without actually passing the password in the clear across the network. For these mechanism (such as CRAM-MD5 and DIGEST-MD5), you will need to supply a source of passwords, such as the sasldb (which is described more fully in the :ref:`Cyrus SASL distribution `) +The SCRAM mechanisms require the user and the server to share a secret (generally a password) that can be used for comparison without actually passing the password in the clear across the network. For these mechanisms, you will need to supply a source of passwords, such as the sasldb (which is described more fully in the :ref:`Cyrus SASL distribution `). Quotas ****** @@ -321,9 +321,14 @@ the sender. Quota Warnings Upon Select When User Has ``d`` Rights ===================================================== -When a user selects a mailbox whose quota root has usage that is close to or over the limit and the user has ``d`` rights on the mailbox, the server will issue an alert notifying the user that usage is close to or over the limit. The threshold of usage at which the server will issue quota warnings is set by the ``quotawarn`` configuration option. +When a user selects a mailbox whose quota root has usage that is close to or +over the limit and the user has ``d`` rights on the mailbox, the server will +issue an alert notifying the user that usage is close to or over the limit. +The threshold of usage at which the server will issue quota warnings is set +by the ``quotawarnpercent`` configuration option. -The server only issues warnings when the user has ``d`` rights because only users with ``d`` rights are capable of correcting the problem. +The server only issues warnings when the user has ``d`` rights because only +users with ``d`` rights are capable of correcting the problem. Quotas and Partitions ===================== @@ -400,8 +405,6 @@ uses the server identity ``imap.host@realm``, where ``host`` is the first component of the server's host name and ``realm`` is the server's Kerberos realm. -When the POP3 server is invoked with the ``-k`` switch, the -server exports MIT's KPOP protocol instead of generic POP3. The syslog facility ******************* diff --git a/docsrc/imap/developer.rst b/docsrc/imap/developer.rst index 149f0e4261..383d2a5599 100644 --- a/docsrc/imap/developer.rst +++ b/docsrc/imap/developer.rst @@ -15,6 +15,7 @@ Getting Started developer/compiling installing developer/developer-testing + developer/coverage developer/jmap .. toctree:: @@ -41,4 +42,6 @@ Releasing .. toctree:: developer/releasing + developer/major-releasing + developer/snapshot-releasing developer/ancient-releasing diff --git a/docsrc/imap/developer/API/cyrusdb.rst b/docsrc/imap/developer/API/cyrusdb.rst index eaedf507ff..7404eec3cb 100644 --- a/docsrc/imap/developer/API/cyrusdb.rst +++ b/docsrc/imap/developer/API/cyrusdb.rst @@ -84,7 +84,7 @@ Internally, the main module for each database sets up struct of pointers to the cyrusdb functions it implements, which is registered in ``lib/cyrusdb.c`` -``lib/cyrus.c`` provides backend-agnostic wrapper functions for +``lib/cyrusdb.c`` provides backend-agnostic wrapper functions for interacting with cyrusdb databases. A full example @@ -136,7 +136,7 @@ imap/ctl_cyrusdb startup on every cyrus installation, so you'll find quite a lot of detritus has built up in this codepath over the years. * EVENTS: "ctl_cyrusdb -c" (checkpoint). - This is run regularly (period=180 at FastMail, examples in the + This is run regularly (period=180 at Fastmail, examples in the codebase have period=5 or period=30). Both this codepath and cyr_expire tend to run periodically on cyrus systems, and cleanup code is spread between those two locations. @@ -309,7 +309,7 @@ something like:: static int exists_cb(void rock attribute((unused)), [...]) { - return CYRUSDB_DONE; / one is enough */ + return CYRUSDB_DONE; /* one is enough */ } and then use ``exists_cb`` as your ``foreach_cb`` and check if the @@ -399,7 +399,7 @@ transaction, does the operation, then commits all within the Gotchas! -------- -* NULL is permitted in both keys and values, though 'flat' and +* ``\0`` is permitted in both keys and values, though 'flat' and 'quotalegacy' have 8-bit cleanliness issues. * zero-length keys are not supported diff --git a/docsrc/imap/developer/API/cyrusdb2.rst b/docsrc/imap/developer/API/cyrusdb2.rst index f2d5cb9676..42760e3cd6 100644 --- a/docsrc/imap/developer/API/cyrusdb2.rst +++ b/docsrc/imap/developer/API/cyrusdb2.rst @@ -50,7 +50,7 @@ Internally, the main module for each database sets up struct of pointers to the cyrusdb functions it implements, which is registered in ``lib/cyrusdb.c`` -``lib/cyrus.c`` provides backend-agnostic wrapper functions for +``lib/cyrusdb.c`` provides backend-agnostic wrapper functions for interacting with cyrusdb databases. A full example diff --git a/docsrc/imap/developer/API/mailbox-api.rst b/docsrc/imap/developer/API/mailbox-api.rst index 5abb5b73ec..efe16839c4 100644 --- a/docsrc/imap/developer/API/mailbox-api.rst +++ b/docsrc/imap/developer/API/mailbox-api.rst @@ -145,6 +145,7 @@ with that name. int mailbox_rename_copy(struct mailbox *oldmailbox, const char *newname, const char *newpart, const char *userid, int ignorequota, + int silent, struct mailbox **newmailboxptr); Very similar to mailbox\_create - the new mailbox is created with an diff --git a/docsrc/imap/developer/ancient-releasing.rst b/docsrc/imap/developer/ancient-releasing.rst index 1aa8e743b0..098a111889 100644 --- a/docsrc/imap/developer/ancient-releasing.rst +++ b/docsrc/imap/developer/ancient-releasing.rst @@ -55,6 +55,11 @@ Pre-release testing vi. Compile it: ``make`` -- it should build correctly. vii. Run the unit tests if there are any: ``make check`` -- they should pass. +.. Note:: + We don't bother to run ``make distcheck`` on the old branches, because it + almost certainly won't work. We also don't bother to run Cassandane, for + much the same reason. If it builds, that's about as much as we can do. + Cross-pollination of release notes ================================== @@ -63,7 +68,7 @@ release notes for these versions appear on the cyrusimap.org website, they need be added to current branches as well. 1. Change to the current stable branch (at time of writing, this is ``cyrus-imapd-3.0``). -2. Create a new release notes at the appropriate location under +2. Create a new release notes document at the appropriate location under ``docsrc/imap/download/release-notes/``. 3. Add the release notes you wrote earlier, this time using RST format rather than simple HTML. @@ -96,19 +101,7 @@ Building the release 11. Push the tag upstream: ``git push ci cyrus-imapd-`` (assuming your remote is named "ci"). -Releasing -========= - -1. Upload the tarball and signature to www: ``scp cyrus-imapd-*.tar.gz cyrus-imapd-*.tar.gz.sig - www.cyrusimap.org:/var/www/html/releases/`` -2. Upload them to ftp too: ``scp cyrus-imapd-*.tar.gz cyrus-imapd-*.tar.gz.sig - ftp.cyrusimap.org:/srv/ftp/cyrus-imapd/`` -3. SSH into both www and ftp, and move older releases to the old versions - directory. You want only the two most recent tarball+sig pairs for each - major series. -4. While SSH'd into www, build the legacy documentation for this release: - - i. Copy the release tarball to /var/tmp - ii. Run the script to publish the tarball documentation to the web: - ``/usr/local/bin/PublishCyrusDocs.pl -f /var/tmp/cyrus-imapd-.tar.gz`` -5. Send an announcement to the info-cyrus and cyrus-announce lists. +Finishing up +============ + +Now follow the remaining steps from :ref:`imap-developer-releasing` diff --git a/docsrc/imap/developer/compiling.rst b/docsrc/imap/developer/compiling.rst index c8e09859ed..e9f5032352 100644 --- a/docsrc/imap/developer/compiling.rst +++ b/docsrc/imap/developer/compiling.rst @@ -4,9 +4,22 @@ Compiling ========= -These instructions are based on Debian 8.0 because it has to be based on something. Other Linux distributions will be similar in the broad ideas but may differ in the specifics. If you already have a preferred distro, use that (we assume you know how to use its package management system). If you don't already have a preferred distro, maybe consider using Debian. +These instructions are based on Debian 8.0 because it has to be based on +something. Other Linux distributions will be similar in the broad ideas but may +differ in the specifics. If you already have a preferred distro, use that (we +assume you know how to use its package management system). If you don't already +have a preferred distro, maybe consider using Debian. -First make sure you have a :ref:`copy of the source `. You can either fetch the latest source from git, or using one of our release tarballs. +First make sure you have a :ref:`copy of the source `. You can either +fetch the latest source from git, or download one of our release tarballs. + +.. Note:: + + Cyrus does not support compiling with `Link Time Optimization + `_, + but some platforms now enable Link Time Optimization by default. + If your platform does so, you will need to override it, perhaps + by adding ``-fno-lto`` to ``CFLAGS`` and ``LDFLAGS``. Setting up dependencies ======================= @@ -14,10 +27,11 @@ Setting up dependencies Required Build Dependencies --------------------------- -Building a basic Cyrus that can send and receive email: the minimum libraries required to build a functional Cyrus. +Building a basic Cyrus that can send and receive email: the minimum libraries +required to build a functional cyrus-imapd. -.. csv-table:: Build Dependencies - :header: "Package", "Debian", "RedHat" +.. csv-table:: + :header: "Package", "Debian", "RedHat", "Notes" `autoconf`_, "autoconf", "autoconf" `automake`_, "automake", "automake" @@ -27,9 +41,9 @@ Building a basic Cyrus that can send and receive email: the minimum libraries re `gcc`_, gcc, gcc `gperf`_, gperf, gperf `jansson`_, libjansson-dev, jansson-devel - `libbsd`_ ,libbsd-dev, libbsd-devel + `libbsd`_, libbsd-dev, libbsd-devel `libtool`_, libtool, libtool - `ICU`_, libicu-dev, libicu-devel + `ICU`_, libicu-dev, libicu-devel, "version 55 or newer" `uuid`_, uuid-dev, libuuid-devel `openssl`_ :ref:`(Note about versions) `, libssl-dev, openssl-devel `pkgconfig`_, pkg-config, pkgconfig @@ -55,111 +69,172 @@ To install all dependencies from packages on Debian Jessie, use this: .. include:: /assets/cyrus-build-reqpkg.rst -Optional Build Dependencies ---------------------------- +Build dependencies for additional functionality +----------------------------------------------- -The following build dependencies are optional, and enable functionality, -code maintenance tasks or building the documentation. +The following dependencies enable additional functionality, help with +code maintenance tasks or are required for building the documentation. Developers only ############### -.. csv-table:: - :header: "Package", "Debian", "RedHat", "Required for ``make check``?", "Notes" - :widths: 20,15,15,5,45 +The developer dependencies are required if you are building from git sources, +have modified certain source files from the release tarball, or have configured +with ``--enable-maintainer-mode`` in order to build a new package. - `CUnit`_, libcunit1-dev, cunit-devel, "yes", "Development headers for compiling Cyrus IMAP's unit tests." - `perl(ExtUtils::MakeMaker)`_, ??, ??, "no", "Perl library to assist in building extensions to Perl. +If you are building normally from a pure release tarball, then you don't need +these dependencies. The files, these dependencies produce, have been pre-built +and included in the release, and do not normally need to be re-built. - Configure option: ``--with-perl``" - `libdb-dev`, libdb-dev, libdb-devel, "no", "The -dev package must match the version of libdb you already have installed (assuming it's probably already installed). On Debian 8.0, ``libdb5.3-dev`` is needed, but ``libdb5.1-dev`` on 7.8." - `perl-devel`_, perl-dev, perl-devel, "no", "Perl development headers to allow building binary perl libraries. Needs version 5+. +.. csv-table:: + :header: "Package", "Debian", "RedHat", "Required", "Notes" + :widths: 20,15,15,5,45 - Configure option: ``--with-perl``" + `perl-devel`_, perl-dev, perl-devel, "no", "Needed for building binary perl + libraries, version 5+." + `perl(ExtUtils::MakeMaker)`_,,, "no", "Needed for building extensions to + Perl." + `perl(Pod::POM::View::Restructured)`_,,, "no", "Needed to generate man + pages. This has to be available to the system-wide perl interpreter, found + by ``which``." + `python(GitPython)`_,,, "no", "Needed for building the documentation." + `python(Sphinx)`_,,, "no", "Needed for building the documentation." + `transfig`_, transfig, transfig, "no", "Also known as fig2dev, transfig is + an artifact from the old days, and is only used for generation of a couple + of png files in the legacy documentation (doc/legacy/murder.png and + doc/legacy/netnews.png). One day it should be merged into the current + documentation, cause then we can get rid of it: `issues/1769`_." `valgrind`_, valgrind, valgrind, "no", "Performance and memory testing." SASL Authentication ################### .. csv-table:: - :header: "Package", "Debian", "RedHat", "Required for ``make check``?", "Notes" + :header: "Package", "Debian", "RedHat", "Required", "Notes" :widths: 20,15,15,5,45 - `Cyrus SASL Plain`_, libsasl2-modules, cyrus-sasl-plain, "yes", "Cyrus SASL package that ships the \ - library required to pass Cyrus IMAP's PLAIN authentication unit tests." - `Cyrus SASL MD5`_, libsasl2-modules, cyrus-sasl-md5, "yes", "Cyrus SASL library required to pass Cyrus IMAP's DIGEST-MD5 - authentication unit tests" - `sasl binaries`_, sasl2-bin, sasl2-bin, "no", "Administration tools for managing SASL" - `Kerberos`_, libsasl2-modules-gssapi-mit, krb5-devel, "no", "Development headers required to enable Kerberos v5 authentication - capabilities. Otherwise also known as the authentication mechanism *GSSAPI*. - - Configure option: ``--with-krbimpl=mit`` " + `Cyrus SASL Plain`_, libsasl2-modules, cyrus-sasl-plain, "yes/no", "Required + to pass Cyrus IMAP's PLAIN authentication unit tests." + `sasl binaries`_, sasl2-bin, sasl2-bin, "no", "Administration tools for + managing SASL." + `Kerberos`_, libsasl2-modules-gssapi-mit, krb5-devel, "yes/no", "Development + headers required to enable Kerberos v5 authentication capabilities, also + known as the authentication mechanism *GSSAPI*. Configure option: + ``--with-krbimpl=mit``." Alternate database formats ########################## .. csv-table:: - :header: "Package", "Debian", "RedHat", "Required for ``make check``?", "Notes" + :header: "Package", "Debian", "RedHat", "Required", "Notes" :widths: 20,15,15,5,45 - `mysql`_ or `mariadb`_, libmysqlclient-dev or libmariadb-dev, mysql-devel or mariadb-devel, "no", "MariaDB or MySQL development headers, to allow Cyrus IMAP to use - it as the backend for its databases. + `mysql`_ or `mariadb`_, "libmysqlclient-dev or libmariadb-dev", "mysql-devel + or mariadb-devel", "yes/no", "MariaDB or MySQL development headers, required + to allow Cyrus IMAP to use it as the backend for its databases. Configure + options: ``--with-mysql``, ``--with-mysql-incdir``, + ``--with-mysql-libdir``." + `postgresql`_, postgresql-dev, postgresql-devel, "yes/no", "PostgreSQL + development headers, required to allow Cyrus IMAP to use it as the backend + for its databases. Configure option: ``--with-pgsql``." - Configure option: ``--with-mysql``, ``--with-mysql-incdir``, ``--with-mysql-libdir``" - `postgresql`_, postgresql-dev, postgresql-devel, "no" - -CalDAV and/or CardDAV -##################### +CalDAV, CardDAV, or JMAP (httpd subsystem) +########################################## .. csv-table:: - :header: "Package", "Debian", "RedHat", "Required for ``make check``?", "Notes" + :header: "Package", "Debian", "RedHat", "Required", "Notes" :widths: 20,15,15,5,45 - `libical`_, libical-dev, libical-devel, "no", "libical >= 0.48 required for scheduling support. - **Note:** Linux distributions Enterprise Linux 6 and Debian Squeeze are - known to ship outdated **libical** packages versions 0.43 and - 0.44 respectively. The platforms will not support scheduling." - `libxml`_, libxml2-dev, libxml2-devel, "", "no" + `libbrotli`_, libbrotli-dev, brotli-devel, "no", "It provides Brotli + compression support for http communications (otherwise only ``deflate`` and + ``gzip`` (optionally) would be available)." + `libchardet`_, libchardet-dev, libchardet-devel, "yes/no", "It is used + by the **JMAP** module of httpd to detect the character set of untagged + 8-bit headers. Without it, cyrus-imapd will not do character-set detection. + If some piece of data has no character set coming in, it will have no + character set. Required for JMAP, but otherwise is not needed." + `libical`_, libical-dev, libical-devel, "yes", "It provides + calendaring functionality for CalDAV, which can't be used without this lib. + Version 3.0.0 or higher is required." + `libxml`_, libxml2-dev, libxml2-devel, "yes", "A fundamental lib for + all \*DAV functionality." + `nghttp2`_, libnghttp2-dev, libnghttp2-devel, "no", "HTTP/2 support + for the entire **httpd** subsystem (\*DAV & JMAP)." + `shapelib`_, shapelib, shapelib, "yes/no", "It is required for + **tzdist** service to have geolocation support. Otherwise it is not needed. + Version 1.3.0 or higher is required when using it." + `wslay`_, libwslay-dev, wslay-devel, "no", "It provides WebSockets support + in httpd. Only used with **JMAP**, otherwise not needed. Version 1.1.1 or + higher is required when using it." + `xxd`_, xxd, vim-common, "yes", "Needed for the _js.h files, for CalDAV + and CardDAV support." + `zlib`_, zlib1g-dev, zlib-devel, "no", "It provides gzip compression + support for http communications." Other ##### .. csv-table:: - :header: "Package", "Debian", "RedHat", "Required for ``make check``?", "Notes" + :header: "Package", "Debian", "RedHat", "Required", "Notes" :widths: 20,15,15,5,45 - SSL certificates, ssl-cert-dev, mod_ssl, "no", "Used if you're installing SSL certificates" - `net-snmp`_, libsnmp-dev, net-snmp-devel, "no", "version 4.2 or higher" - `openldap`_, libldap2-dev, openldap-devel, "no", "Development headers to enable **ptloader** to interface with LDAP - directly, for canonification of login usernames to mailbox names, - and verification of login usernames, ACL subjects and group - membership. - - Configure option: ``--with-ldap``" - `tcp_wrappers`_, tcp_wrappers, xx, "no" - `transfig`_, transfig, xx, "no" - `zlib`_, zlib1g-dev, zlib-devel, "no", "Compression support for httpd" - `nghttp2`_, libnghttp2-dev, libnghttp2-devel, "no", "HTTP/2 support for httpd" - + `CUnit`_, libcunit1-dev, cunit-devel, "no", "Development headers for + compiling Cyrus IMAP's unit tests. Required to run ``make check``." + SSL certificates, ssl-cert-dev, mod_ssl, "no", "Used if you're + installing SSL certificates." + `ClamAV`_, libclamav-dev, clamav-devel, "no", "It is used by + **cyr_virusscan**, otherwise not needed." + `CLD2`_, libcld2-dev, cld2-devel, "yes/no", "Compact Language Detector 2 + (probabilistically detects over 80 languages in Unicode UTF-8 text, either + plain text or HTML/XML). Required for **Xapian** (``--enable-xapian``), + otherwise not needed." + `openldap`_, libldap2-dev, openldap-devel, "no", "Development headers + to enable **ptloader** to interface with LDAP directly, for canonification + of login usernames to mailbox names, and verification of login usernames, + ACL subjects and group membership. Configure option: ``--with-ldap``." + `pcre2`_, libpcre2-dev, pcre2-devel, "yes", "PCRE 2 (10.x) - for utf-8/unicode + regular expression matching. Could be replaced by something else in the + future. See `issues/1731`_ for more information." + `perl(Term::ReadLine)`_,,, "no", "Perl library needed by **cyradm**." + `libsrs2`_, *no package*, *no package*, "no", "It is used for + implementing Sender Rewriting Scheme (SRS) functionality for messages + forwarded by sieve scripts. Without it, messages forwarded by sieve scripts + will not have this functionality and might have difficulty delivering to + SMTP servers that insist on it." + +.. _ClamAV: https://www.clamav.net/ .. _CUnit: http://cunit.sourceforge.net/ .. _Cyrus SASL Plain: :ref:`Cyrus SASL ` -.. _Cyrus SASL MD5: :ref:`Cyrus SASL ` +.. _sasl binaries: :ref:`Cyrus SASL ` .. _Kerberos: http://web.mit.edu/kerberos/www/ -.. _libical: http://freeassociation.sourceforge.net/ +.. _libbrotli: https://github.com/google/brotli +.. _libchardet: https://github.com/Joungkyun/libchardet +.. _libical: https://github.com/libical/libical/ .. _libxml: http://xmlsoft.org/ .. _mysql: http://www.mysql.com .. _mariadb: http://mariadb.org -.. _net-snmp: http://net-snmp.sourceforge.net/ +.. _nghttp2: https://nghttp2.org/ .. _openldap: http://www.openldap.org/ +.. _pcre: http://www.pcre.org/ +.. _pcre2: http://www.pcre.org/ +.. _perl(Term::ReadLine): https://metacpan.org/pod/Term::ReadLine .. _perl(ExtUtils::MakeMaker): http://search.cpan.org/dist/ExtUtils-MakeMaker/ +.. _perl(Pod::POM::View::Restructured): https://metacpan.org/pod/Pod::POM::View::Restructured .. _perl-devel: http://www.perl.org/ .. _postgresql: http://www.postgresql.org/ -.. _tcp_wrappers: ftp://ftp.porcupine.org/pub/security/index.html +.. _python(GitPython): https://github.com/gitpython-developers/GitPython +.. _python(Sphinx): https://www.sphinx-doc.org/ +.. _shapelib: http://shapelib.maptools.org +.. _libsrs2: https://www.libsrs2.org/ .. _transfig: http://www.xfig.org/ .. _valgrind: http://www.valgrind.org/ +.. _wslay: https://tatsuhiro-t.github.io/wslay/ .. _zlib: http://zlib.net/ -.. _nghttp2: https://nghttp2.org/ +.. _xxd: https://github.com/ConorOG/xxd/ +.. _CLD2: https://github.com/CLD2Owners/cld2 +.. _issues/1769: https://github.com/cyrusimap/cyrus-imapd/issues/1769 +.. _issues/1731: https://github.com/cyrusimap/cyrus-imapd/issues/1731#issuecomment-273064554 Install tools for building @@ -174,8 +249,9 @@ Optionally install dependencies for :ref:`building the docs `. Compile Cyrus ============= -There are additional :ref:`compile and installation steps` if you are using Xapian for searching, -or if you are :ref:`using jmap `. +There are additional :ref:`compile and installation steps` +if you are using Xapian for searching, or if you are :ref:`using jmap +`. Default build: mail only ------------------------ @@ -196,10 +272,9 @@ please see: # :command:`./configure --help` .. tip:: - Passing environment variables as an argument to configure, - rather than setting them in the environment before running configure, - allows their values to be logged in config.log. This is useful for diagnosing - problems. + Passing environment variables as an argument to configure, rather than + setting them in the environment before running configure, allows their + values to be logged in config.log. This is useful for diagnosing problems. Optional dependencies --------------------- @@ -209,10 +284,10 @@ via configure. Sieve is enabled by default. -CalDAV and CardDAV -################## +CalDAV, CardDAV, WebDAV, JMAP +############################# - ``./configure --enable-http --enable-calalarmd`` + ``./configure --enable-http --enable-calalarmd --enable-jmap`` Murder ###### @@ -245,16 +320,21 @@ Compile The ``--prefix`` option sets where Cyrus is installed to. -It may be of use to also add ``--std=gnu99`` to the ``CFLAGS``. That generates TONS of warnings. +It may be of use to also add ``--std=gnu99`` to the ``CFLAGS``. That generates +TONS of warnings. Having problems with :ref:`compilation ` or :ref:`linking `? -If you're running on Debian, and you install to ``/usr/local``, you may need to update your library loader. Edit ``/etc/ld.so.conf.d/x86_64-linux-gnu.conf`` so it includes the following additional line:: +If you're running on Debian, and you install to ``/usr/local``, you may need to +update your library loader. Edit ``/etc/ld.so.conf.d/x86_64-linux-gnu.conf`` so +it includes the following additional line:: /usr/local/lib/x86_64-linux-gnu -Without this, when you attempt to start Cyrus, it reports ``error while loading shared libraries: libcyrus_imap.so.0: cannot open shared object file: No such file or directory`` because it can't find the Cyrus library in /usr/local/lib. +Without this, when you attempt to start Cyrus, it reports ``error while loading +shared libraries: libcyrus_imap.so.0: cannot open shared object file: No such +file or directory`` because it can't find the Cyrus library in /usr/local/lib. Check ----- @@ -271,4 +351,4 @@ version, operating system and affected libraries. Next: :ref:`installing Cyrus `. -.. _FastMail : https://www.fastmail.com +.. _Fastmail : https://www.fastmail.com diff --git a/docsrc/imap/developer/coverage.rst b/docsrc/imap/developer/coverage.rst new file mode 100644 index 0000000000..bb4ee7101c --- /dev/null +++ b/docsrc/imap/developer/coverage.rst @@ -0,0 +1,101 @@ +.. _coverage: + +============= +Test Coverage +============= + +This assumes you have a single user development environment where you can +already build and install Cyrus, and run the CUnit and Cassandane tests. It +also assumes your Cyrus install and Cassandane setup do not use "destdir", +and that your compiler is GCC. + +We'll be tinkering with group membership and file permissions, so proceed +with caution on multi-user systems. + +When a coverage-enabled binary runs, it writes coverage data into ``foo.gcda`` +files alongside the source files. CUnit runs as you, but Cassandane runs as +cyrus, so we need to arrange for both these users to be able to write to the +source directory. We'll do that using group memberships and the +group-writeable file mode bit. + +One-time setup +============== + +Group membership +---------------- + +1. Add the "cyrus" user account to your own user group +2. Perhaps: also add your user account to the "cyrus" user's group. This might + be "cyrus" or "mail" depending on how you set your system up. I can't + remember if this is actually necessary for coverage, or if I have it for + something else, so skip it unless it becomes necessary (and update this + doc!) + +File permissions +---------------- + +1. Change into your cyrus-imapd directory: ``cd ~/path/to/cyrus-imapd`` +2. Start from a clean state: ``git clean -xfd`` +3. Set the group-writeable bit on everything: ``chmod -R g+w .`` +4. Allow the group-writeable bit on new files you create: ``umask 0002`` +5. Add that ``umask 0002`` line to your .bashrc or equivalent too, otherwise + you'll have to remember to fix up file permissions every time you want to + make a coverage report + +Dependencies +------------ + +You'll need the ``lcov`` and ``genhtml`` tools for producing human-readable +reports. On Debian, these are both found in the ``lcov`` package. + +Preparing a coverage report +=========================== + +Compile Cyrus and run CUnit tests +--------------------------------- + +The collection of coverage data slows things down, and it might also log a lot +of complaints about overwriting old coverage data, or being unable to. So I +do not recommend routinely compiling with coverage enabled -- only do this when +you're preparing a coverage report. + +1. Change into your cyrus-imapd directory: ``cd ~/path/to/cyrus-imapd`` +2. Start from a clean state: ``git clean -xfd`` +3. Configure Cyrus, using your usual configure options, plus + ``--enable-coverage`` +4. Compile Cyrus: ``make -j4`` +5. Run the CUnit tests: ``make -j4 check`` +6. Install Cyrus (might need sudo): ``make install`` + +Run Cassandane +-------------- + +1. Run Cassandane on the installed Cyrus as you usually would + +Generate report +--------------- + +I'd suggest making a script to automate this part. I use one like `this +`_ + +1. Change into your cyrus-imapd directory: ``cd ~/path/to/cyrus-imapd`` +2. Some of the ``foo.gcda`` files will be owned by your user (from the CUnit + run), some will be owned by the cyrus user (from the Cassandane run). + You can use something like this to reclaim the ownership (if your user:group + is ellie:ellie):: + + find . -name \*.gcda -not -user ellie -execdir sudo chown ellie:ellie "{}" + + +3. If you want to keep accumulating results, you'll need to ensure the Cyrus + user can still write to those files. I don't know if this is useful, but + something like this will do it: + ``find . -name \*.gcda -execdir chmod g+rw "{}" +`` +4. Process all those ``foo.gcda`` files into an intermediate form: + ``lcov --directory . -c -o coverage.info`` +5. Strip out unit test and external library clutter: + ``lcov --remove coverage.info "cunit/*" "/usr/*" -o coverage.info`` +6. Generate HTML: + ``genhtml -o coverage coverage.info`` +7. You can now open that report in your browser. Something like this will + give you a link to copy and paste: + ``echo file://$PWD/coverage/index.html`` diff --git a/docsrc/imap/developer/cyrusworks.rst b/docsrc/imap/developer/cyrusworks.rst index 583432d547..69ea810d23 100644 --- a/docsrc/imap/developer/cyrusworks.rst +++ b/docsrc/imap/developer/cyrusworks.rst @@ -5,24 +5,13 @@ Cyrus Works About Cyrus Works ================= -Whenever the Cyrus team push changes to -`the project repository `_, a notification is -sent to `Jenkins `_ (open source automation server). -Our Jenkins server is called Cyrus Works and can be found at -https://www.cyrus.works. - -Testing -======= - -Once a week Cyrus.Works builds a complete image, fetching all upstream packages. - -Interim builds during the week use the cached weekly image and apply the latest -Cyrus IMAP code changes from Git. +`Cyrus Works `_ is a domain redirection to the Cyrus +IMAP project's Travis CI dashboard. -Email notifications of build results are sent to the development team. - -Cyrus.works will fail is certain strings are found in the log files. To view results and filter errors/warnings view: -https://cyrus.works/job/master-jessie/lastFailedBuild/parsed_console/ +Whenever the Cyrus team push changes to +`the project repository `_, Travis CI +(via github integration) automatically builds the new commits. This also +applies to pull requests submitted through the GitHub site. How it works ============ @@ -31,22 +20,3 @@ How it works framework gets pulled in to the `Docker Container `_, confirms existing functionality still works and no regression bugs have been introduced. - -You can find out more about Cyrus.Works in the `FastMail 2016 advent series blog post `_. - -The code used to build Cyrus.works is available https://github.com/cyrusimap/cyrusworks. - -Adding Rules -============ - -Instructions on how to add rules: -https://wiki.jenkins.io/display/JENKINS/Log+Parser+Plugin - -The rules for Cyrus Works are stored within git: https://github.com/cyrusimap/cyrusworks/blob/master/Scripts/cyrusworksrules - -You need to add rules to two places: - -1. **Git**: so when cyrus.works is reinstalled those rules are not lost - -2. **The server**: so they’re actually used. Changed pushed to git aren’t pushed to the server. This is for security reasons (we don’t want anyone on the internet to be able to push changes to a live server). -``root@cyrus.works:/cyrusworks/source/Scripts/cyrusworksrules`` diff --git a/docsrc/imap/developer/developer-testing.rst b/docsrc/imap/developer/developer-testing.rst index 8671d7f0fe..b77ed78016 100644 --- a/docsrc/imap/developer/developer-testing.rst +++ b/docsrc/imap/developer/developer-testing.rst @@ -11,7 +11,9 @@ This assumes you have your :ref:`basic server running ` and you've m Installing Cassandane ===================== -Cassandane is a Perl-based integration test suite for Cyrus. `Cassandane documentation `_ includes information on setting up tests and writing new tests. +Cassandane is a Perl-based integration test suite for Cyrus. +`Cassandane documentation `_ +includes information on setting up tests and writing new tests. Why "Cassandane"? Wikipedia indicates that Cassandane_ was the name of the consort of King Cyrus the Great of Persia, founder of the Achaemenid @@ -22,9 +24,8 @@ Persian Empire. So that's kinda cool. Install and configure Cassandane -------------------------------- -1. Clone the Cassandane repository - - * ``git clone https://github.com/cyrusimap/cassandane.git`` +1. You already have it -- it's in the "cassandane" subdirectory of the cyrus-imapd + sources. 2. Install dependencies @@ -37,13 +38,14 @@ Install and configure Cassandane libdata-uuid-perl libjson-xs-perl libdata-ical-perl libjson-perl \ libdatetime-format-ical-perl libtext-levenshteinxs-perl \ libmime-types-perl libdatetime-format-iso8601-perl libcal-dav-perl \ - libclone-perl libstring-crc32-perl + libclone-perl libstring-crc32-perl libnet-ldap-server-perl The quickest option for the rest is installing via CPAN, but you could build packages using dh-make-perl if that is preferred. .. code-block:: bash + sudo cpan -i AnyEvent Config::IniFiles Data::GUID Digest::CRC File::Slurp IO::File::fcntl IO::Socket::INET6 Net::Server::PreForkSimple News::NNTPClient Plack::Loader Types::Standard Unix::Syslog XML::Generator XML::Simple sudo cpan -i Tie::DataUUID sudo cpan -i XML::Spice sudo cpan -i XML::Fast @@ -57,15 +59,16 @@ Install and configure Cassandane sudo cpan -i Net::CalDAVTalk sudo cpan -i Mail::JMAPTalk sudo cpan -i Math::Int64 + sudo cpan -i Test::Unit -3. Install Cassandane +3. Build Cassandane's binary components .. code-block:: bash - cd /path/to/cassandane + cd /path/to/cyrus-imapd/cassandane make -4. Copy ``cassandane.ini.example`` to ``cassandane.ini`` +4. Copy ``cassandane.ini.example`` to ``cassandane.ini`` in your home directory 5. Edit ``cassandane.ini`` to set up your cassandane environment. @@ -80,12 +83,6 @@ Install and configure Cassandane [imaptest] basedir=/path/to/imaptest/imaptest suppress=append-binary urlauth-binary fetch-binary-mime fetch-binary-mime-qp - * Ensure that the following config items are off: - - .. code-block:: ini - - altnamespace = no - unixhierarchysep = no 6. Create a ``cyrus`` user and matching group and also add ``cyrus`` to group ``mail`` @@ -97,7 +94,14 @@ Install and configure Cassandane 7. Give your user account access to sudo as ``cyrus`` * ``sudo visudo`` - * add a line like:``username ALL = (cyrus) NOPASSWD: ALL``, where "username" is your own username + * add lines like: + + .. code-block:: + + Defaults:username rlimit_core=default + username ALL = (cyrus) NOPASSWD: ALL + + where "username" is your own username 8. Make the ``destdir`` directory, as the ``cyrus`` user @@ -119,7 +123,19 @@ IMAPTest_ is a testing suite which uses libraries from the Dovecot installation. * ``./configure --with-dovecot=../dovecot-2.2 && make`` (No need for make install) * The ``--with-dovecot=`` parameter is used to specify path to Dovecot v2.2 sources' root directory. +This is not quite the same IMAPTest that CI uses. The CI system uses +a docker image, which among other things has Dovecot and IMAPTest already +built in so that they don't need to be rebuilt every time CI runs. + +The docker image is built from Dockerfile_ in the cyrus-docker repo. If you +want to locally reproduce the same testing that CI runs, you can search it +for "dovecot.git" and "imaptest.git" to see how these two components +are fetched and built, and do the same yourself. Briefly, Dovecot is built +from a known commit id on the upstream repository, whereas IMAPTest is built +from the "cyrus" branch of our own fork. + .. _IMAPTest: http://www.imapwiki.org/ImapTest +.. _Dockerfile: https://github.com/cyrusimap/cyrus-docker/blob/master/Debian/Dockerfile Rebuild Cyrus for Testing ========================= @@ -169,12 +185,20 @@ If you're testing across versions, the binsymlinks is necessary as older Cyrus d Running the tests ================= -As user ``cyrus``, run the tests. +Cassandane internals need to run as the ``cyrus`` user, but if you gave +yourself passwordless sudo access as instructed above, then Cassandane will +take care of switching to the ``cyrus`` user for you. In which case, just run +it as yourself. + +If you didn't give yourself this access, you will first need to become the +``cyrus`` user by some other means, and then run it from there. .. code-block:: bash - cd /path/to/cassandane - sudo -u cyrus ./testrunner.pl -f pretty -j 8 + cd /path/to/cyrus-imapd/cassandane + ./testrunner.pl + +Do not run it as root. Debugging and stacktraces ========================= @@ -183,13 +207,23 @@ Check out the guide to :ref:`running Cyrus components under gdb `. In the event of a crash, here's how to :ref:`generate a stacktrace `. +Core dumps will be owned by the ``cyrus`` user, but your source tree will +probably be owned by yourself. Copy the core dump somewhere convenient, +change the ownership to yourself, and then you can open the core file in +gdb for examination. + Tips and Tricks =============== -Read the script to see other options. If you're having problems, add more ``-v`` options to the testrunner to get more info out. +Read the script to see other options. If you're having problems, add more +``-v`` options to the testrunner to get more info out. -**Looking for memory leaks?** Run with --valgrind to use valgrind (if it's installed). It is slower, which is why it doesn't need to be always used. +**Looking for memory leaks?** Run with --valgrind to use valgrind (if it's +installed). It is slower, which is why it doesn't need to be always used. -Running with -v -v is very noisy, but gives a lot more data. For example: all IMAP telemetry. +Running with -v -v is very noisy, but gives a lot more data. For example: all +IMAP telemetry. -Also helpful to run ``sudo tail -f /var/log/syslog``, and examine /var/tmp/cass as root to examine log files and disk structures for failed tests. +Also helpful to run ``sudo tail -f /var/log/syslog``, and examine +/var/tmp/cass as ``cyrus`` to examine log files and disk structures for +failed tests. diff --git a/docsrc/imap/developer/github-guide.rst b/docsrc/imap/developer/github-guide.rst index d7809c8aa8..41e16fe2f8 100644 --- a/docsrc/imap/developer/github-guide.rst +++ b/docsrc/imap/developer/github-guide.rst @@ -42,7 +42,7 @@ you can `generate a new key `_ (source, or documentation), -contributing to tests with `Cassandane `_ or into helping out with `SASL `_ or +or into helping out with `SASL `_ or any of the other `Cyrus component projects `_, use the Fork button to make a copy of the repository into your own GitHub work space. GitHub has more information on `how to fork a repository `_. diff --git a/docsrc/imap/developer/guidance/hacking.rst b/docsrc/imap/developer/guidance/hacking.rst index 2ae21fef03..b0d3539f66 100644 --- a/docsrc/imap/developer/guidance/hacking.rst +++ b/docsrc/imap/developer/guidance/hacking.rst @@ -12,6 +12,14 @@ utilities for people trying to approach the code in a sane way. It's not well organized right now but hopefully that will improve with time ;) +.. warning:: + + This document is woefully out of date. While some parts of it are still + accurate, it has not been reviewed in quite a while. If you're looking at + the code and it doesn't seem to match this, don't assume the code is wrong. + Talk to the developers! We'll revise this document, but don't hold your + breath right now. (This comment written August 5, 2021.) + Memory Allocation ----------------- @@ -399,3 +407,6 @@ out on the cyrus-devel list in June 2010. offset and a "within this mmap" offset though, and it also applies to using the same variable name for native order and network order numbers, which is where I've seen it a few times and been super frustrated!) + +* Write RFCs in comments capitalized with space after the RFC, like + ``RFC 1234``, not like ``rfc 1234`` or ``RFC1234``. diff --git a/docsrc/imap/developer/guidance/mailbox-format.rst b/docsrc/imap/developer/guidance/mailbox-format.rst index d23982e044..d1f3700a8d 100644 --- a/docsrc/imap/developer/guidance/mailbox-format.rst +++ b/docsrc/imap/developer/guidance/mailbox-format.rst @@ -1,9 +1,5 @@ .. _imap-developer-guidance-mailbox-format: -.. Note: This document was converted from the original by Nic Bernstein - (Onlight). Any formatting mistakes are my fault and not the - original author's. - Cyrus IMAP Server: Mailbox File Formats ======================================= diff --git a/docsrc/imap/developer/install-xapian.rst b/docsrc/imap/developer/install-xapian.rst index c850733ee3..bb82935dee 100644 --- a/docsrc/imap/developer/install-xapian.rst +++ b/docsrc/imap/developer/install-xapian.rst @@ -29,7 +29,7 @@ command line. export CYRUSLIBS="/usr/local/cyruslibs" export PKG_CONFIG_PATH="$CYRUSLIBS/lib/pkgconfig:$PKG_CONFIG_PATH" export LDFLAGS="-Wl,-rpath,$CYRUSLIBS/lib -Wl,-rpath,$CYRUSLIBS/lib/x86_64-linux-gnu" - export XAPIAN_CONFIG="$CYRUSLIBS/bin/xapian-config-1.5" + export PATH="$PATH:$CYRUSLIBS/bin" git clone git@github.com:cyrusimap/cyruslibs.git cd cyruslibs @@ -37,6 +37,14 @@ command line. Then follow on with the Cyrus :ref:`compilation instructions `, adding ``--enable-xapian`` to the flags to ``./configure``. +Additional dependencies required for Cyrus with Xapian support +============================================================== + +When building Cyrus with Xapian support enabled, the following additional +packages are required: + +* `rsync `_ (used when compacting databases) + .. _configuring-xapian: Configuring Xapian diff --git a/docsrc/imap/developer/jmap.rst b/docsrc/imap/developer/jmap.rst index 5c1f81bfef..3d34bdc0d7 100644 --- a/docsrc/imap/developer/jmap.rst +++ b/docsrc/imap/developer/jmap.rst @@ -14,7 +14,7 @@ Compile JMAP support into Cyrus 1. Enable JMAP (and DAV) in Cyrus: - * ``./configure --enable-http --enable-jmap`` along with your other configuration options. + * ``./configure --enable-http --enable-jmap --enable-xapian`` along with your other configuration options. 2. Enable :ref:`conversation support ` @@ -37,7 +37,7 @@ Test JMAP support Once Cyrus is running, you can test JMAP on the command line for any existing Cyrus user. The user must at least have an INBOX provisioned but is not required to have any calendars, contacts or messages. -To obtain the JMAP calendars for user ``test``, issue the following request: +To obtain the JMAP mailbox folders for user ``test``, issue the following request: .. code-block:: bash @@ -45,42 +45,75 @@ To obtain the JMAP calendars for user ``test``, issue the following request: -H "Content-Type: application/json" \ -H "Accept: application/json" \ --user test:test \ - -d '[["getCalendars", {}, "#1"]]' \ - http://localhost/jmap + -d '{ + "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], + "methodCalls": [[ "Mailbox/get", {}, "c1" ]] + }' \ + http://localhost/jmap/ you should get a response which looks similar to .. code-block:: none - [ - [ - "calendars", - { - "accountId": "test@localhost", - "list": [ - { - "color": "#FD8208FF", - "id": "Default", - "mayAddItems": true, - "mayDelete": true, - "mayModifyItems": true, - "mayReadFreeBusy": true, - "mayReadItems": true, - "mayRemoveItems": true, - "mayRename": true, - "name": "Default", - "sortOrder": 1, - "x-href": "/dav/calendars/user/test@localhost/Default" - } - ], - "notFound": null, - "state": "184" + { + "methodResponses": [ + ["Mailbox/get", { + "state": "0", + "list": [{ + "id": "7c76ec2b-9bd8-4091-a665-640e232e3877", + "name": "Inbox", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": false, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": false }, - "#1" - ] - ] - -Similar requests exist to obtain contacts and messages. For details, see the + "role": "inbox", + "totalEmails": 0, + "unreadEmails": 0, + "totalThreads": 0, + "unreadThreads": 0, + "sortOrder": 1, + "isSubscribed": false + }, { + "id": "5d9e4f44-7df9-4489-b8b3-32625b552aa1", + "name": "Trash", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": null, + "totalEmails": 0, + "unreadEmails": 0, + "totalThreads": 0, + "unreadThreads": 0, + "sortOrder": 10, + "isSubscribed": true + }], + "notFound": [], + "accountId": "test" + }, "c1"] + ], + "sessionState": "0" + } + +Similar requests exist to obtain contacts and calendars. For details, see the JMAP specification. Optional: Install sample JMAP client diff --git a/docsrc/imap/developer/libraries/imclient.rst b/docsrc/imap/developer/libraries/imclient.rst index 1d244b34df..c137e15800 100644 --- a/docsrc/imap/developer/libraries/imclient.rst +++ b/docsrc/imap/developer/libraries/imclient.rst @@ -8,7 +8,7 @@ **imclient** library ==================== -Authenticating callback interface to IMAP/IMSP servers +Authenticating callback interface to IMAP servers Synopsis ======== @@ -41,8 +41,8 @@ Synopsis Description =========== -The imclient library functions are distributed with Cyrus IMAP and IMSP. -These functions are used for building IMAP/IMSP client software. These +The imclient library functions are distributed with Cyrus IMAP. +These functions are used for building IMAP client software. These functions handle Kerberos authentication and can set callbacks based on the keyword in untagged replies or based on the command tag at the end of command replies. @@ -280,4 +280,4 @@ See Also Keywords ======== -IMAP, ACAP, IMSP, Kerberos, Authentication +IMAP, ACAP, Kerberos, Authentication diff --git a/docsrc/imap/developer/major-releasing.rst b/docsrc/imap/developer/major-releasing.rst new file mode 100644 index 0000000000..8d3f760d3e --- /dev/null +++ b/docsrc/imap/developer/major-releasing.rst @@ -0,0 +1,380 @@ +.. _imap-developer-major-releasing: + +===================================== +Releasing Cyrus IMAP - major releases +===================================== + +.. contents:: + +These instructions describe the process of turning what was the master +branch into a new release series, and making the first release from it. + +For normal point releases, see :ref:`imap-developer-releasing` + +Prerequisites +============= + +Same as for normal releases. + +Feature freeze +============== + +This is the period where new features are not being merged to the master +branch. It usually starts at the start of December, and continues until +the new series has its own cyrus-imapd-x.y branch, which is usually forked +in early January. + +Once the new series has its own branch, any bug fixes will need to be +developed against master and then backported to the new branch. In +contrast, bug fixes that happen during the feature freeze only need to land +once, so take advantage of this window to focus on those. + +The feature freeze also reduces races between the release manager doing the +various tasks of a release and other developers merging changes. + +Once the new series has its own branch, normal feature development can resume +on master -- developing features for the *next* major release. + +Make sure master is good +======================== + +With the master branch checked out and up to date: + +1. Ensure your git repository is clean, using something like + ``git clean -xfd``. Note that this command will destroy any uncommitted + work you might have, so make sure your ducks are in line before proceeding. +2. Generate a configure script: ``autoreconf -i -s`` +3. Generate everything else: ``./configure --enable-maintainer-mode`` (you do + not need any other options at this stage). +4. Run ``make distcheck``. This will generate a distribution tarball, and + test it in various ways. It takes about 10-15 mins to run, depending on + your hardware. If you usually build Cyrus with a script that sets PATH etc, + you will need to provide the same environment at this step. For example, + ellie uses an alias like this for this step: + + ``alias distcheck="PATH=/usr/local/cyruslibs/bin:$PATH make distcheck"``. + + If ``make distcheck`` fails, you are not ready to proceed -- fix the + problems, get them tested and committed, then restart this testing. +5. ``make distcheck`` can only test so much (it doesn't know about cunit or + cassandane), so you also need to check the tarball against those. + + i. The tarball will be called something like + ``cyrus-imapd-3.0.0-rc2-23-g0241b22.tar.gz`` + (this corresponds to the ``git describe`` output). + ii. Extract it: ``tar xfz cyrus-imapd-*.tar.gz`` + (substitute version for ``*``). + iii. Change into the directory: ``cd cyrus-imapd-*`` + iv. Configure it: ``./configure [...]`` (provide the same arguments and + environment that you would when building for Cassandane at any other + time). + v. Compile it: ``make -j4`` -- it should build correctly. + vi. Run the unit tests: ``make -j4 check`` -- they should pass. + vii. Install it to your Cassandane prefix: ``make install`` + viii. Change into the `cassandane` directory within the extracted source + (not the git source!): ``cd cassandane`` + ix. Build Cassandane's binary components: ``make -j4`` + x. Run Cassandane: ``./testrunner.pl`` + xi. If any of this fails, get it fixed and merged, then redo this testing + + +Forking the new series branch +============================= + +You will find (e.g. with ``git describe`` when viewing the master branch) that +the master branch has a version with an odd number in the second field, e.g. +3.\ **7**\ . The new series branch should be named one number higher than +this, making it an even number. Thus, if master is currently 3.7, then the new +series will be 3.8 (and then master will become 3.9). + +1. Make sure your repository and master branch are up to date +2. Checkout the master branch: ``git checkout master`` +3. Create and check out the new series branch: + ``git checkout -b cyrus-imapd-`` +4. Edit `docsrc/conf.py`. Update all the versioning information to say that: + + - this is version `.0-alpha0` + - the current stable version is `.0-alpha0` (i.e. this one) + - the previous stable version is whatever the current stable version + used to be + - the latest development version is the next odd number up from what it used + to be, as a `.0-alpha0` -- that is, if it used to be `3.7.something`, + it is now `3.9.0-alpha0`. + - (these are all lies right now, but they will become true as we go) + - find `html_theme_options` and update the option that configures which + branch to show for the build status badge to be this branch, not "master" + - Also add a suitable entry to the `extlinks` table near the bottom of the + file. + +5. Update `docsrc/index.rst` to state that this is the stable version, not + the development one. It's easiest to just copy and update the text from the + previous stable version of this file. +6. Add release notes infrastructure: + + a. Make the directories for the new series: + ``mkdir -p docsrc/imap/download/release-notes//x`` + (note the `x`, it's important for some historical reason) + b. Make the directories for the new dev version: + ``mkdir -p docsrc/imap/download/release-notes//x`` + c. Create `docsrc/imap/download/release-notes//index.rst` + for each of these, with stub contents. It's easiest to just + copy and update from an older one. + d. Add stub release notes for alpha0. This will be a file called + `docsrc/imap/download/release-notes//x/.0-alpha0.rst`. + If we've been doing dev snapshots from master, start by copying the + release notes from the most recent one of those. If we haven't, then + you will be starting with a blank document, in which case it's easiest + to copy the release notes file from the previous major release, delete + all the bullet points (leaving just the headings), and fix all the + numbers. + +7. Update `README.md`: + + - It will be claiming to be the development version, but this is now (or + will be) the stable version, so update that. If in doubt, mimic what + the old stable branch's copy says. This is another set of lies that will + become true as we go. + - Search through the whole document for version numbers, and update them + sensibly for the future reality. Do this mindfully, not with a batch + find-replace. + - The stable "build status" badge at the very top should reference the real + stable version for now. This gets shown on GitHub rather than our own + site, so it can't lie. + - This is also a good time for a careful review of the contents of this + file. Fix anything that's out of date, missing, etc. + +8. Make sure your RST changes are good: ``make doc-html``. Pay attention + to any errors or warnings (they will be coloured). There will be some + you can clearly ignore, such as glob patterns for future release notes + that don't exist yet, but do your best to deal with everything else. + The generated documentation will be under the `doc/html/` directory -- + examine it in your browser to make sure all your formatting and such makes + sense. +9. XXX maybe missing some stuff here still? + +You can double check your work by looking at what changed last time a new +stable series was forked: +``git show --format=fuller cyrus-imapd-.0-alpha0``. +Also look a few commits forward, in case the previous releaser +missed steps before tagging, and had to catch them up later. + +Once you're satisfied that you've done everything that needed doing here: + +1. Commit all these changes. A single commit is good, we would like this to + be the very first commit after the fork point. +2. Create a signed, annotated tag declaring that this is now alpha0 of the .0 + release of the new series: + ``git tag -s cyrus-imapd-.0-alpha0`` +3. You will be prompted to enter a commit message for the tag (this is + what makes it an "annotated" tag). Ellie uses something like "not a real + release, but need a tag for versioning". +4. You will also be prompted to enter the pass phrase for your GPG key, do it. +5. Push the new branch: ``git push ci cyrus-imapd-`` (assuming your + remote is named "ci") +6. Push the new tag: ``git push ci cyrus-imapd-.0-alpha0`` + +Fastmail specific: also push the new branch and tag to the Fastmail repo. + +Updating the master branch +========================== + +You now need to make similar, but not identical, changes to the master branch, +too. + +1. Check out the master branch: ``git checkout master`` +2. Edit `docsrc/conf.py`: Make all the same changes as you did before, except + that: + + - version and release should reflect that this is the development version, + not the new stable version + - XXX anything else? + +3. Create the release notes directories and populate their stub index files. + Note that in this case you're doing both the new series stubs, and the new + dev series stubs. You need to do both, because someday this will be a + stable version, and the website will need all the historical release notes. +4. Remove all files except the template from `changes/next/`. These will be + new features in the new release, which means they're no longer new on the + master branch. An exception is if there are changes currently on master + that will be reverted from the new branch after forking -- in that case, + don't delete those changes files from master. More on this later. +5. Update `README.md`. +6. XXX probably steps missing here too +7. Make sure the RST changes are good: ``make doc-html``, pay attention + to errors and warnings. + +You may think you can do this by cherry-picking your commit from the new +release branch and then amending it with the dev version differences... and you +can, but do so very cautiously, because the differences between these branches +are important. + +You can double check your work by looking at what changed *on master* last time +a new series forked. As before, look a few commits ahead too, in case the +previous releaser missed steps before tagging. + +Once you're satisfied that you've done everything that needed doing here: + +1. Commit all these changes. A single commit is good, we would like this to + be the very first commit after the fork point. +2. Create a signed, annotated tag declaring that this is now alpha0 of the .0 + "release" of the new development version: + ``git tag -s cyrus-imapd-.0-alpha0`` +3. You will be prompted to enter a commit message for the tag (this is + what makes it an "annotated" tag). Ellie uses something like "not a real + release, but need a tag for versioning". +4. You will also be prompted to enter the pass phrase for your GPG key, do it. +5. Push the new commit +6. Push the new tag: ``git push ci cyrus-imapd-.0-alpha0`` + +**Once this step is done, the feature freeze can end.** + +Fastmail specific: also push the updated master branch and new tag to the +Fastmail repo. This ensures our builds will also start using the new version +number once they update past the fork point. + +Github updates +============== + +On Github, have a look at the branch protection settings that apply to the +current stable branch. Apply the same protections to the new branch. + +Create labels for the new series and new dev series. Give them pleasant +colours and sensible descriptions. + +- +- backport-to- +- + +Also update the description of the label for the old master version number. + +Revert anything that's not yet ready +==================================== + +If there are commits on master that need to remain on master, but are not +yet ready for release for some reason, this is a good point to revert those +commits on the new branch only. Any `changes/next` files from these commits +should remain on master, or be copied back to master if they were accidentally +deleted earlier. + +This doesn't and shouldn't happen often. + +Tell the website builder about the new branch +============================================= + +The website is automatically rebuilt by a script, which needs to be updated +to know about the new series. + +1. Clone ``git@github.com:cyrusimap/cyrusimap.org.git``, or ensure the clone + you already have is up to date +2. Update `run-gp.sh` to know about the new version. You'll need to add code + in several places, but it's pretty self-explanatory once you look at it. + For the time being, do NOT change which version `$target` and + `$target/stable` are rsync'd from. We'll change these later, once the real + release has been published. In the meantime, we want the top level and + stable sections to continue to be built from the existing stable branch. +3. You can check your work by comparing your changes to previous commits +4. Commit and push your changes. The system that runs this script fetches + changes automatically before running it, so the next run to start will + use the updated version. It starts approximately on the hour, and can + take ~15 minutes if there are large changes, such as adding a whole new + branch... +5. You should now be able to access a version of the website built from the + new branch at `https://www.cyrusimap.org//`. Check that in your + browser, make sure it reports the correct new versions. +6. You should also see a new "automatic commit" from "cyrusdocgen" on + https://github.com/cyrusimap/cyrusimap.github.io -- that's the result of + the run-gp.sh script having run. + +First beta +========== + +This work mostly happens on the new branch. + +1. Check through `lib/imapoptions` for options with `"UNRELEASED"` in any of + their version fields. + + - Replace these with the version number of the eventual actual (non-beta) + release. For example, if we're starting the 3.8 series, this will be + "3.8.0". That is to say, the first real release that these changes will + appear in. + - If any have been missed, there will be warnings (in yellow) when trying + to (re)generate `lib/imapopts.c`. You can run + ``touch lib/imapoptions && make lib/imapopts.c`` to check + - Commit this change, and also cherry-pick it onto `master`. + +2. Copy the stub release notes that you made for `.0-alpha0` into a new + document for `.0-beta1`. +3. Review the contents of all the `changes/next/*` files. Flesh out the new + release notes document accordingly. (Compare previous `...-beta1` release + notes to get a sense of the tone and level of detail.) +4. Review `docsrc/imap/download/upgrade.rst`, also with reference to the + `changes/next/*` files. Make any necessary updates. We expect people + upgrading to the new version to follow these instructions, so they'd better + be as complete and correct as we can get them. +5. Review `docsrc/imap/rfc-support.rst`, also with reference to the + `changes/next/*` files, and make any necessary updates. Also compare this + file with the version of it on the stable branch. Check for any changes + that don't have an accompanying `changes/next` file, and if there are any, + also add suitable release notes and/or upgrade documentation for those. +6. Check your RST changes: ``make doc-html`` +7. Once the documentation updates have been finalised, the `changes/next/*` + files (except for the template) should be removed -- they are no longer + changes. The history is a bit easier to read later if you commit the + doc updates and the removal of the changes files in the same commit. +8. Follow the :ref:`imap-developer-releasing` instructions to get + `cyrus-imapd-.0-beta1` released. + +Subsequent betas +================ + +Monitor Github and the mailing lists for bug reports against the previous beta. +Make fixes against master, then backport them to the new series branch. + +Periodically make new beta releases, as bugs are found and fixed. + +Remember that until the real release is really released, the release notes +contain the changes since the previous *stable* version. This means each of +the betas will start with copying the previous beta's release notes and +adding any new details, without removing what was already there. + +Release candidates +================== + +After a while, the flow of bug reports and fixes will dry up, and so we start +cutting "release candidates" instead. These are effectively identical to +betas, except we call them -rc1, -rc2, etc instead. The change in name +reflects our increased confidence in the software and documentation. + +Release +======= + +Oh boy, we've come a long way, haven't we! + +For this one, we've got a little more housekeeping to do. + +1. Follow the :ref:`imap-developer-releasing` normal release process as + previous, again copy-and-updating the release notes from the last release + candidate, except this time you're actually doing `.0`, with no + alpha, beta, or rc qualifiers. Don't send the announcement email just + yet though. +2. Remember how we lied about the new version being the stable release? + We only did that on the new branch and master, though. `docsrc/conf.py` + on each of the existing branches will still be announcing old version + numbers in the "rst_prolog" section. Go through the old branches and + update each's `docsrc/conf.py` to contain the same lie. Commit and push + these as you go. +3. Remember the `run-gp.sh` script from the cyrusimap.org repository? Go and + move the ``rsync ... $target`` and ``rsync ... $target/stable`` lines from + the block for what is now the previous stable release, into the block for + the new version (don't forget to update the numbers embedded in these lines + too). Once this is pushed, the next website rebuild will make it all true. +4. Once the website is fully updated, send that announcement email. + +Post-release +============ + +From now on, just follow the normal release process to make point releases. +Release notes for point releases describe the difference between this point +release and the previous, and are much more specific than those of major +releases. + diff --git a/docsrc/imap/developer/process.rst b/docsrc/imap/developer/process.rst index ba4da4fc41..d10405aae7 100644 --- a/docsrc/imap/developer/process.rst +++ b/docsrc/imap/developer/process.rst @@ -9,7 +9,7 @@ We need to develop not just for ourselves, but for those who follow us. This mea Coding Style ============ -* Unix style: CRLF line endings. +* Unix style: LF line endings. * No trailing spaces. * No tabs: use 4 spaces instead. * Bracketing style: use the style that's already there. @@ -62,6 +62,6 @@ Community Participation Join us! The project is only as good as the sum of its people. We all work together, despite the tyranny of distance and timezones. -Meetings are currently :ref:`held online ` via Google Hangouts. Not sure what time that is for you? Try the `meeting planner `_. +Meetings are currently :ref:`held online via Zoom `. -There's also :ref:`IRC and mailing lists `. +There's also :ref:`mailing lists `. diff --git a/docsrc/imap/developer/releasing.rst b/docsrc/imap/developer/releasing.rst index 8cd6a44f30..a520e10367 100644 --- a/docsrc/imap/developer/releasing.rst +++ b/docsrc/imap/developer/releasing.rst @@ -1,17 +1,23 @@ .. _imap-developer-releasing: -==================== -Releasing Cyrus IMAP -==================== +====================================== +Releasing Cyrus IMAP - normal releases +====================================== .. contents:: -These instructions are specifically for doing releases from branches that -contain RST-based documentation and infrastructure. This includes 2.5 and -later versions. +These instructions are specifically for doing point releases (x.y.z) from +branches that contain RST-based documentation and infrastructure. This +includes 2.5 and later versions. + +For snapshot releases from the master branch, see +:ref:`imap-developer-snapshot-releasing` For new releases from ancient branches, see :ref:`imap-developer-ancient-releasing` +For major (x.y) releases, you will follow this process for publishing the +release tarballs, but there are additional steps before and around that, +detailed at :ref:`imap-developer-major-releasing`. Prerequisites ============= @@ -25,14 +31,27 @@ manual like she did. :) Once you have a GPG key, it's helpful to upload your public key to `the MIT key-server `_ -You also need a shell account on www.cyrusimap.org and ftp.cyrusimap.org, -with SSH key authentication. You need to be in the "admin" group on each, -and also the "cyrupload" group on the latter. - And you need permission to send to the cyrus-announce mailing list. .. endblob releaseprereqs +Order of operations +=================== + +Sometimes you're releasing several new versions all at once(ish), for example +maybe there's been a security fix that affected 2.4, 2.5 and 3.0. + +Github's release page will put a "Latest Release" graphic on the release with +the newest tag (by timestamp, I think). This means that, if you're doing new +releases for several different versions, you want to do the oldest one first, +and only do the release for the current-stable branch last. + +If you start at the current stable branch and then work your way backwards +through the older ones, you'll get Github saying "2.5.15 is the Latest +Release" even though 3.0.13 was also just released... so, even though +releasing the current-stable fix feels more urgent, suck it up and get the +older-branch ones out first. + Release notes ============= @@ -59,7 +78,7 @@ Pre-release testing need any other options at this stage). 4. Run ``make distcheck``. This will generate a distribution tarball, and test it in various ways. It takes about 10-15 mins to run, depending on - your hardware. If this command fails, you are not ready to release -- + your hardware. If this command fails*, you are not ready to release -- fix the problems, get them tested and committed, then restart the pre-release testing. 5. ``make distcheck`` can only test so much (it doesn't know about cunit or @@ -78,6 +97,21 @@ Pre-release testing ix. If any of this fails, fix it, commit it, then restart the pre-release testing. +.. Note:: + ``make distcheck`` doesn't work on the 2.5 branch. For 2.5, just use + ``make dist`` instead. + +.. Note:: + Realistically, there's usually some set of expected Cassandane failures + from each Cyrus branch, especially for 2.5. If you're doing releases + regularly, you've probably got a good gut feel for which failures are a + problem and which ones are just "that old thing". If you don't do + releases regularly, try to pull in someone who does for guidance about + which failures are ignorable, and which should be a source of stress. + + If in doubt, try building and testing the previous release from the same + series, and compare the test results. + Linking up release notes ======================== @@ -98,9 +132,9 @@ website updating before the downloads are available. Version tagging =============== -Note: it is absolutely critical that your local commits have been pushed -upstream at this point. If they are not, and if anybody else pushes in the -meantime, you will end up with a mess. +Note: it is absolutely critical that your repository is clean and your local +commits have been pushed upstream at this point. If they are not, and if +anybody else pushes in the meantime, you will end up with a mess. 1. Ensure your repository is clean again: ``git clean -xfd`` 2. Create a signed, annotated tag for the new version: ``git tag -s cyrus-imapd-`` @@ -126,15 +160,59 @@ meantime, you will end up with a mess. 11. Push the tag upstream: ``git push ci cyrus-imapd-`` (assuming your remote is named "ci"). -Releasing + +Inter-version website consistency +================================= + +The website is built from an amalgamation of documentation from: + +* The current stable cyrus-imapd branch (top level) +* The current master cyrus-imapd branch (``/dev`` hierarchy) +* Each of the following cyrus-imapd branches (``/x.y`` hierarchies) + + - cyrus-imapd-2.5 + - cyrus-imapd-3.0 + - cyrus-imapd-3.2 + +* The current master cyrus-sasl branch (``/sasl`` hierarchy) + +When making a cyrus-imapd release, you need to add the new release notes +file to each relevant cyrus-imapd branch. You also need to check and +update the contents of ``docsrc/conf.py`` on each branch AND the cyrus-sasl +repository. + +Sometimes you can just cherry-pick the commits around, but note that the +2.5 website stores release notes files in a different path, so if you +bother to copy release notes back to this branch, a naive cherry-pick will +not put them in the right place! + +This step often gets forgotten, so if you actually follow it, and notice +some missing versions, just go ahead and add them while you're there. + +Uploading ========= -1. Upload the tarball and signature to www: ``scp cyrus-imapd-*.tar.gz cyrus-imapd-*.tar.gz.sig - www.cyrusimap.org:/var/www/html/releases/`` -2. Upload them to ftp too: ``scp cyrus-imapd-*.tar.gz cyrus-imapd-*.tar.gz.sig - ftp.cyrusimap.org:/srv/ftp/cyrus-imapd/`` -3. SSH into both www and ftp, and move older releases to the old versions - directory. You want only the two most recent tarball+sig pairs for each - major series. -4. Update the topic in the #cyrus IRC channel. -5. Send an announcement to the info-cyrus and cyrus-announce lists. +.. Note:: + This section does NOT apply to releases from the master branch. We + do not publish release tarballs for those. People running master code + are expected to use a git checkout. + +Time to upload the release tarball and signature file! + +1. Navigate to https://github.com/cyrusimap/cyrus-imapd/releases +2. The tag you pushed earlier will now be available as a release, but it will + have very little information about it +3. Click on the tag name +4. Click "Edit tag" on the right +5. *Leave every field on the page as it is (probably blank!), except*: +6. Use the "Attach binaries by dropping them here or selecting them" widget + to upload the tarball and signature files +7. If this is an alpha/beta/rc release, click the "This is a pre-release" + checkbox +8. Click "Save". The commit message from the tag annotation will be used + as the release description. + +Tell the world +============== + +1. Send an announcement to the info-cyrus and cyrus-announce lists. diff --git a/docsrc/imap/developer/snapshot-releasing.rst b/docsrc/imap/developer/snapshot-releasing.rst new file mode 100644 index 0000000000..62d5af66a6 --- /dev/null +++ b/docsrc/imap/developer/snapshot-releasing.rst @@ -0,0 +1,215 @@ +.. _imap-developer-snapshot-releasing: + +========================================== +Releasing Cyrus IMAP - developer snapshots +========================================== + +.. contents:: + +These instructions describe the process of producing "developer snapshots" +from the master branch. These are tag-only releases: no release tarball +is published. + +For normal point releases, see :ref:`imap-developer-releasing` + +We haven't been doing this much, or very consistently. Consider this +document and process a work in progress, which we'll refine as we go. + +You can look at the tag cyrus-imapd-3.3.1 and a few commits before it to +get a sense of the kind of things this process involves. + +Prerequisites +============= + +Same as for normal releases + +Make sure master is good +======================== + +With the master branch checked out and up to date: + +1. Ensure your git repository is clean, using something like + ``git clean -xfd``. Note that this command will destroy any uncommitted + work you might have, so make sure your ducks are in line before proceeding. +2. Generate a configure script: ``autoreconf -i -s`` +3. Generate everything else: ``./configure --enable-maintainer-mode`` (you do + not need any other options at this stage). +4. Run ``make distcheck``. This will generate a distribution tarball, and + test it in various ways. It takes about 10-15 mins to run, depending on + your hardware. If you usually build Cyrus with a script that sets PATH etc, + you will need to provide the same environment at this step. For example, + ellie uses an alias like this for this step: + + ``alias distcheck="PATH=/usr/local/cyruslibs/bin:$PATH make distcheck"``. + + If ``make distcheck`` fails, you are not ready to proceed -- fix the + problems, get them tested and committed, then restart this testing. +5. ``make distcheck`` can only test so much (it doesn't know about cunit or + cassandane), so you also need to check the tarball against those. + + i. The tarball will be called something like + ``cyrus-imapd-3.0.0-rc2-23-g0241b22.tar.gz`` + (this corresponds to the ``git describe`` output). + ii. Extract it: ``tar xfz cyrus-imapd-*.tar.gz`` + (substitute version for ``*``). + iii. Change into the directory: ``cd cyrus-imapd-*`` + iv. Configure it: ``./configure [...]`` (provide the same arguments and + environment that you would when building for Cassandane at any other + time). + v. Compile it: ``make -j4`` -- it should build correctly. + vi. Run the unit tests: ``make -j4 check`` -- they should pass. + vii. Install it to your Cassandane prefix: ``make install`` + viii. Change into the `cassandane` directory within the extracted source + (not the git source!): ``cd cassandane`` + ix. Build Cassandane's binary components: ``make -j4`` + x. Run Cassandane: ``./testrunner.pl`` + xi. If any of this fails, get it fixed and merged, then redo this testing + +Mixed-version Cassandane testing +================================ + +This is a good point to make sure that replication and murder setups can talk +to each other between versions. + +1. Add `[cyrus murder]` and `[cyrus replica]` sections to your cassandane.ini + and configure each with their own prefix, different from your + `[cyrus default]` one. Check `cassandane/cassandane.ini.example` in the + repository for examples with comments. Do the same for `[cyrus backup]` if + you like -- it's not documented like the others, but it works the same way. +2. For each prefix you've configured, check out the Cyrus version you want to + run there, do a from-scratch complete build with + ``./configure --prefix=/the/prefix ...`` and install it +3. Do the same for your usual `[cyrus default]` prefix +4. Check out the version whose Cassandane you want to run. Rebuild Cassandane's + binary components with ``make -C cassandane/utils`` +5. Run Cassandane -- the Replication, MurderIMAP, and MurderJMAP suites are + significant here, and the Backups suite if you configured `[cyrus backup]`. +6. Rinse and repeat for other combinations + +You should do this in four combinations: + +1. `[cyrus default]` built from master branch, others built from the current + stable branch, and running Cassandane from the master branch +2. Same as 1, but running Cassandane from the current stable branch +3. `[cyrus default]` built from the current stable branch, others built from + the master branch, and running Cassandane from the current stable branch +4. Same as 3, but running Cassandane from the master branch + +lib/imapoptions +=============== + +I'm not sure about whether we want to do this step at each snapshot, or +save it for a big batch at the next major release. The difference is +whether we conceptually treat these tags as releases of the feature or +not. + +If we do do this: + +Check through `lib/imapoptions` for options with `"UNRELEASED"` in any of +their version fields. + +1. Replace these with the version number that this snapshot will be tagged + as. +2. If any have been missed, there will be warnings (in yellow) when trying + to (re)generate `lib/imapopts.c`. You can run + ``touch lib/imapoptions && make lib/imapopts.c`` to check + +Release notes +============= + +Snapshot release notes are like major x.y.0 release notes, in that they +contain a high-level overview of the new features/etc, but not a blow-by-blow +of every commit. They describe the changes since the *last stable series*, +which means the release notes for each subsequent snapshot start as a copy +of the previous, and reset only when a new stable series forks. The release +notes for the developer snapshots will form the starting point for the release +notes of the next major release. + +Release notes live under ``docsrc/imap/download/release-notes/``. + +1. Copy the release notes from the previous snapshot of this series into + a new file for this snapshot. If this is the first snapshot of the series, + then copy the `.0-alpha0` release notes instead. +2. Review the contents of all the `changes/next/*` files. Flesh out the new + release notes document accordingly. (Compare previous `...-beta*` and + `x.y.0` release notes to get a sense of the tone and level of detail.) +3. Review `docsrc/imap/download/upgrade.rst`, also with reference to the + `changes/next/*` files. Make any necessary updates. We expect people + upgrading to the new version to follow these instructions, so they'd better + be as complete and correct as we can get them. +4. Review `docsrc/imap/rfc-support.rst`, also with reference to the + `changes/next/*` files, and make any necessary updates. Also compare this + file with the version of it on the stable branch. Check for any changes + that don't have an accompanying `changes/next` file, and if there are any, + also add suitable release notes and/or upgrade documentation for those. + +Should the `changes/next` files be removed at this point? I'm not sure, we +have not done any snapshot releases since we started tracking changes like +that. The major releasing process assumes that this all happens as a big +bang before the x.y.0, but if we return to doing regular snapshots, we can +distribute that load over the year. If the `changes/next` files are removed +as they're integrated into a snapshot, that will be less confusing, but it +will be harder to do a holistic review later. Maybe they can be moved +aside somewhere instead of removed, to `changes/` or +something... + +docsrc/conf.py +============== + +1. Update all the relevant version strings in `docsrc/conf.py` + +check documentation +=================== + +1. Make sure your RST changes are good: ``make doc-html``. Pay attention + to any errors or warnings (they will be coloured). There will be some + you can clearly ignore, such as glob patterns for future release notes + that don't exist yet, but do your best to deal with everything else. + The generated documentation will be under the `doc/html/` directory -- + examine it in your browser to make sure all your formatting and such makes + sense. + +PR and/or commit +================ + +Once you're satisfied that you've done everything that needed doing here, +commit the changes to a branch and submit a PR. Historically we've usually +just made these changes directly on master, but since our workflow uses +PRs these days, let's try that. + +Once the PR has been approved, rebase your branch on top of current master, +force-push it, and then "Merge" it through the GitHub UI. + +Tag +=== + +You'll want to apply the tag to the merge commit where the PR landed. Usually +this will be the head of master, but if there's been hang time between merging +the PR and starting this step, other merges might have snuck in on top of it. +That's fine, just be careful about which commit you're tagging. + +1. Make sure your master branch is checked out, clean, and up to date +2. Create a signed, annotated tag declaring that this is now whatever version + it is: + ``git tag -s cyrus-imapd-`` +3. You will be prompted to enter a commit message for the tag (this is + what makes it an "annotated" tag). Ellie uses something like + "Developer release ". +4. You will also be prompted to enter the pass phrase for your GPG key, do it. +5. It's a good idea to do a full build-and-test of a release tarball at this + point, just to make sure things are sane. Throw the tarball away when + you're done though, we don't publish it. +6. Push the new tag: ``git push ci cyrus-imapd-`` + +Fastmail specific: also push the new tag to the Fastmail repo. + +Tell the world +============== + +1. Send an announcement to the cyrus-devel list. + +Update this document +==================== + +The process probably changed a little in practice. Update this document to +match reality! diff --git a/docsrc/imap/developer/thoughts/improved_mboxlist_sort.rst b/docsrc/imap/developer/thoughts/improved_mboxlist_sort.rst index 10bdb1e8cb..7893c78eec 100644 --- a/docsrc/imap/developer/thoughts/improved_mboxlist_sort.rst +++ b/docsrc/imap/developer/thoughts/improved_mboxlist_sort.rst @@ -1,21 +1,23 @@ -.. _imap-developer-thoughts-improved_mboxlist_sort: - .. Note: This document was converted from the original by Nic Bernstein (Onlight). Any formatting mistakes are my fault and not the original author's. Converted via the pandoc tool from HTML. +.. _enabling improved mboxlist sort: + Enabling improved_mboxlist_sort -================================= +=============================== You can't enable and disable ``improved_mboxlist_sort`` on a live system. You need to dump and load the necessary database after stopping -and before starting the master process. +and before starting the master process. Rename the original mailboxes.db +out of the way between dumping the old and loading the new. Dumping the mailboxes.db file :: ctl_mboxlist -d > /var/tmp/mailboxes.txt + mv mailboxes.db mailboxes.db.orig ctl_mboxlist -u < /var/tmp/mailboxes.txt If your subscription databases are not in flat files you need to do diff --git a/docsrc/imap/developer/unit-tests.rst b/docsrc/imap/developer/unit-tests.rst index 5a49fe5a82..0cda895207 100644 --- a/docsrc/imap/developer/unit-tests.rst +++ b/docsrc/imap/developer/unit-tests.rst @@ -425,7 +425,7 @@ You need to add the filename of your new test to the definition of the cunit/binhex.testc \ cunit/bitvector.testc \ cunit/buf.testc \ - cunit/byteorder64.testc \ + cunit/byteorder.testc \ cunit/charset.testc \ cunit/crc32.testc \ cunit/dlist.testc \ @@ -595,7 +595,7 @@ global state that the functions under test rely on, in such a way that their state is predictable and always the same no matter who runs the test or when or how many times. Similarly the suite teardown function should clean up any state which might possibly interfere with other test -suites. Note that some suites will need an setup function but not +suites. Note that some suites will need a setup function but not necessarily a teardown function. Adding these functions is very easy: you just write functions of the diff --git a/docsrc/imap/download/getcyrus.rst b/docsrc/imap/download/getcyrus.rst index 7fd0fbc888..75110aa51f 100644 --- a/docsrc/imap/download/getcyrus.rst +++ b/docsrc/imap/download/getcyrus.rst @@ -29,7 +29,7 @@ Use a release packaged tarball The Cyrus team produce packaged tarballs containing full source and pre-built documentation. -Download a versioned tarball using `FTP`_ or `HTTPS`_. Latest stable +Download a versioned tarball using `HTTPS`_. Latest stable version is |imap_current_stable_version|. Extract the tarball: @@ -38,9 +38,7 @@ Extract the tarball: $ :command:`tar xzvf cyrus-imapd-x.y.z.tar.gz` -.. _FTP: ftp://ftp.cyrusimap.org/cyrus-imapd/ - -.. _HTTPS: https://www.cyrusimap.org/releases/ +.. _HTTPS: https://github.com/cyrusimap/cyrus-imapd/releases Use the source from Git ----------------------- diff --git a/docsrc/imap/download/installation/distributions/ubuntu.rst b/docsrc/imap/download/installation/distributions/ubuntu.rst index b4cc38424b..ad461619f3 100644 --- a/docsrc/imap/download/installation/distributions/ubuntu.rst +++ b/docsrc/imap/download/installation/distributions/ubuntu.rst @@ -36,7 +36,6 @@ system, issue the following command: * db-util * db-upgrade-util - * libdb5.X (where X depends on OS version) * libsasl2-2 * libsasl2-modules * libcomerr2 diff --git a/docsrc/imap/download/installation/http/caldav.rst b/docsrc/imap/download/installation/http/caldav.rst index 72871ef5f0..453877c99f 100644 --- a/docsrc/imap/download/installation/http/caldav.rst +++ b/docsrc/imap/download/installation/http/caldav.rst @@ -178,7 +178,8 @@ The tables below show how the access controls are used by the CalDAV module. CYRUS:admin DAV:read-acl
      DAV:write-acl -
      DAV:unlock +
      DAV:share +
      DAV:unlock ACL
      PROPFIND (DAV:acl property ONLY)
      UNLOCK (ANY lock) @@ -301,8 +302,12 @@ user's home calendar collection. (e.g. a mailbox named ``user.murch.#calendars``). To enable unauthenticated users (non-Cyrus) to access freebusy information, the freebusy ACL must be given to "anyone". -Freebusy information is accessed via URLs of the following form: -``https:///freebusy/user/`` +Freebusy information, consolidating the data of all user's calendars, is +accessed via URLs of the following form: +``https:///freebusy/user/``. Querying individual CalDAV +collections, when they have explicitly "freebusy" ACL (9) set, is done via +``https:///freebusy/user//``. + Query parameters can be added to the URL per Section 4 of `Freebusy Read URL `_, @@ -315,7 +320,7 @@ Time Zone Distribution Service (TZDist) What is TZDist -------------- -The Time Zone module allows Cyrus to function as a Time Zone Distribution +The TZDist module allows Cyrus to function as a Time Zone Distribution Service (:rfc:`7808` and :rfc:`7809`), providing time zone data for CalDAV and calendaring clients, without having to wait for their client vendor and/or OS vendor to update the timezone information. The responsibility for keeping @@ -324,9 +329,8 @@ the time zone information up to date then falls upon the Cyrus administrator. TZDist is optional: without Cyrus having TZDist enabled, calendar clients should still be able to get their timezone information from their client or their OS. -TZDist is also required if you want the CalDAV server to strip known VTIMEZONEs -from incoming iCalendar data (as advertised by the ``calendar-no-timezone`` DAV -option from :rfc:`7809`). +TZDist strips known VTIMEZONEs from incoming iCalendar data (as +advertised by the ``calendar-no-timezone`` DAV option from :rfc:`7809`). Configuration ------------- @@ -345,25 +349,37 @@ Configuration :start-after: startblob zoneinfo_db :end-before: endblob zoneinfo_db -This module stores time zone data in the ``zoneinfo/`` subdirectory of the Cyrus -configuration directory (as specified by the ``configdirectory`` option). The data is -indexed by a database whose location is specified by the ``zoneinfo_db_path`` -option, using the format specified by the ``zoneinfo_db`` option. + | + + .. include:: /imap/reference/manpages/configs/imapd.conf.rst + :start-after: startblob zoneinfo_dir + :end-before: endblob zoneinfo_dir + +The TZDist module requires the ``zoneinfo_dir`` setting in :cyrusman:`imapd.conf(5)` +to be set to the directory where your time zone data is stored. + +The data is indexed by a database whose location is specified by the +``zoneinfo_db_path`` option, using the format specified by the ``zoneinfo_db`` +option. Administration -------------- -This module is designed to use the IANA Time Zone Database data (a.k.a. Olson -Database) converted to the iCalendar format. +The TZDist module is designed to use the IANA Time Zone Database data (a.k.a. +Olson Database) converted to the iCalendar format. -Cyrus uses a modified `vzic `_ to convert IANA -formatted data into iCalendar format. There is more information on Cyrus vzic in -``tools/vzic/README``. +`vzic `_ does convert the IANA TZ DB to iCalendar +format. For each time zone it creates a separate file with its own TZID property. +The TZID property can have a vendor prefix, that is fixed when compiling vzic by the +``TZID_PREFIX`` Makefile variable, which defaults to `/citadel.org/%D_1/`. Cyrus +IMAP requires that the vendor prefix is the empty string. -The steps to populate the Cyrus ``zoneinfo/`` directory are: +The `cyrus-timezones package `_ provides +a vzic, which sets TZID_PREFIX to the emtpy string. -1. Build the local "vzic" utility located in the ``tools/vzic/`` subdirectory - of the Cyrus source code. Run make in the tools/vzic/ subdirectory to build. +The steps to populate the ``zoneinfo_dir`` directory are: + +1. Acquire and build your choice of ``vzic`` tool. 2. Download the latest version of the `Time Zone Database data from IANA `_. Note @@ -371,14 +387,17 @@ The steps to populate the Cyrus ``zoneinfo/`` directory are: 3. Expand the downloaded time zone data into a temporary directory of your choice. -4. Populate ``/zoneinfo/`` with iCalendar data: +4. Copy leap-seconds.list from the temporary directory to ````. + +5. Populate ``zoneinfo_dir`` with iCalendar data: *Initial Install Only* a. Convert the raw data into iCalendar format by running vzic as follows: - ``vzic --pure --olson-dir --output-dir /zoneinfo`` + ``vzic --pure --olson-dir --output-dir `` - This will create and install iCalendar data directly into the ``/zoneinfo/`` directory. + This will create and install iCalendar data directly into the + ```` directory. *Updating Data Only* @@ -386,20 +405,19 @@ The steps to populate the Cyrus ``zoneinfo/`` directory are: ``vzic --pure --olson-dir `` This will create a zoneinfo/ subdirectory in your current location - (which should be `tools/vzic/`). - c. Merge new/updated iCalendar data into the ``/zoneinfo/`` directory + c. Merge new/updated iCalendar data into the ```` directory by running vzic-merge.pl in your current location: ``vzic-merge.pl`` -5. Rebuild the Cyrus zoneinfo index by running :cyrusman:`ctl_zoneinfo(8)` as +6. Rebuild the Cyrus zoneinfo index by running :cyrusman:`ctl_zoneinfo(8)` as follows: ``ctl_zoneinfo -r `` - where describes the recently downloaded time zone data - (e.g. "IANA Time Zone Database v.2013h"). + where contains description of the recently downloaded time + zone data, colon, and the version of the data (e.g. "IANA Time Zone Database:2020a"). -6. Check that the zoneinfo index database and all iCalendar data files/links +7. Check that the zoneinfo index database and all iCalendar data files/links are readable by the cyrus user. iSchedule diff --git a/docsrc/imap/download/installation/http/jmap.rst b/docsrc/imap/download/installation/http/jmap.rst index 9c2782bd21..9cbb4a8446 100644 --- a/docsrc/imap/download/installation/http/jmap.rst +++ b/docsrc/imap/download/installation/http/jmap.rst @@ -62,29 +62,24 @@ JMAP implementation status The JMAP implementation in Cyrus is at various stages of maturity. -Working -------- - -* **Contacts** - * All JMAP methods are implemented. JMAP blobs are not supported. - -* **Calendars** - * All JMAP methods are implemented. JMAP blobs are not supported. - -* **Messages** - * Most JMAP methods are implemented. The following methods are not planned for implementation: - - * copyMessages - * reportMessages - * getVacationResponse - * setVacationResponse - * getIdentityUpdates - * setIdentities - -Not yet implemented -------------------- - -* **Remote mailboxes** - -* **Events** - * Changes on mailbox entries trigger notifications. However, the JMAP event service is not implemented. +Implemented +----------- + +* The core protocol (:rfc:`8620`), except for PushSubscription +* JMAP Mail (:rfc:`8621`) +* A JMAP Subprotocol for WebSocket (:rfc:`8887`) + +In development +-------------- + +* JMAP Calendars (:draft:`draft-ietf-jmap-calendars`) +* JMAP Sharing (:draft:`draft-ietf-jmap-sharing`) +* JMAP Blobs (:rfc:`9404`) +* JMAP Sieve (:draft:`draft-ietf-jmap-sieve`) +* JMAP Contacts (:draft:`draft-ietf-jmap-jscontact`) +* JMAP MDN (:rfc:`9007`) + +Not implemented +--------------- +* JMAP Tasks (:draft:`draft-ietf-jmap-tasks`) +* JMAP SMIME (:rfc:`9219`) diff --git a/docsrc/imap/download/installation/http/rss.rst b/docsrc/imap/download/installation/http/rss.rst index 0bc5a45715..8c4fb93a28 100644 --- a/docsrc/imap/download/installation/http/rss.rst +++ b/docsrc/imap/download/installation/http/rss.rst @@ -32,8 +32,7 @@ List of mailboxes: rss_feeds The list of available RSS feeds can be obtained by clients by accessing the ``/rss/`` URL on the Cyrus server. -The rss_feeds option uses the -`wildmat `_ format to specify +The rss_feeds option uses the "wildmat" (:rfc:`3977#section-4`) format to specify which mailboxes/folders will be made available via RSS. This list is further limited to only those mailboxes and folders that the authenticated user has permissions to see. diff --git a/docsrc/imap/download/installation/manage-dav.rst b/docsrc/imap/download/installation/manage-dav.rst index a53a55f4ff..960aa5a501 100644 --- a/docsrc/imap/download/installation/manage-dav.rst +++ b/docsrc/imap/download/installation/manage-dav.rst @@ -35,7 +35,7 @@ Its feature set is limited to: * Allows synchronization of mail clients via the JSON Mail Access Protocol (JMAP). * Other (RSS, static content) * Serves static content (such as the RSS feed list template and the - CalDAV/CardDAV web GUIs ). + CalDAV/CardDAV web GUIs). * Serves IMAP mailboxes as RSS feeds. HTTPD Configuration @@ -67,11 +67,7 @@ Authentication -------------- As with other Cyrus services, the Cyrus httpd service uses -:ref:`Cyrus SASL ` to perform -its authentication. Cyrus supports the following HTTP authentication schemes: -Basic, Digest, Negotiate (Kerberos only), and NTLM. While Basic and NTLM are available in -all versions of SASL, the remaining schemes are only available in Cyrus SASL -2.1.16 (and higher). +:ref:`Cyrus SASL ` to perform its authentication. .. sidebar:: allowplaintext @@ -84,19 +80,20 @@ all versions of SASL, the remaining schemes are only available in Cyrus SASL Similar to plaintext login commands supported by the other Cyrus services (IMAP LOGIN, POP3 USER/PASS), the Cyrus httpd service determines whether to advertise the HTTP Basic authentication scheme based on the ``allowplaintext`` option and -whether the client has connected over a TLS protected connection (HTTPS). +whether the client has connected over a TLS protected connection (HTTPS). BASIC +authentication does not depend on a Cyrus SASL plugin. -The availability of the other HTTP authentication schemes is controlled by the +The advertisement of the other HTTP authentication schemes is controlled by the :ref:`SASL mech_list option ` option. For Cyrus httpd -the DIGEST-MD5, GSS-SPNEGO, and NTLM SASL -plugins support the Digest, Negotiate, and NTLM authentication schemes -respectively, provided that these plugins are installed on the server. +the GSS-SPNEGO, SCRAM-SHA-1, and SCRAM-SHA-256 values enable +support for the Negotiate (Kerberos only), SCRAM-SHA-1, and +SCRAM-SHA-256 authentication schemes respectively, provided that the plugins +are installed on the server. Module-specific information =========================== .. toctree:: - :glob: :maxdepth: 2 http/caldav @@ -116,7 +113,8 @@ needs to be customized to your specific hostnames. * Many clients find calendars automatically if you provide the correct server, username and password. * Otherwise, use the direct URL: ``https:///dav/calendars/user///`` * Freebusy - * ``https:///freebusy/user/`` + * ``https:///freebusy/user/`` - considers all CalDAV collections of the user + * ``https:///freebusy/user//`` - considers a single CalDAV collection * Query parameters can be added to the URL per Section 4 of `Freebusy Read URL `_. * CardDAV diff --git a/docsrc/imap/download/packagers.rst b/docsrc/imap/download/packagers.rst index 63293b6419..35a3b5b5df 100644 --- a/docsrc/imap/download/packagers.rst +++ b/docsrc/imap/download/packagers.rst @@ -1,9 +1,8 @@ -=================== Notes for Packagers =================== Binary naming -============= +------------- Prevent namespace clashes. We suggest renaming all binaries with ``cyr_`` at the front, including renaming the ``ctl_*`` to ``cyr_``. @@ -12,7 +11,7 @@ The Cyrus team are looking to fix this in the core bundle in upcoming releases so packagers have less to do. Sample configuration files -========================== +-------------------------- There are several samples of :cyrusman:`cyrus.conf(5)` and :cyrusman:`imapd.conf(5)` located in the ``doc/examples`` directory of @@ -21,10 +20,10 @@ documentation directory (i.e. ``/usr/share/doc/cyrus-imapd``) as a reference for your users. Predefined configurations -========================= +------------------------- The configuration file for master: cyrus.conf ---------------------------------------------- +````````````````````````````````````````````` When installing a predefined :cyrusman:`cyrus.conf(5)` for your users, please pay attention to new features and how these may impact users. @@ -73,7 +72,7 @@ sections are: it should shut down and clean up after. The configuration file for the various programs: imapd.conf ------------------------------------------------------------ +``````````````````````````````````````````````````````````` The sample :cyrusman:`imapd.conf(5)` files must be adapted for use from site to site. Here, therefore, we'll attempt to point you towards some @@ -109,64 +108,30 @@ files. In this example, the filesystem ``/run`` is on tmpfs:: New default settings #################### -With the introduction of version 3.0, the defaults for some settings -have changed. Please consult :ref:`upgrade` for details. - -New features -############ - -There are several features either new to version 3.0, or newly improved. -Some of these may be features which previously were not considered ripe -for packaging, but merit new consideration. - -Please see the release notes :ref:`relnotes-3.0.0-changes` for more -details and other recent changes. - -* Conversations - - * Server-side threading with reduced protocol chatter for mobile - or other high-latency clients. - * Required for JMAP support. - * See the ``conversations`` options in :cyrusman:`imapd.conf(5)` - -* JMAP - - * JSON Mail Access Protocol - * Follow-on successor to IMAP ("J comes after I") with a special - focus on mobile and other clients with high-latency or - unreliable connectivity. - * Includes Calendaring, Contacts, Conversations, message delivery. - * See ``httpmodules`` in :cyrusman:`imapd.conf(5)` - -* Xapian - - * Higher quality full-text search support. - * Required for JMAP support. - * See the ``search_engine`` option in :cyrusman:`imapd.conf(5)` - and ``doc/README.xapian`` in the source distribution. - -* Archive partitions - - * Automatically migrate messages from posh, fast storage (think - SSD) to cheap, slow storage (spinning rust). - * Requires addition of an archive partition for each data - partition. - * See ``archive_*`` options in :cyrusman:`imapd.conf(5)` +A new stable series means the defaults for some settings may have changed. +Please consult :ref:`upgrade` for details. -* Backup +New or improved features +######################## - * Replication-based backup to dedicated instance with efficient, - compact scheme. - * See :ref:`Cyrus Backups ` +A new stable series means new features, and improvements to existing features. +Some of these may be features which previously were not considered ripe for +packaging, but merit new consideration. -Please consider enabling these features in the :cyrusman:`imapd.conf(5)` -you ship in your packages. +Please see :ref:`imap-release-notes-3.2` for details, and consider enabling +these features in the :cyrusman:`imapd.conf(5)` you ship in your packages. Services in ``/etc/services`` -============================= +----------------------------- -Listing named services through ``/etc/services`` aids in cross-system consistency and cross-platform interoperability. Furthermore, it enables administrators and users to refer to the service by name (for example in ``/etc/cyrus.conf``, 'listen=mupdate' can be specified instead of 'listen=3905'). +Listing named services through ``/etc/services`` aids in cross-system +consistency and cross-platform interoperability. Furthermore, it enables +administrators and users to refer to the service by name (for example in +``/etc/cyrus.conf``, 'listen=mupdate' can be specified instead of +'listen=3905'). -Some of the services Cyrus IMAP would like to see available through ``/etc/services`` have not been assigned an IANA port number, and few have configuration options. +Some of the services Cyrus IMAP would like to see available through +``/etc/services`` have not been assigned an IANA port number, and few have +configuration options. .. include:: /assets/services.rst diff --git a/docsrc/imap/download/release-notes/1/1.x.x.rst b/docsrc/imap/download/release-notes/1/1.x.x.rst index d7b8a670d4..4ccc12c107 100644 --- a/docsrc/imap/download/release-notes/1/1.x.x.rst +++ b/docsrc/imap/download/release-notes/1/1.x.x.rst @@ -177,7 +177,7 @@ Changes to the Cyrus IMAP Server Since Version 1.5 * Bug fix: auth_newstate now initializes its structures. * Bug fix: pop3d.c, a printf was changed to prot_printf. * Cyrus now sends X-NON-HIERARCHICAL-RENAME to alert clients that it is not handling RENAME in an IMAP4rev1 compliant manner. This will be fixed in a subsequent release. -* Bug fix: imclient_autenticate now does resolution on the hostname before authenticating to it. This caused problems when authenticating to an address that was a CNAME. +* Bug fix: imclient_authenticate now does resolution on the hostname before authenticating to it. This caused problems when authenticating to an address that was a CNAME. * Bug fix: LIST %.% (and other multiple hierarchy delimiter matches) works properly. Several other glob.c fixes are included as well. * Bug fix: a fetch of exclusively BODY[HEADER.FIELDS...] should now work properly. * Bug fix: reconstruct now considers a nonexistant INN news directory to be empty; this makes reconstruct fix the cyrus.* files in the imap news partition. @@ -201,7 +201,7 @@ Changes to the Cyrus IMAP Server Since Version 1.4 * Make "reconstruct -r" with no args reconstruct every mailbox. * The configure script now defaults to using unix_pwcheck instead of unix if the file /etc/shadow exists. * The location of the pwcheck socket directory now defaults to "/var/ptclient/". It is controlled by the "--with-statedir=DIR" option, which defaults to "/var". -* Bug fix: by using an certain address form, one could deliver to a user's mailbox bypassing the ACL's. +* Bug fix: by using a certain address form, one could deliver to a user's mailbox bypassing the ACL's. * Bug fix: un-fold header lines when parsing for the ENVELOPE. * Delete quota roots when deleting the last mailbox that uses them. Doesn't catch all cases, but should get over 99% of them. * Implement plaintextloginpause configuration option, imposes artificial delay on plaintext password logins. diff --git a/docsrc/imap/download/release-notes/2.0/2.0.x.rst b/docsrc/imap/download/release-notes/2.0/2.0.x.rst index bb817b9c4a..1fec26b7cd 100644 --- a/docsrc/imap/download/release-notes/2.0/2.0.x.rst +++ b/docsrc/imap/download/release-notes/2.0/2.0.x.rst @@ -6,7 +6,7 @@ Changes to the Cyrus IMAP Server since 2.0.16 * migrated to SASLv2 (Rob Siemborski) * altnamespace: it is now possible to display user mailboxes as siblings to the INBOX at the top-level (Ken Murchison) -* unixhierarchysep: it is now possible possible to use slash as the hierarchy seperator, instead of a period. (Ken Murchison, inspired by David Fuchs, dfuchs@uniserve.com) +* unixhierarchysep: it is now possible to use slash as the hierarchy separator, instead of a period. (Ken Murchison, inspired by David Fuchs, dfuchs@uniserve.com) * SSL/TLS session caching (Ken Murchison) * support for IMAP CHILDREN & LISTEXT extensions (Ken Murchison, work in progress) * check recipient quota & ACL at time of RCPT TO: in lmtpd (Ken Murchison) @@ -123,11 +123,11 @@ Changes to the Cyrus IMAP Server since 2.0.6 * off-by-one bug in seen_db fixed. * starting/committing/aborting transactions now logged more correctly in cyrsudb_db3 * master will now accept port numbers instead of just service names in cyrus.conf. also logs even more verbosely (see bug #115.) -* libwrap_init() is now inside the loop, since i don't quite understand the semantics of libwrap calls. +* libwrap_init() is now inside the loop, since I don't quite understand the semantics of libwrap calls. * setquota in cyradm now behaves more sanely (and gives correct usage message). * bugfixes to the managesieve client perl api. (still needs work.) * small fixes in timsieved. -* added a "make dist" target so i won't dread releases as much. +* added a "make dist" target so I won't dread releases as much. Changes to the Cyrus IMAP Server since 2.0.5 diff --git a/docsrc/imap/download/release-notes/2.2/2.2.x.rst b/docsrc/imap/download/release-notes/2.2/2.2.x.rst index 39ad762bb8..f9255be6a8 100644 --- a/docsrc/imap/download/release-notes/2.2/2.2.x.rst +++ b/docsrc/imap/download/release-notes/2.2/2.2.x.rst @@ -70,12 +70,12 @@ Changes to the Cyrus IMAP Server since 2.2.6 Changes to the Cyrus IMAP Server since 2.2.5 * Fix a bug in the proxy code where a backend connection might get closed twice -* Improved consistancy checking in chk_cyrus +* Improved consistency checking in chk_cyrus * Fix segfault in APPEND code * Fix a bug with an interaction between sieve and unixhierarchysep * Fix a file descriptor leak in the quotadb code * Fix a triggered assertation in service-thread services -* Add a number of internal consistancy checks to the skiplist code +* Add a number of internal consistency checks to the skiplist code * Allow mbpath to handle virtual domains * Fix various MANAGESIEVE client authentication issues * Other minor fixes @@ -143,7 +143,7 @@ Changes to the Cyrus IMAP Server since 2.2.0 Changes to the Cyrus IMAP Server since 2.1.x -* There have been extensive performance and consistancy changes to the configuration subsystem. This will both ensure greater consistancy between the documentation and the code, as well as a more standard format for specifing service-specific configuration options in imapd.conf. Important changes are detailed here: +* There have been extensive performance and consistency changes to the configuration subsystem. This will both ensure greater consistency between the documentation and the code, as well as a more standard format for specifing service-specific configuration options in imapd.conf. Important changes are detailed here: * The tls_[service]_* configuration options have been removed. Now use [servicename]_tls_*, where servicename is the service identifier from cyrus.conf for that particular process. * Administrative groups (e.g. admins and lmtp_admins) no longer union, service groups completely override the generic group. * lmtp_allowplaintext is no longer a defined parameter and must be specified using the service name of your lmtp process if you require a specific value diff --git a/docsrc/imap/download/release-notes/2.4-dav/x/2.4.17-caldav-beta8.rst b/docsrc/imap/download/release-notes/2.4-dav/x/2.4.17-caldav-beta8.rst index 1243610e3b..cb84e4f660 100644 --- a/docsrc/imap/download/release-notes/2.4-dav/x/2.4.17-caldav-beta8.rst +++ b/docsrc/imap/download/release-notes/2.4-dav/x/2.4.17-caldav-beta8.rst @@ -4,7 +4,7 @@ Cyrus IMAP 2.4.17-caldav-beta8 Release Notes Changes to the Cyrus IMAP Server since 2.4.17-caldav-beta7 -* Added Timzone Service module along with associated admin tools (ctl_zoneinfo, vzic). +* Added Timezone Service module along with associated admin tools (ctl_zoneinfo, vzic). * Added support for accepting/returning jCal (requires Jansson) and xCal data wherever iCalendar data is allowed. * Proxied responses (including chunked) are now piped to client rather than being buffered and forwarded. * Better handling of COPY/MOVE between backends (including LOCKs). diff --git a/docsrc/imap/download/release-notes/2.4/x/2.4.21.rst b/docsrc/imap/download/release-notes/2.4/x/2.4.21.rst new file mode 100644 index 0000000000..34ee90f4ed --- /dev/null +++ b/docsrc/imap/download/release-notes/2.4/x/2.4.21.rst @@ -0,0 +1,16 @@ +=============================== +Cyrus IMAP 2.4.21 Release Notes +=============================== + +* Fixed :issue:`885`: timsieved segfault (thanks Christian Henz) +* Fixed :issue:`2199`: better recovery from mupdate failure (thanks Michael + Menge) +* Fixed: sync_client now replicates annotations in user/mailbox mode (thanks + John Capo) +* Fixed: build failure with LibreSSL 2.7 (thanks Bernard Spil) +* Fixed: XFER now correctly distinguishes between 2.3.x releases +* Fixed :issue:`3123`: XFER now recognises 3.1, 3.2 and 3.3 backends +* Fixed: XFER now syslogs a warning when it doesn't recognise the backend + Cyrus version + +:ref:`imap-release-notes-2.4` diff --git a/docsrc/imap/download/release-notes/2.4/x/2.4.22.rst b/docsrc/imap/download/release-notes/2.4/x/2.4.22.rst new file mode 100644 index 0000000000..6819326f68 --- /dev/null +++ b/docsrc/imap/download/release-notes/2.4/x/2.4.22.rst @@ -0,0 +1,8 @@ +=============================== +Cyrus IMAP 2.4.22 Release Notes +=============================== +* Fixed :issue:`3312`: fixed use-after-free segfault in imapd and + mupdate-client (thanks Mario Haustein) +* Fixed: XFER now recognises 3.4 and 3.5 backends + +:ref:`imap-release-notes-2.4` diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.0.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.0.rst index f975cc2d6c..264f7cc5ac 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.0.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.0.rst @@ -15,15 +15,10 @@ Cyrus IMAP 2.5.0 Release Notes * :ref:`relnotes-2.5.0-development-phabricator` * :ref:`relnotes-2.5.0-development-documentation` -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.0.tar.gz - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.0.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.0.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.0.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.0/cyrus-imapd-2.5.0.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.0/cyrus-imapd-2.5.0.tar.gz.sig .. _imap-relnotes-2.5.0-upgrading: @@ -592,5 +587,3 @@ The GIT repository for the documentation is at .. [#] http://opera.brong.fastmail.fm.user.fm/talks/twoskip/twoskip-yapc12.pdf - -.. _RFC 5464: http://tools.ietf.org/html/rfc5464> diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.1.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.1.rst index 3560cbc4ab..b1d4b72fa5 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.1.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.1.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.1 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.1.tar.gz - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.1.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.1.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.1.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.1/cyrus-imapd-2.5.1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.1/cyrus-imapd-2.5.1.tar.gz.sig .. _relnotes-2.5.1-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.10.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.10.rst index 7120b096fc..5675c761bf 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.10.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.10.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.10 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.10.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.10.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.10.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.10.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.10/cyrus-imapd-2.5.10.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.10/cyrus-imapd-2.5.10.tar.gz.sig .. _relnotes-2.5.10-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.11.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.11.rst index 734be1ac9d..4652c0855d 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.11.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.11.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.11 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.11.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.11.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.11.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.11.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.11/cyrus-imapd-2.5.11.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.11/cyrus-imapd-2.5.11.tar.gz.sig .. _relnotes-2.5.11-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.13.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.13.rst new file mode 100644 index 0000000000..e5691c1e9b --- /dev/null +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.13.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 2.5.13 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the `stable 2.5 series `_. + + Refer to the Cyrus IMAP 2.5.0 Release Notes for important information + about the 2.5 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.13/cyrus-imapd-2.5.13.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.13/cyrus-imapd-2.5.13.tar.gz.sig + +.. _relnotes-2.5.13-changes: + +Changes Since 2.5.12 +==================== + +Release changes +--------------- + +We’re trialing using the Github Releases feature. If you have trouble +downloading this release, please report this to the mailing lists. Thanks! + +Security fixes +-------------- + +* Fixed CVE-2019-11356: buffer overrun in httpd + +Bug fixes +--------- + +* Fixed: ptloader, ptexpire and ptdump now honour the ``ptscache_db_path`` + setting diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.15.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.15.rst new file mode 100644 index 0000000000..2b6f83cc84 --- /dev/null +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.15.rst @@ -0,0 +1,59 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 2.5.15 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the `stable 2.5 series `_. + + Refer to the Cyrus IMAP 2.5.0 Release Notes for important information + about the 2.5 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.15/cyrus-imapd-2.5.15.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.15/cyrus-imapd-2.5.15.tar.gz.sig + +.. _relnotes-2.5.15-changes: + +Changes Since 2.5.14 +==================== + +Release changes +--------------- + +We’re trialing using the Github Releases feature. If you have trouble +downloading this release, please report this to the mailing lists. Thanks! + +Security fixes +-------------- + +* Fixed CVE-2019-19783_: When creating a missing mailbox as part of a sieve + 'fileinto' directive, lmtpd would create it as administrator, bypassing ACL + checks. + + lmtpd creates missing mailboxes as part of a sieve 'fileinto' + directive if: + + * (2.5+) the `anysievefolder` option is enabled (default: not), or + * (3.0+) the `sieve_extensions` option has the 'mailbox' extension enabled + (default: enabled) and the 'fileinto' directive contains the ":create" + argument + + Under these conditions, a user with the ability to upload a custom sieve + script to their account could use it to create any valid mailbox on the + server (with ACL inherited from the parent mailbox as usual). + + lmtpd no longer creates these mailboxes as administrator, so users may no + longer use a 'fileinto' directive to create a mailbox they couldn't create + otherwise. + +.. _CVE-2019-19783: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-19783 + +Bug fixes +--------- + +* Fixed :issue:`2913`: errors are now logged when `maxlogins_per_host`, + `maxlogins_per_user`, and `popminpoll` limits are reached (thanks Sergey) diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.16.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.16.rst new file mode 100644 index 0000000000..2b33b5bab0 --- /dev/null +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.16.rst @@ -0,0 +1,30 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 2.5.16 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the `2.5 series `_. + + Refer to the Cyrus IMAP 2.5.0 Release Notes for important information + about the 2.5 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.16/cyrus-imapd-2.5.16.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.16/cyrus-imapd-2.5.16.tar.gz.sig + +.. _relnotes-2.5.16-changes: + +Changes Since 2.5.15 +==================== + +Bug fixes +--------- + +* Fixed: XFER now correctly distinguishes between 2.3.x releases +* Fixed :issue:`3123`: XFER now recognises 3.1, 3.2 and 3.3 backends +* Fixed: XFER now syslogs a warning when it doesn't recognise the backend + Cyrus version diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.17.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.17.rst new file mode 100644 index 0000000000..3f88aeb986 --- /dev/null +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.17.rst @@ -0,0 +1,33 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 2.5.17 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the `2.5 series `_. + + Refer to the Cyrus IMAP 2.5.0 Release Notes for important information + about the 2.5 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.17/cyrus-imapd-2.5.17.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.17/cyrus-imapd-2.5.17.tar.gz.sig + +.. _relnotes-2.5.17-changes: + +Changes Since 2.5.16 +==================== + +Bug fixes +--------- + +* Fixed :issue:`3143`: tools/git-version.sh did not need bash specifically +* Fixed :issue:`3191`: saved session reuse crash when TLS enabled for backend + connections +* Fixed: XFER now recognises 3.4 and 3.5 backends +* Fixed :issue:`3320`: memory leak during backend auth state cleanup +* Fixed :issue:`3312`: fixed use-after-free segfault in mupdate-client (thanks + Mario Haustein) diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.2.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.2.rst index 1027b84d8b..7287bcb105 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.2.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.2.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.2 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.2.tar.gz - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.2.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.2.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.2.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.2/cyrus-imapd-2.5.2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.2/cyrus-imapd-2.5.2.tar.gz.sig .. _relnotes-2.5.2-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.3.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.3.rst index a929950c8c..2b3d3c13f3 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.3.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.3.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.3 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.3.tar.gz - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.3.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.3.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.3.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.3/cyrus-imapd-2.5.3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.3/cyrus-imapd-2.5.3.tar.gz.sig .. _relnotes-2.5.3-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.4.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.4.rst index 8536985e7a..771412afe6 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.4.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.4.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.4 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.4.tar.gz - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.4.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.4.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.4.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.4/cyrus-imapd-2.5.4.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.4/cyrus-imapd-2.5.4.tar.gz.sig .. _relnotes-2.5.4-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.5.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.5.rst index ac481bf8c8..d49037cd48 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.5.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.5.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.5 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.5.tar.gz - * http://www.cyrusimap.org/releases/old/cyrus-imapd-2.5.5.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.5.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/OLD-VERSIONS/cyrus-imapd-2.5.5.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.5/cyrus-imapd-2.5.5.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.5/cyrus-imapd-2.5.5.tar.gz.sig .. _relnotes-2.5.5-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.6.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.6.rst index 2f27018e65..b75290eee9 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.6.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.6.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.6 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.6.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.6.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.6.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.6.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.6/cyrus-imapd-2.5.6.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.6/cyrus-imapd-2.5.6.tar.gz.sig .. _relnotes-2.5.6-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.7.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.7.rst index 96047ff86b..d83d1a6fb6 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.7.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.7.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.7 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.7.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.7.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.7.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.7.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.7/cyrus-imapd-2.5.7.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.7/cyrus-imapd-2.5.7.tar.gz.sig .. _relnotes-2.5.7-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.8.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.8.rst index 7f78fc860f..d66929c1e9 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.8.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.8.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.8 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.8.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.8.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.8.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.8.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.8/cyrus-imapd-2.5.8.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.8/cyrus-imapd-2.5.8.tar.gz.sig .. _relnotes-2.5.8-changes: diff --git a/docsrc/imap/download/release-notes/2.5/x/2.5.9.rst b/docsrc/imap/download/release-notes/2.5/x/2.5.9.rst index c2e70b64b3..809087587c 100644 --- a/docsrc/imap/download/release-notes/2.5/x/2.5.9.rst +++ b/docsrc/imap/download/release-notes/2.5/x/2.5.9.rst @@ -11,15 +11,10 @@ Cyrus IMAP 2.5.9 Release Notes Refer to the Cyrus IMAP 2.5.0 Release Notes for important information about the 2.5 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.9.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-2.5.9.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.9.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-2.5.9.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.9/cyrus-imapd-2.5.9.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-2.5.9/cyrus-imapd-2.5.9.tar.gz.sig .. _relnotes-2.5.9-changes: diff --git a/docsrc/imap/download/release-notes/3.0/index.rst b/docsrc/imap/download/release-notes/3.0/index.rst index d7b0f5b12c..e222b81321 100644 --- a/docsrc/imap/download/release-notes/3.0/index.rst +++ b/docsrc/imap/download/release-notes/3.0/index.rst @@ -8,4 +8,7 @@ Cyrus IMAP 3.0 Releases :maxdepth: 1 :glob: - x/?.?.* + x/?.?.*-beta* + x/?.?.*-rc* + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta1.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta1.rst index 32277bc53e..eed43de1c1 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta1.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta1.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 beta1 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta1.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta1.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta1.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta1.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta1/cyrus-imapd-3.0.0-beta1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta1/cyrus-imapd-3.0.0-beta1.tar.gz.sig .. _relnotes-3.0.0-beta1-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta2.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta2.rst index a3b2d3a136..0d3eaf5b53 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta2.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta2.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 beta2 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta2.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta2.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta2.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta2.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta2/cyrus-imapd-3.0.0-beta2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta2/cyrus-imapd-3.0.0-beta2.tar.gz.sig .. _relnotes-3.0.0-beta2-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta3.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta3.rst index d6b05b886b..6c66633946 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta3.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta3.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 beta3 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta3.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta3.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta3.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta3.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta3/cyrus-imapd-3.0.0-beta3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta3/cyrus-imapd-3.0.0-beta3.tar.gz.sig .. _relnotes-3.0.0-beta3-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta4.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta4.rst index 8bb5d262cb..61cd1c205f 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta4.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta4.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 beta4 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta4.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta4.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta4.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta4.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta4/cyrus-imapd-3.0.0-beta4.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta4/cyrus-imapd-3.0.0-beta4.tar.gz.sig .. _relnotes-3.0.0-beta4-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta5.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta5.rst index f56dc4c0b9..a9800ee146 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta5.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta5.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 beta5 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta5.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta5.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta5.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta5.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta5/cyrus-imapd-3.0.0-beta5.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta5/cyrus-imapd-3.0.0-beta5.tar.gz.sig .. _relnotes-3.0.0-beta5-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta6.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta6.rst index 31aa0c9af2..c8cefa6415 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta6.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-beta6.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 beta6 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta6.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-beta6.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta6.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-beta6.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta6/cyrus-imapd-3.0.0-beta6.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-beta6/cyrus-imapd-3.0.0-beta6.tar.gz.sig .. _relnotes-3.0.0-beta6-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc1.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc1.rst index 5e2361e1e1..f8e8607286 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc1.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc1.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 rc1 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-rc1.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-rc1.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-rc1.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-rc1.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-rc1/cyrus-imapd-3.0.0-rc1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-rc1/cyrus-imapd-3.0.0-rc1.tar.gz.sig .. _relnotes-3.0.0-rc1-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc2.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc2.rst index 224dd2aa56..dce54c2689 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc2.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc2.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 rc2 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-rc2.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-rc2.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-rc2.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-rc2.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-rc2/cyrus-imapd-3.0.0-rc2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-rc2/cyrus-imapd-3.0.0-rc2.tar.gz.sig .. _relnotes-3.0.0-rc2-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc3.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc3.rst index d6b83bf065..6aece8fcb3 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc3.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc3.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 rc3 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-rc3.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-rc3.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-rc3.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-rc3.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-rc3/cyrus-imapd-3.0.0-rc3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-rc3/cyrus-imapd-3.0.0-rc3.tar.gz.sig .. _relnotes-3.0.0-rc3-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc4.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc4.rst index 39bceada1b..da47faf0cc 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc4.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0-rc4.rst @@ -10,15 +10,10 @@ Cyrus IMAP 3.0.0 rc4 Release Notes Do **NOT** use this version unless you're a developer of sorts. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-rc4.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0-rc4.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-rc4.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0-rc4.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-rc4/cyrus-imapd-3.0.0-rc4.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0-rc4/cyrus-imapd-3.0.0-rc4.tar.gz.sig .. _relnotes-3.0.0-rc4-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.0.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.0.rst index 7dbcdf4d20..2c5ffe872f 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.0.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.0.rst @@ -4,15 +4,10 @@ Cyrus IMAP 3.0.0 Release Notes ==================================== -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.0.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.0.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0/cyrus-imapd-3.0.0.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.0/cyrus-imapd-3.0.0.tar.gz.sig .. _relnotes-3.0.0-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.1.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.1.rst index 95d74275d4..ac252dc75f 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.1.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.1.rst @@ -11,15 +11,10 @@ Cyrus IMAP 3.0.1 Release Notes Refer to the Cyrus IMAP 3.0.0 Release Notes for important information about the 3.0 series, including upgrading instructions. -Download via HTTP: +Download from GitHub: - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.1.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.1.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.1.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.1.tar.gz.sig + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.1/cyrus-imapd-3.0.1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.1/cyrus-imapd-3.0.1.tar.gz.sig .. _relnotes-3.0.1-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.10.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.10.rst new file mode 100644 index 0000000000..5af1a27a71 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.10.rst @@ -0,0 +1,48 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.10 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the stable 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.10/cyrus-imapd-3.0.10.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.10/cyrus-imapd-3.0.10.tar.gz.sig + + +.. _relnotes-3.0.10-changes: + +Changes Since 3.0.9 +=================== + +Release changes +--------------- + +We're trialing using the Github Releases feature. If you have trouble downloading +this release, please report this to the mailing lists. Thanks! + +Security fixes +-------------- + +* Fixed CVE-2019-11356: buffer overrun in httpd + +Bug fixes +--------- + +* Fixed :issue:`2621`: Now builds correctly with ClamAV 0.101 (thanks Philippe + Kueck) +* Fixed :issue:`2678`: imap/conversations.h can now be included in C++ sources + (thanks Дилян Палаузов) +* Fixed :issue:`2679`: libcyrus_imap.pc is now installed by ``make install`` + (thanks Дилян Палаузов) +* Fixed :issue:`2712`: Cyrus::IMAP::Admin::rename() no longer produces warning + when called without optional partition parameter +* Fixed :issue:`2729`: conversation subject normalisation now treats + non-breaking space characters the same as other whitespace diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.11.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.11.rst new file mode 100644 index 0000000000..c9271a3363 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.11.rst @@ -0,0 +1,78 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.11 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the stable 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.11/cyrus-imapd-3.0.11.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.11/cyrus-imapd-3.0.11.tar.gz.sig + + +.. _relnotes-3.0.11-changes: + +Changes Since 3.0.10 +==================== + +Release changes +--------------- + +We're trialing using the Github Releases feature. If you have trouble downloading +this release, please report this to the mailing lists. Thanks! + +Data-loss bug fix (:issue:`2839`) +--------------------------------- + +This bug affects upgrades to all Cyrus IMAP 3.0.x releases prior to 3.0.11, +where mailboxes have existed from 2.3 and earlier. + +Specifically, mailboxes that either: + +* still have an index version less than 8, or +* previously had an index version less than 8, but were upgraded to a newer + index version by Cyrus IMAP 2.4 or 2.5, or +* previously had an index version less than 8, but were upgraded to a newer + index version by some earlier Cyrus IMAP 2.3.x versions (unknown which) + +may contain message records with an uninitialised modseq of 0. + +Some later versions of 2.3.x (unknown which) would rewrite modseqs of 0 to 1 +while upgrading indexes. If one of these versions was used to upgrade the +index, then there will no longer be message records with a modseq of 0. + +This behaviour was removed during pre-2.4 refactors, so Cyrus IMAP 2.4 and +2.5 do not replace modseq of 0 when upgrading indexes. + +A new API introduced in Cyrus IMAP 3.0.0 resulted in index records with a +modseq of 0 being completely ignored (therefore invisible to users, and +apparently "missing"). This includes the `reconstruct` utility, which means +that attempting to fix the "missing messages" by running `reconstruct` will +ignore (and maybe remove) the existing index records, and then "rediscover" +the message files on disk and add new records for them (without the index-only +data from the old record, like seen state and other flags). + +Cyrus IMAP 3.0.11 no longer ignores these records. + +Bug fixes +--------- + +* Fixed :issue:`2777`: `rm` and `rsync` binary locations are now detected during + configure, rather than just hardcoded as `/bin/rm` and `/usr/bin/rsync` +* Many documentation enhancements (thanks Дилян Палаузов, Christoph Moench-Tegeder, + Xavier Guimard) +* Fixed :issue:`2795`: failing unit test on PPC (32-bit, Big Endian) +* Fixed :issue:`2817`: build failure with ICU 64 +* Fixed: removed old limit of 20 delayed-delete subfolders + (`mailing list discussion `_) +* Fixed :issue:`2831`: httpd version info footer formats LibXML version correctly + (thanks Дилян Палаузов) +* Fixed :issue:`2832`: httpd no longer crashes on timezone propfind for + nonexistent mailbox (thanks Дилян Палаузов) diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.12.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.12.rst new file mode 100644 index 0000000000..6a124aedc9 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.12.rst @@ -0,0 +1,63 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.12 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the stable 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.12/cyrus-imapd-3.0.12.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.12/cyrus-imapd-3.0.12.tar.gz.sig + + +.. _relnotes-3.0.12-changes: + +Changes Since 3.0.11 +==================== + +Release changes +--------------- + +We're trialing using the Github Releases feature. If you have trouble downloading +this release, please report this to the mailing lists. Thanks! + +Security fixes +-------------- + +* Fixed CVE-2019-18928: unauthenticated HTTP requests no longer inherit + authentication from the previous request on the same connection + +Build changes +------------- + +* `configure` can now find xapian-config-1.5 on its own, if it's in your PATH. +* `make distcheck` now tries to build with most components enabled +* Sphinx 2 is now supported for documentation builds (thanks Jakob Gahde) +* Now builds cleanly with GCC 9.1.1 (thanks Дилян Палаузов) +* `configure --disable-pcre` now works properly, and falls back to POSIX regexes + +Other changes +------------- + +* Added support for tls1_3 + +Bug fixes +--------- + +* Fixed :issue:`2848`: MessageExpunge events from ipurge now have valid uri field +* Fixed :issue:`2877`: `quota -f` now works correctly with `improved_mboxlist_sort: no` +* Fixed :issue:`2741`: Murder frontends no longer crash on GETMETADATA +* Fixed :issue:`2808`: UNDUMP no longer crashes when quota needs updating +* Fixed: mailboxes can no longer be created in the namespace reserved for + reverseacls data +* Fixed :issue:`2893`: the experimental backup system now supports shared mailboxes +* Fixed: can now assign a sieve variable based on itself +* Fixed :issue:`2904`: authenticataed, cacheable DAV responses now include a + Cache-Control: private header diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.13.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.13.rst new file mode 100644 index 0000000000..4ae88ebdb1 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.13.rst @@ -0,0 +1,74 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.13 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the stable 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.13/cyrus-imapd-3.0.13.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.13/cyrus-imapd-3.0.13.tar.gz.sig + + +.. _relnotes-3.0.13-changes: + +Changes Since 3.0.12 +==================== + +Release changes +--------------- + +We're trialing using the Github Releases feature. If you have trouble downloading +this release, please report this to the mailing lists. Thanks! + +Security fixes +-------------- + +* Fixed CVE-2019-19783_: When creating a missing mailbox as part of a sieve + 'fileinto' directive, lmtpd would create it as administrator, bypassing ACL + checks. + + lmtpd creates missing mailboxes as part of a sieve 'fileinto' + directive if: + + * (2.5+) the `anysievefolder` option is enabled (default: not), or + * (3.0+) the `sieve_extensions` option has the 'mailbox' extension enabled + (default: enabled) and the 'fileinto' directive contains the ":create" + argument + + Under these conditions, a user with the ability to upload a custom sieve + script to their account could use it to create any valid mailbox on the + server (with ACL inherited from the parent mailbox as usual). + + lmtpd no longer creates these mailboxes as administrator, so users may no + longer use a 'fileinto' directive to create a mailbox they couldn't create + otherwise. + +.. _CVE-2019-19783: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-19783 + +Build changes +------------- + +* `configure --disable-http2` can now be used to disable HTTP/2 support, + even when libnghttp2 is installed on the system (thanks Дилян Палаузов) + +Bug fixes +--------- + +* Fixed :issue:`2383`: XFER of a single mailbox now works (thanks Anthony Prades) +* Fixed :issue:`2914`: `ctl_backups lock` no longer crashes if the backup is already + locked +* Fixed :issue:`2913`: errors are now logged when `maxlogins_per_host`, + `maxlogins_per_user`, and `popminpoll` limits are reached (thanks Sergey) +* Fixed: various IOERRORs resulting from bad handling of files >2GB +* Fixed :issue:`2920`: backup tools now expect admin namespace mboxnames, not + internal names +* Fixed :issue:`2931`: symbol ordering in libcyrus.so no longer depends on shell + locale in effect during compilation (thanks Xavier) diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.14.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.14.rst new file mode 100644 index 0000000000..4925313dbc --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.14.rst @@ -0,0 +1,41 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.14 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.14/cyrus-imapd-3.0.14.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.14/cyrus-imapd-3.0.14.tar.gz.sig + + +.. _relnotes-3.0.14-changes: + +Changes Since 3.0.13 +==================== + +Build changes +------------- + +* Added compatibility with recent versions of libcap (thanks Jakob Gahde) + +Bug fixes +--------- + +* Fixed :issue:`2920`: backup tools now expect admin namespace mboxnames, not + internal names (additional fixes that were not included in 3.0.13) +* Fixed: don't cross '.' boundaries when iterating DELETED mailboxes +* Fixed :issue:`3116`: :cyrusman:`cyr_info(8)` now correctly validates + archivepartition- settings +* Fixed: XFER now correctly distinguishes between 2.3.x releases +* Fixed :issue:`3123`: XFER now recognises 3.1, 3.2 and 3.3 backends +* Fixed: XFER now syslogs a warning when it doesn't recognise the backend + Cyrus version diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.15.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.15.rst new file mode 100644 index 0000000000..6ccce194d6 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.15.rst @@ -0,0 +1,38 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.15 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.15/cyrus-imapd-3.0.15.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.15/cyrus-imapd-3.0.15.tar.gz.sig + + +.. _relnotes-3.0.15-changes: + +Changes Since 3.0.14 +==================== + +Bug fixes +--------- + +* Fixed :issue:`3143`: tools/git-version.sh did not need bash specifically +* Fixed :issue:`3191`: saved session reuse crash when TLS enabled for backend + connections +* Fixed :issue:`3287`: tools/translatesieve directory iteration protection + (thanks Daniel O'Connor) +* Fixed :issue:`53`: mailbox tombstones were not being cleaned up by + :cyrusman:`cyr_expire(8)` +* Fixed: XFER now recognises 3.4 and 3.5 backends +* Fixed :issue:`3320`: memory leak during backend auth state cleanup +* Fixed :issue:`3312`: fixed use-after-free segfault in mupdate-client (thanks + Mario Haustein) diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.16.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.16.rst new file mode 100644 index 0000000000..724696e4de --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.16.rst @@ -0,0 +1,55 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.16 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.16/cyrus-imapd-3.0.16.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.16/cyrus-imapd-3.0.16.tar.gz.sig + + +.. _relnotes-3.0.16-changes: + +Changes Since 3.0.15 +==================== + +Security fixes: +--------------- + +* Fixed CVE-2021-33582_: Certain user inputs are used as hash table keys during + processing. A poorly chosen string hashing algorithm meant that the user + could control which bucket their data was stored in, allowing a malicious + user to direct many inputs to a single bucket. Each subsequent insertion to + the same bucket requires a strcmp of every other entry in it. At tens of + thousands of entries, each new insertion could keep the CPU busy in a strcmp + loop for minutes. + + The string hashing algorithm has been replaced with a better one, and now + also uses a random seed per hash table, so malicious inputs cannot be + precomputed. + + Discovered by Matthew Horsfall, Fastmail + +.. _CVE-2021-33582: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-33582 + +Build fixes +----------- + +* Fixed: expired test certificates caused unit test failures +* Fixed: various warnings raised by newer compilers + +Bug fixes +--------- + +* Fixed: crash when looking up entries in zero-sized hash tables +* Fixed: deduplicated code in hash_del (thanks Дилян Палаузов) +* Fixed :issue:`3456`: per-server annotations were unable to replicate diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.17.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.17.rst new file mode 100644 index 0000000000..a93052f326 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.17.rst @@ -0,0 +1,40 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.17 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.17/cyrus-imapd-3.0.17.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.17/cyrus-imapd-3.0.17.tar.gz.sig + + +.. _relnotes-3.0.17-changes: + +Changes Since 3.0.16 +==================== + +Bug fixes +--------- + +* Fixed :issue:`2383`: XFER of a single user or mailbox now works again +* Fixed: XFER no longer tries to sync_restart (and hangs) when the destination + backend doesn't support XFER-via-replication +* Fixed: XFER now reports an error when the name argument doesn't match + anything, instead of doing nothing and then reporting that it succeeded at + it. + +Other changes +------------- + +* The formerly-standalone Cassandane tool has been merged into the + cyrus-imapd repository, in the 'cassandane' subdirectory. +* XFER will now recognise backends from the upcoming 3.6 and 3.7 versions diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.18.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.18.rst new file mode 100644 index 0000000000..b9d5050688 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.18.rst @@ -0,0 +1,45 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.18 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.18/cyrus-imapd-3.0.18.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.18/cyrus-imapd-3.0.18.tar.gz.sig + + +.. _relnotes-3.0.18-changes: + +Changes Since 3.0.17 +==================== + +Build changes +------------- + +* Fixed: iCal GEO property is text in new libical versions +* Fixed: docs now build correctly with python3 and Sphinx 3.4 + +Bug fixes +--------- + +* Fixed :issue:`4002`: cyradm: allow LIST command even when support_referrals + is false +* Fixed :issue:`4216`: httpd killed by SIGSEGV for calendar request (thanks + Дилян Палаузов) + +Other changes +------------- + +* Fixed :issue:`4380`: ``backend_version()`` now properly parses the remote + server's version string, and can recognise when it is newer than the local + server. This means XFER to a newer backend no longer requires a local + software update to recognise the new version number first. diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.2.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.2.rst index b4df3bc570..93f89726a6 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.2.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.2.rst @@ -11,16 +11,10 @@ Cyrus IMAP 3.0.2 Release Notes Refer to the Cyrus IMAP 3.0.0 Release Notes for important information about the 3.0 series, including upgrading instructions. -Download via HTTP: - - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.2.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.2.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.2.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.2.tar.gz.sig +Download from GitHub: + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.2/cyrus-imapd-3.0.2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.2/cyrus-imapd-3.0.2.tar.gz.sig .. _relnotes-3.0.2-changes: @@ -44,6 +38,7 @@ Other changes * Cyrus distributions now contain a VERSION file, containing the released Cyrus version, which is preserved across autoreconf calls. * Removed convert-sieve.pl: :cyrusman:`translatesieve(8)` does everything you need and more. +* Distribution now contains Cyrus::DList, Cyrus::ImapClone, and Cyrus::SyncProto perl modules Bug fixes --------- @@ -64,3 +59,4 @@ Bug fixes * Fixed: pkgconfig files now contain exec_prefix (thanks Дилян Палаузов) * Fixed :issue:`1993`: crc32c and xsha1 modules now build correctly on big-endian platforms (thanks Jason Tibbitts) +* Fixed: reconstruct no longer crashes on non-64bit platforms (thanks Jason Tibbitts) diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.3.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.3.rst index fadab94967..41bb9798f1 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.3.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.3.rst @@ -11,16 +11,10 @@ Cyrus IMAP 3.0.3 Release Notes Refer to the Cyrus IMAP 3.0.0 Release Notes for important information about the 3.0 series, including upgrading instructions. -Download via HTTP: - - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.3.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.3.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.3.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.3.tar.gz.sig +Download from GitHub: + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.3/cyrus-imapd-3.0.3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.3/cyrus-imapd-3.0.3.tar.gz.sig .. _relnotes-3.0.3-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.4.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.4.rst index f44e2bb33c..83cc8877cb 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.4.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.4.rst @@ -11,16 +11,10 @@ Cyrus IMAP 3.0.4 Release Notes Refer to the Cyrus IMAP 3.0.0 Release Notes for important information about the 3.0 series, including upgrading instructions. -Download via HTTP: - - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.4.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.4.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.4.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.4.tar.gz.sig +Download from GitHub: + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.4/cyrus-imapd-3.0.4.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.4/cyrus-imapd-3.0.4.tar.gz.sig .. _relnotes-3.0.4-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.5.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.5.rst index 47166c7a26..7a99b67357 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.5.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.5.rst @@ -11,16 +11,10 @@ Cyrus IMAP 3.0.5 Release Notes Refer to the Cyrus IMAP 3.0.0 Release Notes for important information about the 3.0 series, including upgrading instructions. -Download via HTTP: - - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.5.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.5.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.5.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.5.tar.gz.sig +Download from GitHub: + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.5/cyrus-imapd-3.0.5.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.5/cyrus-imapd-3.0.5.tar.gz.sig .. _relnotes-3.0.5-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.6.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.6.rst index 1561861a09..b0b2a6b1f0 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.6.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.6.rst @@ -11,16 +11,10 @@ Cyrus IMAP 3.0.6 Release Notes Refer to the Cyrus IMAP 3.0.0 Release Notes for important information about the 3.0 series, including upgrading instructions. -Download via HTTP: - - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.6.tar.gz - * http://www.cyrusimap.org/releases/cyrus-imapd-3.0.6.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.6.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.6.tar.gz.sig +Download from GitHub: + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.6/cyrus-imapd-3.0.6.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.6/cyrus-imapd-3.0.6.tar.gz.sig .. _relnotes-3.0.6-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.7.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.7.rst index 49533e8a41..74ad619777 100644 --- a/docsrc/imap/download/release-notes/3.0/x/3.0.7.rst +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.7.rst @@ -11,16 +11,10 @@ Cyrus IMAP 3.0.7 Release Notes Refer to the Cyrus IMAP 3.0.0 Release Notes for important information about the 3.0 series, including upgrading instructions. -Download via HTTPS: - - * https://www.cyrusimap.org/releases/cyrus-imapd-3.0.7.tar.gz - * https://www.cyrusimap.org/releases/cyrus-imapd-3.0.7.tar.gz.sig - -Download via FTP: - - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.7.tar.gz - * ftp://ftp.cyrusimap.org/cyrus-imapd/cyrus-imapd-3.0.7.tar.gz.sig +Download from GitHub: + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.7/cyrus-imapd-3.0.7.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.7/cyrus-imapd-3.0.7.tar.gz.sig .. _relnotes-3.0.7-changes: diff --git a/docsrc/imap/download/release-notes/3.0/x/3.0.9.rst b/docsrc/imap/download/release-notes/3.0/x/3.0.9.rst new file mode 100644 index 0000000000..1e8506775b --- /dev/null +++ b/docsrc/imap/download/release-notes/3.0/x/3.0.9.rst @@ -0,0 +1,68 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.0.9 Release Notes +=============================== + +.. IMPORTANT:: + + This is a bug-fix release in the stable 3.0 series. + + Refer to the Cyrus IMAP 3.0.0 Release Notes for important information + about the 3.0 series, including upgrading instructions. + +Download via HTTPS: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.9/cyrus-imapd-3.0.9.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.0.9/cyrus-imapd-3.0.9.tar.gz.sig + + +.. _relnotes-3.0.9-changes: + +Changes Since 3.0.8 +=================== + +Release changes +--------------- + +We're trialing using the Github Releases feature. If you have trouble downloading +this release, please report this to the mailing lists. Thanks! + +Dependency changes +------------------ + +* ClamAV 0.101 is now supported (thanks Adam Gołębiowski) + +New configuration options +------------------------- + +* The new ``cyrus_group`` option in :cyrusman:`imapd.conf(5)` can be used to + set the UNIX group that Cyrus processes run as (thanks Jakob Gahde). The + default is to use the primary group of the configured ``cyrus_user``. The + old ``--with-cyrus-group`` configure option has been non-functional for many + years, and has been removed. + +Bug fixes +--------- + +* Fixed :issue:`2521`: cyradm getquotaroot now supports mailbox names + containing spaces (thanks Marco Favero) +* Fixed :issue:`2524`: ipurge no longer crashes on partial name matches +* Fixed: various memory leaks (thanks Pavel Zhukov) +* Fixed :issue:`2534`: com_err now built early enough (thanks + Дилян Палаузов) +* Fixed :issue:`2566`: idle socket setup no longer wrapped by asserts (thanks + Zan Lynx) +* Fixed :issue:`2597`: lmtpd segfault with sieve redirections on shared folder + (thanks Anthony Prades) +* Fixed :issue:`2449`: lmtpd segfault with sieve rejections on shared folder + (thanks Anthony Prades) +* Fixed :issue:`2609`: mbexamine now correctly handles mailbox where all mail + is archived and ``archive-partition`` and ``meta-partition`` are enabled + (thanks Michael Menge) +* Fixed :issue:`2625`: the ``ptscache_db_path`` setting now works correctly +* Fixed :issue:`2643`: ptclient now returns correct group names when resolved + by filter (thanks Felix Schumacher) +* Fixed: conversationsdb now stores GUID even if message has no CID +* Fixed :issue:`2663`: Q-encoded MIME headers no longer split multi-octet + UTF words (thanks Дилян Палаузов) diff --git a/docsrc/imap/download/release-notes/3.1/x/3.1.7.rst b/docsrc/imap/download/release-notes/3.1/x/3.1.7.rst new file mode 100644 index 0000000000..470b0b7310 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.1/x/3.1.7.rst @@ -0,0 +1,75 @@ +:tocdepth: 3 + +========================== +Cyrus IMAP 3.1.7 Tag Notes +========================== + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to tag these + snapshots at coherent development points, but there will generally be **large + breaking changes** occurring between releases in this series. + +.. _relnotes-3.1.7-changes: + +Major changes since the 3.0.x series +==================================== + +* Sieve bug fixes and features. +* Caldav and Carddav improvements. +* Support for JMAP. +* Xapian bug fixes. +* Improvements to Annotations handling. +* DRAC support has been deprecated. +* Support for Prometheus stats. +* Removed support for the Sphinx backend to squatter searches. +* New cyrus.index format v16 included since 3.1.5 - adds unseen count and + createdmodseq to index header, savedate and createdmodseq to index records +* Support for WebSockets +* Support for HTTP/2.0 +* Support for Zeroskip database format +* Intermediate mailboxes are now recorded in mailboxes database +* Conversations database format update - adds flags and internaldate fields, + and is now versioned for future-compatibility. You will need to rebuild + your conversations databases with :cyrusman:`ctl_conversationsdb(8)` and + the `-b` switch to benefit from this. +* IMAP FETCH accepts two new data items, MAILBOXIDS and MAILBOXES, which + respectively return the unique ids or names of the containing mailboxes of + each message in the sequence (for best performance, rebuild your + conversations databases as above) +* :cyrusman:`mbpath(8)` is now much more useful +* Twoskip database format now supports shared locks +* All Cyrus binaries now use real sysexits exit codes instead of mapping + nearly everything to EX_TEMPFAIL +* CyrusDB errors now syslog the actual error instead of just "cyrusdb error" + + +Updates to default configuration +================================ + +* The `specialusealways` option is now enabled by default. It must + explicitly be disabled for interoperability with legacy clients that + can't handle RFC 6154 attributes in extended LIST commands. +* The values accepted by `expunge_mode` have changed, please see the + documentation for more information about the changes. +* The legacy GETANNOTATIONS/SETANNOTATIONS IMAP commands will no longer + work unless `annotation_enable_legacy_commands` is enabled + +Security fixes +============== +* Contains fix for `CVE-2017-14230 `_ + +Significant bugfixes +==================== + +* Contains fix for :issue:`2839` + + +.. _Xapian: https://xapian.org +.. _ClamAV: https://www.clamav.net +.. _JMAP: http://jmap.io diff --git a/docsrc/imap/download/release-notes/3.1/x/3.1.8.rst b/docsrc/imap/download/release-notes/3.1/x/3.1.8.rst new file mode 100644 index 0000000000..4b3f2a3db0 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.1/x/3.1.8.rst @@ -0,0 +1,92 @@ +:tocdepth: 3 + +========================== +Cyrus IMAP 3.1.8 Tag Notes +========================== + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to tag these + snapshots at coherent development points, but there will generally be **large + breaking changes** occurring between releases in this series. + +.. _relnotes-3.1.8-changes: + +Major changes since the 3.0.x series +==================================== + +.. XXX explicate JMAP support properly, not just "Support for JMAP" + * Support for RFCxyz JMAP Core and Email standards + * Experimental support for JMAP Calendar, Contacts, ... extensions + +* Sieve bug fixes and features. +* Caldav and Carddav improvements. +* Support for JMAP. +* Xapian bug fixes. +* Improvements to Annotations handling. +* DRAC support has been deprecated. +* Support for Prometheus stats. +* Removed support for the Sphinx backend to squatter searches. +* New cyrus.index format v16 included since 3.1.5 - adds unseen count and + createdmodseq to index header, savedate and createdmodseq to index records +* Support for WebSockets +* Support for HTTP/2.0 +* Support for Zeroskip database format +* Intermediate mailboxes are now recorded in mailboxes database +* Conversations database format update - adds flags and internaldate fields, + and is now versioned for future-compatibility. You will need to rebuild + your conversations databases with :cyrusman:`ctl_conversationsdb(8)` and + the `-b` switch to benefit from this. +* IMAP FETCH accepts two new data items, MAILBOXIDS and MAILBOXES, which + respectively return the unique ids or names of the containing mailboxes of + each message in the sequence (for best performance, rebuild your + conversations databases as above) +* :cyrusman:`mbpath(8)` is now much more useful +* Twoskip database format now supports shared locks +* All Cyrus binaries now use real sysexits exit codes instead of mapping + nearly everything to EX_TEMPFAIL +* CyrusDB errors now syslog the actual error instead of just "cyrusdb error" +* New `allowdeleted` :cyrusman:`imapd.conf(5)` option (default off), which + allows admin users to see deleted mailboxes and expunged messages over IMAP +* :cyrusman:`cyr_virusscan(8)` now supports custom templates for notifications + sent about infected messages that have been deleted +* :cyrusman:`imapd.conf(5)` options that represent a time duration now accept + 'd', 'h', 'm', 's' suffixes rather than arbitrary units. +* The `tls_server_cert` and `tls_server_key` :cyrusman:`imapd.conf(5)` options + now allow two certificate/key pairs (e.g. RSA and EC) to be used. Thanks + Дилян Палаузов + + +Updates to default configuration +================================ + +* The `specialusealways` option is now enabled by default. It must + explicitly be disabled for interoperability with legacy clients that + can't handle RFC 6154 attributes in extended LIST commands. +* The values accepted by `expunge_mode` have changed, please see the + documentation for more information about the changes. +* The legacy GETANNOTATIONS/SETANNOTATIONS IMAP commands will no longer + work unless `annotation_enable_legacy_commands` is enabled + + +Security fixes +============== + +* Contains fix for `CVE-2017-14230 `_ +* Contains fix for `CVE-2019-18928 `_ + + +Significant bugfixes +==================== + +* Contains fix for :issue:`2839` + + +.. _Xapian: https://xapian.org +.. _ClamAV: https://www.clamav.net +.. _JMAP: http://jmap.io diff --git a/docsrc/imap/download/release-notes/3.1/x/3.1.9.rst b/docsrc/imap/download/release-notes/3.1/x/3.1.9.rst new file mode 100644 index 0000000000..88e91b32c5 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.1/x/3.1.9.rst @@ -0,0 +1,97 @@ +:tocdepth: 3 + +========================== +Cyrus IMAP 3.1.9 Tag Notes +========================== + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to tag these + snapshots at coherent development points, but there will generally be **large + breaking changes** occurring between releases in this series. + +.. _relnotes-3.1.9-changes: + +Major changes since the 3.0.x series +==================================== + +* Sieve bug fixes and features. +* Caldav and Carddav improvements. +* Support for JMAP core protocol (:rfc:`8620`). +* Support for JMAP Mail (:rfc:`8621`). +* Experimental support for JMAP Contacts (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental support for JMAP Calendars (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Xapian bug fixes. +* Improvements to Annotations handling. +* DRAC support has been deprecated. +* Support for Prometheus stats. +* Removed support for the Sphinx backend to squatter searches. +* New cyrus.index format v16 included since 3.1.5 - adds unseen count and + createdmodseq to index header, savedate and createdmodseq to index records +* Support for WebSockets +* Support for HTTP/2.0 +* Support for Zeroskip database format +* Intermediate mailboxes are now recorded in mailboxes database +* Conversations database format update - adds flags and internaldate fields, + and is now versioned for future-compatibility. You will need to rebuild + your conversations databases with :cyrusman:`ctl_conversationsdb(8)` and + the `-b` switch to benefit from this. +* IMAP FETCH accepts two new data items, MAILBOXIDS and MAILBOXES, which + respectively return the unique ids or names of the containing mailboxes of + each message in the sequence (for best performance, rebuild your + conversations databases as above) +* :cyrusman:`mbpath(8)` is now much more useful +* Twoskip database format now supports shared locks +* All Cyrus binaries now use real sysexits exit codes instead of mapping + nearly everything to EX_TEMPFAIL +* CyrusDB errors now syslog the actual error instead of just "cyrusdb error" +* New `allowdeleted` :cyrusman:`imapd.conf(5)` option (default off), which + allows admin users to see deleted mailboxes and expunged messages over IMAP +* :cyrusman:`cyr_virusscan(8)` now supports custom templates for notifications + sent about infected messages that have been deleted +* :cyrusman:`imapd.conf(5)` options that represent a time duration now accept + 'd', 'h', 'm', 's' suffixes rather than arbitrary units. +* The `tls_server_cert` and `tls_server_key` :cyrusman:`imapd.conf(5)` options + now allow two certificate/key pairs (e.g. RSA and EC) to be used. Thanks + Дилян Палаузов +* Mailbox create/delete/rename are now performed under a lock on the user's + namespace, to prevent races (especially during big renames). + + +Updates to default configuration +================================ + +* The `specialusealways` option is now enabled by default. It must + explicitly be disabled for interoperability with legacy clients that + can't handle RFC 6154 attributes in extended LIST commands. +* The values accepted by `expunge_mode` have changed, please see the + documentation for more information about the changes. +* The legacy GETANNOTATIONS/SETANNOTATIONS IMAP commands will no longer + work unless `annotation_enable_legacy_commands` is enabled. +* The `outbox_sendlater` option and its functionality have been removed. + + +Security fixes +============== + +* Contains fix for `CVE-2017-14230 `_ +* Contains fix for `CVE-2019-18928 `_ +* Contains fix for `CVE-2019-19783 `_ + + +Significant bugfixes +==================== + +* Contains fix for :issue:`2839` + + +.. _Xapian: https://xapian.org +.. _ClamAV: https://www.clamav.net +.. _JMAP: http://jmap.io diff --git a/docsrc/imap/download/release-notes/3.10/index.rst b/docsrc/imap/download/release-notes/3.10/index.rst new file mode 100644 index 0000000000..b0e5115d37 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.10/index.rst @@ -0,0 +1,15 @@ +.. _imap-release-notes-3.10: + +======================== +Cyrus IMAP 3.10 Releases +======================== + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.??.*-alpha* + x/?.??.*-beta* + x/?.??.*-rc* + x/?.??.? + x/?.??.?? diff --git a/docsrc/imap/download/release-notes/3.10/x/3.10.0-alpha0.rst b/docsrc/imap/download/release-notes/3.10/x/3.10.0-alpha0.rst new file mode 100644 index 0000000000..5d4e4598a3 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.10/x/3.10.0-alpha0.rst @@ -0,0 +1,46 @@ +:tocdepth: 3 + +====================================== +Cyrus IMAP 3.10.0-alpha0 Release Notes +====================================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-alpha0/cyrus-imapd-3.10.0-alpha0.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-alpha0/cyrus-imapd-3.10.0-alpha0.tar.gz.sig + +.. _relnotes-3.10.0-alpha0_changes: + +Major changes since the 3.8 series +================================== + +* None so far + +.. _relnotes_3.10.0-alpha0_storage_changes: + +Storage changes +=============== + +* None so far + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* None so far + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* None so far diff --git a/docsrc/imap/download/release-notes/3.10/x/3.10.0-beta1.rst b/docsrc/imap/download/release-notes/3.10/x/3.10.0-beta1.rst new file mode 100644 index 0000000000..2db83251ff --- /dev/null +++ b/docsrc/imap/download/release-notes/3.10/x/3.10.0-beta1.rst @@ -0,0 +1,113 @@ +:tocdepth: 3 + +===================================== +Cyrus IMAP 3.10.0-beta1 Release Notes +===================================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-beta1/cyrus-imapd-3.10.0-beta1.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-beta1/cyrus-imapd-3.10.0-beta1.tar.gz.sig + +.. _relnotes-3.10.0-beta1_changes: + +Major changes since the 3.8 series +================================== + +* URLs found in HTML ````, ```` and ```` tags, as well as "alt" + text in ```` tags, are now indexed for search and snippets +* :cyrusman:`cyr_expire(8)` now supports non-day durations in the + archive/delete/expire annotations +* :cyrusman:`cyr_expire(8)` no longer supports fractional durations in command + line arguments. Installations that passed fractional durations such as + "1.5d" to any of the ``-E``, ``-X``, ``-D``, or ``-A`` arguments must adapt + these to only use integer durations such as "1d12h" +* :cyrusman:`cyr_expire(8)` now supports the 'noexpire_until' annotation to + disable cyr_expire per user +* JMAP calendar default alarms are now stored in a non-DAV mailbox annotation. + See :ref:`upgrade_jmap_default_alarms` for upgrading instructions if you are + already using the experimental JMAP Calendars API +* Removes support for parsing and generating bytecode for the deprecated + denotify action and notify actions using the legacy (pre-:rfc:`5435`) syntax. + Existing bytecode containing these actions will still be executed. Scripts + that contain the deprecated denotify action should be rewritten to remove + them. Scripts that contain notify actions using the legacy syntax should be + rewritten to use the syntax in :rfc:`5435` +* Adds support for the "exp" and "nbf" JSON Web Token claims. Thanks Bruno + Thomas +* Adds support for IMAP Version 4rev2 (:rfc:`9051`) +* Adds support for IMAP NOTIFY (:rfc:`5465`). Only available if ``idled`` is + running +* Refresh interval for APNS subscriptions to DAV resources is now configurable. + See the ``aps_expiry`` :cyrusman:`imapd.conf(5)` option +* Upgrade IMAP Quota support to :rfc:`9208`. Sites running a Murder should + upgrade frontend machines first, then backends +* Adds support for IMAP REPLACE (:rfc:`8508`) +* Adds support for IMAP UIDONLY extension (:draft:`draft-ietf-extra-imap-uidonly`) +* Adds experimental support for JMAP Contacts per upcoming IETF standards. + Requires the not-yet-released + `libicalvcard `_ +* :cyrusman:`squatter(8)` now supports the "wait=y" :cyrusman:`cyrus.conf(5)` + option when started in rolling mode from the ``DAEMON`` section +* :cyrusman:`master(8)` now touches a ready file to indicate it is "ready for + work". See :ref:`upgrade_master_pid_ready_files` +* :cyrusman:`master(8)` now gets its pidfile name from the ``master_pid_file`` + :cyrusman:`imapd.conf(5)` option. See :ref:`upgrade_master_pid_ready_files` +* Adds pcre2 support. Prefers pcre2 over pcre if both are available. See + :ref:`upgrade_pcre2_support` +* The ``proc`` :cyrusman:`cyr_info(8)` subcommand now also reports DAEMON and + EVENTS processes +* JMAP CalendarEventNotification objects are now automatically pruned. + The ``jmap_max_calendareventnotifs`` :cyrusman:`imapd.conf(5)` option can be + used to tune this behaviour +* Cyrus now requires libical >= 3.0.10 for HTTP support +* Sieve [current]date ``:zone`` parameter now accepts either a UTC offset or an + IANA time zone ID +* Adds an ``implicit_keep_target`` Sieve action to change the target mailbox + for an implicit keep +* :cyrusman:`squatter(8)` no longer holds a mailbox lock while extracting text + from attachments +* IMAP ``RENAME`` command no longer emits non-standard per-folder updates. Use + the new ``XRENAME`` command if you need this behaviour + +.. _relnotes_3.10.0-beta1_storage_changes: + +Storage changes +=============== + +* None in 3.10. But if your upgrade is skipping over 3.6 and 3.8, please do + not miss :ref:`3.6.0 Storage changes ` + and :ref:`3.8.0 Storage changes ` + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The master pidfile name is now read from imapd.conf, and defaults + to ``{configdirectory}/master.pid``. If you have something that + looks for this file, you should either update it to look in the new + default location, or set ``master_pid_file`` in :cyrusman:`imapd.conf(5)` + to override the default. The ``-p`` option to :cyrusman:`master(8)` + can still be used to override it + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: squat db reindexes are no longer always incremental +* Fixed: squat db corruption from unintentional indexing of fields + intended to be skipped +* Fixed: squat db out of bounds access in incremental reindex docID map +* Restored functionality of the sync_client ``-o``/``--connect-once`` option +* Fixed :issue:`4654`: copying/moving messages from split conversations is now + correct diff --git a/docsrc/imap/download/release-notes/3.10/x/3.10.0-beta2.rst b/docsrc/imap/download/release-notes/3.10/x/3.10.0-beta2.rst new file mode 100644 index 0000000000..63c52627ff --- /dev/null +++ b/docsrc/imap/download/release-notes/3.10/x/3.10.0-beta2.rst @@ -0,0 +1,120 @@ +:tocdepth: 3 + +===================================== +Cyrus IMAP 3.10.0-beta2 Release Notes +===================================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-beta2/cyrus-imapd-3.10.0-beta2.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-beta2/cyrus-imapd-3.10.0-beta2.tar.gz.sig + +.. _relnotes-3.10.0-beta2_changes: + +Major changes since the 3.8 series +================================== + +* URLs found in HTML ````, ```` and ```` tags, as well as "alt" + text in ```` tags, are now indexed for search and snippets +* :cyrusman:`cyr_expire(8)` now supports non-day durations in the + archive/delete/expire annotations +* :cyrusman:`cyr_expire(8)` no longer supports fractional durations in command + line arguments. Installations that passed fractional durations such as + "1.5d" to any of the ``-E``, ``-X``, ``-D``, or ``-A`` arguments must adapt + these to only use integer durations such as "1d12h" +* :cyrusman:`cyr_expire(8)` now supports the 'noexpire_until' annotation to + disable cyr_expire per user +* JMAP calendar default alarms are now stored in a non-DAV mailbox annotation. + See :ref:`upgrade_jmap_default_alarms` for upgrading instructions if you are + already using the experimental JMAP Calendars API +* Removes support for parsing and generating bytecode for the deprecated + denotify action and notify actions using the legacy (pre-:rfc:`5435`) syntax. + Existing bytecode containing these actions will still be executed. Scripts + that contain the deprecated denotify action should be rewritten to remove + them. Scripts that contain notify actions using the legacy syntax should be + rewritten to use the syntax in :rfc:`5435` +* Adds support for the "exp" and "nbf" JSON Web Token claims. Thanks Bruno + Thomas +* Adds support for IMAP Version 4rev2 (:rfc:`9051`) +* Adds support for IMAP NOTIFY (:rfc:`5465`). Only available if ``idled`` is + running +* Refresh interval for APNS subscriptions to DAV resources is now configurable. + See the ``aps_expiry`` :cyrusman:`imapd.conf(5)` option +* Upgrade IMAP Quota support to :rfc:`9208`. Sites running a Murder will be + unable to set ANNOTATION-STORAGE or MAILBOX quotas (formerly known as + X-ANNOTATION-STORAGE and X-NUM_FOLDERS) in a mixed-version environment until + frontends are upgraded. Upgraded frontends know how to negotiate with older + backends. +* Adds support for IMAP REPLACE (:rfc:`8508`) +* Adds support for IMAP UIDONLY extension (:draft:`draft-ietf-extra-imap-uidonly`) +* Adds experimental support for JMAP Contacts per upcoming IETF standards. + Requires the not-yet-released + `libicalvcard `_ +* :cyrusman:`squatter(8)` now supports the "wait=y" :cyrusman:`cyrus.conf(5)` + option when started in rolling mode from the ``DAEMON`` section +* :cyrusman:`master(8)` now touches a ready file to indicate it is "ready for + work". See :ref:`upgrade_master_pid_ready_files` +* :cyrusman:`master(8)` now gets its pidfile name from the ``master_pid_file`` + :cyrusman:`imapd.conf(5)` option. See :ref:`upgrade_master_pid_ready_files` +* Adds pcre2 support. Prefers pcre2 over pcre if both are available. See + :ref:`upgrade_pcre2_support` +* The ``proc`` :cyrusman:`cyr_info(8)` subcommand now also reports DAEMON and + EVENTS processes +* JMAP CalendarEventNotification objects are now automatically pruned. + The ``jmap_max_calendareventnotifs`` :cyrusman:`imapd.conf(5)` option can be + used to tune this behaviour +* Cyrus now requires libical >= 3.0.10 for HTTP support +* Sieve [current]date ``:zone`` parameter now accepts either a UTC offset or an + IANA time zone ID +* Adds an ``implicit_keep_target`` Sieve action to change the target mailbox + for an implicit keep +* :cyrusman:`squatter(8)` no longer holds a mailbox lock while extracting text + from attachments +* IMAP ``RENAME`` command no longer emits non-standard per-folder updates. Use + the new ``XRENAME`` command if you need this behaviour + +.. _relnotes_3.10.0-beta2_storage_changes: + +Storage changes +=============== + +* None in 3.10. But if your upgrade is skipping over 3.6 and 3.8, please do + not miss :ref:`3.6.0 Storage changes ` + and :ref:`3.8.0 Storage changes ` + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The master pidfile name is now read from imapd.conf, and defaults + to ``{configdirectory}/master.pid``. If you have something that + looks for this file, you should either update it to look in the new + default location, or set ``master_pid_file`` in :cyrusman:`imapd.conf(5)` + to override the default. The ``-p`` option to :cyrusman:`master(8)` + can still be used to override it + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: squat db reindexes are no longer always incremental +* Fixed: squat db corruption from unintentional indexing of fields + intended to be skipped +* Fixed: squat db out of bounds access in incremental reindex docID map +* Fixed :issue:`4692`: squat db searches now handle unindexed messages + correctly again (thanks Gabriele Bulfon) +* Restored functionality of the sync_client ``-o``/``--connect-once`` option +* Fixed :issue:`4654`: copying/moving messages from split conversations is now + correct +* Fixed :issue:`4758`: fix renaming mailbox between users +* Fixed :issue:`4804`: mailbox_maxmessages limits now applied correctly diff --git a/docsrc/imap/download/release-notes/3.10/x/3.10.0-rc1.rst b/docsrc/imap/download/release-notes/3.10/x/3.10.0-rc1.rst new file mode 100644 index 0000000000..0c5e4b04f6 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.10/x/3.10.0-rc1.rst @@ -0,0 +1,157 @@ +:tocdepth: 3 + +===================================== +Cyrus IMAP 3.10.0-rc1 Release Notes +===================================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-rc1/cyrus-imapd-3.10.0-rc1.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-rc1/cyrus-imapd-3.10.0-rc1.tar.gz.sig + +.. _relnotes-3.10.0-rc1_changes: + +Major changes since the 3.8 series +================================== + +* URLs found in HTML ````, ```` and ```` tags, as well as "alt" + text in ```` tags, are now indexed for search and snippets +* :cyrusman:`cyr_expire(8)` now supports non-day durations in the + archive/delete/expire annotations +* :cyrusman:`cyr_expire(8)` no longer supports fractional durations in command + line arguments. Installations that passed fractional durations such as + "1.5d" to any of the ``-E``, ``-X``, ``-D``, or ``-A`` arguments must adapt + these to only use integer durations such as "1d12h" +* :cyrusman:`cyr_expire(8)` now supports the 'noexpire_until' annotation to + disable cyr_expire per user +* JMAP calendar default alarms are now stored in a non-DAV mailbox annotation. + See :ref:`upgrade_jmap_default_alarms` for upgrading instructions if you are + already using the experimental JMAP Calendars API +* Removes support for parsing and generating bytecode for the deprecated + denotify action and notify actions using the legacy (pre-:rfc:`5435`) syntax. + Existing bytecode containing these actions will still be executed. Scripts + that contain the deprecated denotify action should be rewritten to remove + them. Scripts that contain notify actions using the legacy syntax should be + rewritten to use the syntax in :rfc:`5435` +* Adds support for the "exp" and "nbf" JSON Web Token claims. Thanks Bruno + Thomas +* Adds support for IMAP Version 4rev2 (:rfc:`9051`) +* Adds support for IMAP NOTIFY (:rfc:`5465`). Only available if ``idled`` is + running +* Refresh interval for APNS subscriptions to DAV resources is now configurable. + See the ``aps_expiry`` :cyrusman:`imapd.conf(5)` option +* Upgrade IMAP Quota support to :rfc:`9208`. Sites running a Murder will be + unable to set ANNOTATION-STORAGE or MAILBOX quotas (formerly known as + X-ANNOTATION-STORAGE and X-NUM_FOLDERS) in a mixed-version environment until + frontends are upgraded. Upgraded frontends know how to negotiate with older + backends. +* Adds support for IMAP REPLACE (:rfc:`8508`) +* Adds support for IMAP UIDONLY extension (:draft:`draft-ietf-extra-imap-uidonly`) +* Adds experimental support for JMAP Contacts per upcoming IETF standards. + Requires the not-yet-released + `libicalvcard `_ +* :cyrusman:`squatter(8)` now supports the "wait=y" :cyrusman:`cyrus.conf(5)` + option when started in rolling mode from the ``DAEMON`` section +* :cyrusman:`master(8)` now touches a ready file to indicate it is "ready for + work". See :ref:`upgrade_master_pid_ready_files` +* :cyrusman:`master(8)` now gets its pidfile name from the ``master_pid_file`` + :cyrusman:`imapd.conf(5)` option. See :ref:`upgrade_master_pid_ready_files` +* Adds pcre2 support. Prefers pcre2 over pcre if both are available. See + :ref:`upgrade_pcre2_support` +* The ``proc`` :cyrusman:`cyr_info(8)` subcommand now also reports DAEMON and + EVENTS processes +* JMAP CalendarEventNotification objects are now automatically pruned. + The ``jmap_max_calendareventnotifs`` :cyrusman:`imapd.conf(5)` option can be + used to tune this behaviour +* Cyrus now requires libical >= 3.0.10 for HTTP support +* Sieve [current]date ``:zone`` parameter now accepts either a UTC offset or an + IANA time zone ID +* Adds an ``implicit_keep_target`` Sieve action to change the target mailbox + for an implicit keep +* :cyrusman:`squatter(8)` no longer holds a mailbox lock while extracting text + from attachments +* IMAP ``RENAME`` command no longer emits non-standard per-folder updates. Use + the new ``XRENAME`` command if you need this behaviour + +.. _relnotes_3.10.0-beta2_storage_changes: + +Storage changes +=============== + +* None in 3.10. But if your upgrade is skipping over 3.6 and 3.8, please do + not miss :ref:`3.6.0 Storage changes ` + and :ref:`3.8.0 Storage changes ` + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The master pidfile name is now read from imapd.conf, and defaults + to ``{configdirectory}/master.pid``. If you have something that + looks for this file, you should either update it to look in the new + default location, or set ``master_pid_file`` in :cyrusman:`imapd.conf(5)` + to override the default. The ``-p`` option to :cyrusman:`master(8)` + can still be used to override it + +Security fixes +============== + +* Fixed CVE-2024-34055_: + Cyrus-IMAP through 3.8.2 and 3.10.0-beta2 allow authenticated attackers + to cause unbounded memory allocation by sending many LITERALs in a + single command. + + The IMAP protocol allows for command arguments to be LITERALs of + negotiated length, and for these the server allocates memory to + receive the content before instructing the client to proceed. The + allocated memory is released when the whole command has been received + and processed. + + The IMAP protocol has a number commands that specify an unlimited + number of arguments, for example SEARCH. Each of these arguments can + be a LITERAL, for which memory will be allocated and not released + until the entire command has been received and processed. This can run + a server out of memory, with varying consequences depending on the + server's OOM policy. + + Discovered by Damian Poddebniak. + + Two limits, with corresponding :cyrusman:`imapd.conf(5)` options, have + been added to address this: + + * ``maxargssize`` (default: unlimited): limits the overall length of a + single IMAP command. Deployments should configure this to a size that + suits their system resources and client usage patterns + * ``maxliteral`` (default: 128K): limits the length of individual IMAP + LITERALs + + Connections sending commands that would exceed these limits will see the + command fail, or the connection closed, depending on the specific context. + The error message will contain the ``[TOOBIG]`` response code. + + These limits may be set small without affecting message uploads, as the + APPEND command's message literal is limited by ``maxmessagesize``, not by + these new options. + +.. _CVE-2024-34055: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-34055 + +Significant bugfixes +==================== + +* Fixed: squat db reindexes are no longer always incremental +* Fixed: squat db corruption from unintentional indexing of fields + intended to be skipped +* Fixed: squat db out of bounds access in incremental reindex docID map +* Fixed :issue:`4692`: squat db searches now handle unindexed messages + correctly again (thanks Gabriele Bulfon) +* Restored functionality of the sync_client ``-o``/``--connect-once`` option +* Fixed :issue:`4654`: copying/moving messages from split conversations is now + correct +* Fixed :issue:`4758`: fix renaming mailbox between users +* Fixed :issue:`4804`: mailbox_maxmessages limits now applied correctly diff --git a/docsrc/imap/download/release-notes/3.10/x/3.10.0-rc2.rst b/docsrc/imap/download/release-notes/3.10/x/3.10.0-rc2.rst new file mode 100644 index 0000000000..5c293b2e19 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.10/x/3.10.0-rc2.rst @@ -0,0 +1,161 @@ +:tocdepth: 3 + +===================================== +Cyrus IMAP 3.10.0-rc2 Release Notes +===================================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-rc2/cyrus-imapd-3.10.0-rc2.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0-rc2/cyrus-imapd-3.10.0-rc2.tar.gz.sig + +.. _relnotes-3.10.0-rc2_changes: + +Major changes since the 3.8 series +================================== + +* URLs found in HTML ````, ```` and ```` tags, as well as "alt" + text in ```` tags, are now indexed for search and snippets +* :cyrusman:`cyr_expire(8)` now supports non-day durations in the + archive/delete/expire annotations +* :cyrusman:`cyr_expire(8)` no longer supports fractional durations in command + line arguments. Installations that passed fractional durations such as + "1.5d" to any of the ``-E``, ``-X``, ``-D``, or ``-A`` arguments must adapt + these to only use integer durations such as "1d12h" +* :cyrusman:`cyr_expire(8)` now supports the 'noexpire_until' annotation to + disable cyr_expire per user +* JMAP calendar default alarms are now stored in a non-DAV mailbox annotation. + See :ref:`upgrade_jmap_default_alarms` for upgrading instructions if you are + already using the experimental JMAP Calendars API +* Removes support for parsing and generating bytecode for the deprecated + denotify action and notify actions using the legacy (pre-:rfc:`5435`) syntax. + Existing bytecode containing these actions will still be executed. Scripts + that contain the deprecated denotify action should be rewritten to remove + them. Scripts that contain notify actions using the legacy syntax should be + rewritten to use the syntax in :rfc:`5435` +* Adds support for the "exp" and "nbf" JSON Web Token claims. Thanks Bruno + Thomas +* Adds support for IMAP Version 4rev2 (:rfc:`9051`) +* Adds support for IMAP NOTIFY (:rfc:`5465`). Only available if ``idled`` is + running +* Refresh interval for APNS subscriptions to DAV resources is now configurable. + See the ``aps_expiry`` :cyrusman:`imapd.conf(5)` option +* Upgrade IMAP Quota support to :rfc:`9208`. Sites running a Murder will be + unable to set ANNOTATION-STORAGE or MAILBOX quotas (formerly known as + X-ANNOTATION-STORAGE and X-NUM_FOLDERS) in a mixed-version environment until + frontends are upgraded. Upgraded frontends know how to negotiate with older + backends. +* Adds support for IMAP REPLACE (:rfc:`8508`) +* Adds support for IMAP UIDONLY extension (:draft:`draft-ietf-extra-imap-uidonly`) +* Adds experimental support for JMAP Contacts per upcoming IETF standards. + Requires the not-yet-released + `libicalvcard `_ +* :cyrusman:`squatter(8)` now supports the "wait=y" :cyrusman:`cyrus.conf(5)` + option when started in rolling mode from the ``DAEMON`` section +* :cyrusman:`master(8)` now touches a ready file to indicate it is "ready for + work". See :ref:`upgrade_master_pid_ready_files` +* :cyrusman:`master(8)` now gets its pidfile name from the ``master_pid_file`` + :cyrusman:`imapd.conf(5)` option. See :ref:`upgrade_master_pid_ready_files` +* Adds pcre2 support. Prefers pcre2 over pcre if both are available. See + :ref:`upgrade_pcre2_support` +* The ``proc`` :cyrusman:`cyr_info(8)` subcommand now also reports DAEMON and + EVENTS processes +* JMAP CalendarEventNotification objects are now automatically pruned. + The ``jmap_max_calendareventnotifs`` :cyrusman:`imapd.conf(5)` option can be + used to tune this behaviour +* Cyrus now requires libical >= 3.0.10 for HTTP support +* Sieve [current]date ``:zone`` parameter now accepts either a UTC offset or an + IANA time zone ID +* Adds an ``implicit_keep_target`` Sieve action to change the target mailbox + for an implicit keep +* :cyrusman:`squatter(8)` no longer holds a mailbox lock while extracting text + from attachments +* IMAP ``RENAME`` command no longer emits non-standard per-folder updates. Use + the new ``XRENAME`` command if you need this behaviour +* Outgoing SMTP connections now EHLO as `client_bind_name` or `servername` + rather than `localhost` +* Deprecates the experimental Cyrus Backups feature + +.. _relnotes_3.10.0-beta2_storage_changes: + +Storage changes +=============== + +* None in 3.10. But if your upgrade is skipping over 3.6 and 3.8, please do + not miss :ref:`3.6.0 Storage changes ` + and :ref:`3.8.0 Storage changes ` + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The master pidfile name is now read from imapd.conf, and defaults + to ``{configdirectory}/master.pid``. If you have something that + looks for this file, you should either update it to look in the new + default location, or set ``master_pid_file`` in :cyrusman:`imapd.conf(5)` + to override the default. The ``-p`` option to :cyrusman:`master(8)` + can still be used to override it + +Security fixes +============== + +* Fixed CVE-2024-34055_: + Cyrus-IMAP through 3.8.2 and 3.10.0-beta2 allow authenticated attackers + to cause unbounded memory allocation by sending many LITERALs in a + single command. + + The IMAP protocol allows for command arguments to be LITERALs of + negotiated length, and for these the server allocates memory to + receive the content before instructing the client to proceed. The + allocated memory is released when the whole command has been received + and processed. + + The IMAP protocol has a number commands that specify an unlimited + number of arguments, for example SEARCH. Each of these arguments can + be a LITERAL, for which memory will be allocated and not released + until the entire command has been received and processed. This can run + a server out of memory, with varying consequences depending on the + server's OOM policy. + + Discovered by Damian Poddebniak. + + Two limits, with corresponding :cyrusman:`imapd.conf(5)` options, have + been added to address this: + + * ``maxargssize`` (default: unlimited): limits the overall length of a + single IMAP command. Deployments should configure this to a size that + suits their system resources and client usage patterns + * ``maxliteral`` (default: 128K): limits the length of individual IMAP + LITERALs + + Connections sending commands that would exceed these limits will see the + command fail, or the connection closed, depending on the specific context. + The error message will contain the ``[TOOBIG]`` response code. + + These limits may be set small without affecting message uploads, as the + APPEND command's message literal is limited by ``maxmessagesize``, not by + these new options. + +.. _CVE-2024-34055: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-34055 + +Significant bugfixes +==================== + +* Fixed: squat db reindexes are no longer always incremental +* Fixed: squat db corruption from unintentional indexing of fields + intended to be skipped +* Fixed: squat db out of bounds access in incremental reindex docID map +* Fixed :issue:`4692`: squat db searches now handle unindexed messages + correctly again (thanks Gabriele Bulfon) +* Restored functionality of the sync_client ``-o``/``--connect-once`` option +* Fixed :issue:`4654`: copying/moving messages from split conversations is now + correct +* Fixed :issue:`4758`: fix renaming mailbox between users +* Fixed :issue:`4804`: mailbox_maxmessages limits now applied correctly +* Fixed :issue:`4932`: LITERAL+ broken in mupdate diff --git a/docsrc/imap/download/release-notes/3.10/x/3.10.0.rst b/docsrc/imap/download/release-notes/3.10/x/3.10.0.rst new file mode 100644 index 0000000000..459b26cf63 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.10/x/3.10.0.rst @@ -0,0 +1,161 @@ +:tocdepth: 3 + +===================================== +Cyrus IMAP 3.10.0 Release Notes +===================================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0/cyrus-imapd-3.10.0.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.10.0/cyrus-imapd-3.10.0.tar.gz.sig + +.. _relnotes-3.10.0_changes: + +Major changes since the 3.8 series +================================== + +* URLs found in HTML ````, ```` and ```` tags, as well as "alt" + text in ```` tags, are now indexed for search and snippets +* :cyrusman:`cyr_expire(8)` now supports non-day durations in the + archive/delete/expire annotations +* :cyrusman:`cyr_expire(8)` no longer supports fractional durations in command + line arguments. Installations that passed fractional durations such as + "1.5d" to any of the ``-E``, ``-X``, ``-D``, or ``-A`` arguments must adapt + these to only use integer durations such as "1d12h" +* :cyrusman:`cyr_expire(8)` now supports the 'noexpire_until' annotation to + disable cyr_expire per user +* JMAP calendar default alarms are now stored in a non-DAV mailbox annotation. + See :ref:`upgrade_jmap_default_alarms` for upgrading instructions if you are + already using the experimental JMAP Calendars API +* Removes support for parsing and generating bytecode for the deprecated + denotify action and notify actions using the legacy (pre-:rfc:`5435`) syntax. + Existing bytecode containing these actions will still be executed. Scripts + that contain the deprecated denotify action should be rewritten to remove + them. Scripts that contain notify actions using the legacy syntax should be + rewritten to use the syntax in :rfc:`5435` +* Adds support for the "exp" and "nbf" JSON Web Token claims. Thanks Bruno + Thomas +* Adds support for IMAP Version 4rev2 (:rfc:`9051`) +* Adds support for IMAP NOTIFY (:rfc:`5465`). Only available if ``idled`` is + running +* Refresh interval for APNS subscriptions to DAV resources is now configurable. + See the ``aps_expiry`` :cyrusman:`imapd.conf(5)` option +* Upgrade IMAP Quota support to :rfc:`9208`. Sites running a Murder will be + unable to set ANNOTATION-STORAGE or MAILBOX quotas (formerly known as + X-ANNOTATION-STORAGE and X-NUM_FOLDERS) in a mixed-version environment until + frontends are upgraded. Upgraded frontends know how to negotiate with older + backends. +* Adds support for IMAP REPLACE (:rfc:`8508`) +* Adds support for IMAP UIDONLY extension (:draft:`draft-ietf-extra-imap-uidonly`) +* Adds experimental support for JMAP Contacts per upcoming IETF standards. + Requires the not-yet-released + `libicalvcard `_ +* :cyrusman:`squatter(8)` now supports the "wait=y" :cyrusman:`cyrus.conf(5)` + option when started in rolling mode from the ``DAEMON`` section +* :cyrusman:`master(8)` now touches a ready file to indicate it is "ready for + work". See :ref:`upgrade_master_pid_ready_files` +* :cyrusman:`master(8)` now gets its pidfile name from the ``master_pid_file`` + :cyrusman:`imapd.conf(5)` option. See :ref:`upgrade_master_pid_ready_files` +* Adds pcre2 support. Prefers pcre2 over pcre if both are available. See + :ref:`upgrade_pcre2_support` +* The ``proc`` :cyrusman:`cyr_info(8)` subcommand now also reports DAEMON and + EVENTS processes +* JMAP CalendarEventNotification objects are now automatically pruned. + The ``jmap_max_calendareventnotifs`` :cyrusman:`imapd.conf(5)` option can be + used to tune this behaviour +* Cyrus now requires libical >= 3.0.10 for HTTP support +* Sieve [current]date ``:zone`` parameter now accepts either a UTC offset or an + IANA time zone ID +* Adds an ``implicit_keep_target`` Sieve action to change the target mailbox + for an implicit keep +* :cyrusman:`squatter(8)` no longer holds a mailbox lock while extracting text + from attachments +* IMAP ``RENAME`` command no longer emits non-standard per-folder updates. Use + the new ``XRENAME`` command if you need this behaviour +* Outgoing SMTP connections now EHLO as `client_bind_name` or `servername` + rather than `localhost` +* Deprecates the experimental Cyrus Backups feature + +.. _relnotes_3.10.0_storage_changes: + +Storage changes +=============== + +* None in 3.10. But if your upgrade is skipping over 3.6 and 3.8, please do + not miss :ref:`3.6.0 Storage changes ` + and :ref:`3.8.0 Storage changes ` + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The master pidfile name is now read from imapd.conf, and defaults + to ``{configdirectory}/master.pid``. If you have something that + looks for this file, you should either update it to look in the new + default location, or set ``master_pid_file`` in :cyrusman:`imapd.conf(5)` + to override the default. The ``-p`` option to :cyrusman:`master(8)` + can still be used to override it + +Security fixes +============== + +* Fixed CVE-2024-34055_: + Cyrus-IMAP through 3.8.2 and 3.10.0-beta2 allow authenticated attackers + to cause unbounded memory allocation by sending many LITERALs in a + single command. + + The IMAP protocol allows for command arguments to be LITERALs of + negotiated length, and for these the server allocates memory to + receive the content before instructing the client to proceed. The + allocated memory is released when the whole command has been received + and processed. + + The IMAP protocol has a number commands that specify an unlimited + number of arguments, for example SEARCH. Each of these arguments can + be a LITERAL, for which memory will be allocated and not released + until the entire command has been received and processed. This can run + a server out of memory, with varying consequences depending on the + server's OOM policy. + + Discovered by Damian Poddebniak. + + Two limits, with corresponding :cyrusman:`imapd.conf(5)` options, have + been added to address this: + + * ``maxargssize`` (default: unlimited): limits the overall length of a + single IMAP command. Deployments should configure this to a size that + suits their system resources and client usage patterns + * ``maxliteral`` (default: 128K): limits the length of individual IMAP + LITERALs + + Connections sending commands that would exceed these limits will see the + command fail, or the connection closed, depending on the specific context. + The error message will contain the ``[TOOBIG]`` response code. + + These limits may be set small without affecting message uploads, as the + APPEND command's message literal is limited by ``maxmessagesize``, not by + these new options. + +.. _CVE-2024-34055: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-34055 + +Significant bugfixes +==================== + +* Fixed: squat db reindexes are no longer always incremental +* Fixed: squat db corruption from unintentional indexing of fields + intended to be skipped +* Fixed: squat db out of bounds access in incremental reindex docID map +* Fixed :issue:`4692`: squat db searches now handle unindexed messages + correctly again (thanks Gabriele Bulfon) +* Restored functionality of the sync_client ``-o``/``--connect-once`` option +* Fixed :issue:`4654`: copying/moving messages from split conversations is now + correct +* Fixed :issue:`4758`: fix renaming mailbox between users +* Fixed :issue:`4804`: mailbox_maxmessages limits now applied correctly +* Fixed :issue:`4932`: LITERAL+ broken in mupdate diff --git a/docsrc/imap/download/release-notes/3.11/index.rst b/docsrc/imap/download/release-notes/3.11/index.rst new file mode 100644 index 0000000000..4d7125ad53 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.11/index.rst @@ -0,0 +1,21 @@ +.. _imap-release-notes-3.11: + +==================== +Cyrus IMAP 3.11 Tags +==================== + +.. warning:: + + The 3.11 series are tagged snapshots of the master branch, and should be + considered for **testing purposes** and **bleeding-edge features** only. + We will try to tag these snapshots at coherent development points, but + there will generally be **large breaking changes** occurring between + releases in this series. + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.??.?-alpha* + x/?.??.? + x/?.??.?? diff --git a/docsrc/imap/download/release-notes/3.11/x/3.11.0-alpha0.rst b/docsrc/imap/download/release-notes/3.11/x/3.11.0-alpha0.rst new file mode 100644 index 0000000000..c2c6677c05 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.11/x/3.11.0-alpha0.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +================================== +Cyrus IMAP 3.11.0-alpha0 Tag Notes +================================== + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to + tag these snapshots at coherent development points, but there will + generally be **large breaking changes** occurring between releases in this + series. + +.. _relnotes-3.11.0-alpha0-changes: + +Major changes since the 3.10 series +=================================== + +* None yet! + +Updates to default configuration +================================ + +* None yet! + +Security fixes +============== + +* None yet! + +Significant bugfixes +==================== + +* None yet! diff --git a/docsrc/imap/download/release-notes/3.2/index.rst b/docsrc/imap/download/release-notes/3.2/index.rst new file mode 100644 index 0000000000..bf4f40f316 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/index.rst @@ -0,0 +1,14 @@ +.. _imap-release-notes-3.2: + +============================= +Cyrus IMAP 3.2 Releases +============================= + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.?.*-beta* + x/?.?.*-rc* + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.0-beta3.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.0-beta3.rst new file mode 100644 index 0000000000..7925c5cfd0 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.0-beta3.rst @@ -0,0 +1,123 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.2.0-beta3 Release Notes +==================================== + +.. WARNING:: + This is a beta release and may contain fun bugs. Testing it + out and providing feedback would be greatly appreciated, but do + not run this on your production mail stores! + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.0-beta3/cyrus-imapd-3.2.0-beta3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.0-beta3/cyrus-imapd-3.2.0-beta3.tar.gz.sig + +.. _relnotes-3.2.0-beta3-changes: + +Major changes since the 3.0.x series +==================================== + +* Sieve bug fixes and features +* Replication safety improvements +* Caldav and Carddav improvements +* Support for JMAP core protocol (:rfc:`8620`) +* Support for JMAP Mail (:rfc:`8621`) +* Experimental support for JMAP Contacts (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`) +* Experimental support for JMAP Calendars (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`) +* Xapian bug fixes +* Improvements to Annotations handling +* DRAC support has been deprecated +* Support for Prometheus stats +* SNMP stats support has been deprecated +* Removed support for the Sphinx backend to squatter searches +* New cyrus.index format v16 included since 3.1.5 - adds unseen count and + createdmodseq to index header, savedate and createdmodseq to index records +* Support for WebSockets +* Support for HTTP/2.0 +* Experimental support for Zeroskip database format +* Intermediate mailboxes are now recorded in mailboxes database +* Conversations database format update - adds flags and internaldate fields, + and is now versioned for future-compatibility. You will need to rebuild + your conversations databases with :cyrusman:`ctl_conversationsdb(8)` and + the `-b` switch to benefit from this +* IMAP FETCH accepts two new data items, MAILBOXIDS and MAILBOXES, which + respectively return the unique ids or names of the containing mailboxes of + each message in the sequence (for best performance, rebuild your + conversations databases as above) +* :cyrusman:`mbpath(8)` is now much more useful +* Twoskip database format now supports shared locks, and ensures record + headers do not span disk block boundaries +* All Cyrus binaries now use real sysexits exit codes instead of mapping + nearly everything to EX_TEMPFAIL +* CyrusDB errors now syslog the actual error instead of just "cyrusdb error" +* New `allowdeleted` :cyrusman:`imapd.conf(5)` option (default off), which + allows admin users to see deleted mailboxes and expunged messages over IMAP +* :cyrusman:`cyr_virusscan(8)` now supports custom templates for notifications + sent about infected messages that have been deleted +* :cyrusman:`imapd.conf(5)` options that represent a time duration now accept + 'd', 'h', 'm', 's' suffixes rather than arbitrary units +* The `tls_server_cert` and `tls_server_key` :cyrusman:`imapd.conf(5)` options + now allow two certificate/key pairs (e.g. RSA and EC) to be used. Thanks + Дилян Палаузов +* Mailbox create/delete/rename are now performed under a lock on the user's + namespace, to prevent races (especially during big renames) +* The :cyrusman:`cyr_info(8)` `conf-lint` subcommand no longer complains + about channel-prefixed sync options +* New `master_bind_errors_fatal` :cyrusman:`imapd.conf(5)` option (default + off), with which master will refuse to start if any of the configured + services are unable to successfully bind their port. The default and legacy + behaviour is for master to start with the affected services disabled, and + not try to start them again until a SIGHUP is received +* New `autocreate_acl` :cyrusman:`imapd.conf(5)` option, for specifying ACLs + to use when mailboxes are created by `autocreate_inbox_folders` +* New `zoneinfo_dir` :cyrusman:`imapd.conf(5)` option, for specifying the + directory Cyrus should look for timezone definitions in. The default is + to let libical find them itself. If the `tzdist` http module is enabled, + this option is mandatory. + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +now accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to re-examine and maybe change +during the process. + +* The `specialusealways` option is now enabled by default. It must + explicitly be disabled for interoperability with legacy clients that + can't handle RFC 6154 attributes in extended LIST commands. +* The values accepted by `expunge_mode` have changed, please see the + documentation for more information about the changes. +* The legacy GETANNOTATIONS/SETANNOTATIONS IMAP commands will no longer + work unless `annotation_enable_legacy_commands` is enabled. +* The `outbox_sendlater` option and its functionality have been removed. +* The `tzdist` http module now finds its timezone data directory according + to the new `zoneinfo_dir` :cyrusman:`imapd.conf(5)` option, instead of + being hardcoded to "{configdirectory}/zoneinfo". If you are using this + module, you MUST now set this option explicitly. Calendaring services + will use the same timezone definitions. + + +Security fixes +============== + +* Contains fix for `CVE-2017-14230 `_ +* Contains fix for `CVE-2019-18928 `_ +* Contains fix for `CVE-2019-19783 `_ + + +Significant bugfixes +==================== + +* Contains fix for :issue:`2839` + + +.. _Xapian: https://xapian.org +.. _ClamAV: https://www.clamav.net +.. _JMAP: http://jmap.io diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.0-beta4.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.0-beta4.rst new file mode 100644 index 0000000000..cfbfb347c0 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.0-beta4.rst @@ -0,0 +1,126 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.2.0-beta4 Release Notes +==================================== + +.. WARNING:: + This is a beta release and may contain fun bugs. Testing it + out and providing feedback would be greatly appreciated, but do + not run this on your production mail stores! + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.0-beta4/cyrus-imapd-3.2.0-beta4.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.0-beta4/cyrus-imapd-3.2.0-beta4.tar.gz.sig + +.. _relnotes-3.2.0-beta4-changes: + +Major changes since the 3.0.x series +==================================== + +* Sieve bug fixes and features +* Replication safety improvements +* Caldav and Carddav improvements +* Support for JMAP core protocol (:rfc:`8620`) +* Support for JMAP Mail (:rfc:`8621`) +* Experimental support for JMAP Contacts (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`) +* Experimental support for JMAP Calendars (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`) +* Xapian bug fixes +* Improvements to Annotations handling +* DRAC support has been deprecated +* Support for Prometheus stats +* SNMP stats support has been deprecated +* Removed support for the Sphinx backend to squatter searches +* New cyrus.index format v16 included since 3.1.5 - adds unseen count and + createdmodseq to index header, savedate and createdmodseq to index records +* Support for WebSockets +* Support for HTTP/2.0 +* Experimental support for Zeroskip database format +* Intermediate mailboxes are now recorded in mailboxes database +* Conversations database format update - adds flags and internaldate fields, + and is now versioned for future-compatibility. You will need to rebuild + your conversations databases with :cyrusman:`ctl_conversationsdb(8)` and + the `-b` switch to benefit from this +* IMAP FETCH accepts two new data items, MAILBOXIDS and MAILBOXES, which + respectively return the unique ids or names of the containing mailboxes of + each message in the sequence (for best performance, rebuild your + conversations databases as above) +* :cyrusman:`mbpath(8)` is now much more useful +* Twoskip database format now supports shared locks, and ensures record + headers do not span disk block boundaries +* All Cyrus binaries now use real sysexits exit codes instead of mapping + nearly everything to EX_TEMPFAIL +* CyrusDB errors now syslog the actual error instead of just "cyrusdb error" +* New `allowdeleted` :cyrusman:`imapd.conf(5)` option (default off), which + allows admin users to see deleted mailboxes and expunged messages over IMAP +* :cyrusman:`cyr_virusscan(8)` now supports custom templates for notifications + sent about infected messages that have been deleted +* :cyrusman:`imapd.conf(5)` options that represent a time duration now accept + 'd', 'h', 'm', 's' suffixes rather than arbitrary units +* The `tls_server_cert` and `tls_server_key` :cyrusman:`imapd.conf(5)` options + now allow two certificate/key pairs (e.g. RSA and EC) to be used. Thanks + Дилян Палаузов +* Mailbox create/delete/rename are now performed under a lock on the user's + namespace, to prevent races (especially during big renames) +* The :cyrusman:`cyr_info(8)` `conf-lint` subcommand no longer complains + about channel-prefixed sync options +* New `master_bind_errors_fatal` :cyrusman:`imapd.conf(5)` option (default + off), with which master will refuse to start if any of the configured + services are unable to successfully bind their port. The default and legacy + behaviour is for master to start with the affected services disabled, and + not try to start them again until a SIGHUP is received +* New `autocreate_acl` :cyrusman:`imapd.conf(5)` option, for specifying ACLs + to use when mailboxes are created by `autocreate_inbox_folders` +* New `zoneinfo_dir` :cyrusman:`imapd.conf(5)` option, for specifying the + directory Cyrus should look for timezone definitions in. The default is + to let libical find them itself. If the `tzdist` http module is enabled, + this option is mandatory. +* The iso-8859-1 charset is now treated as an alias for windows-1252, as per + `WHATWG Encoding for emails and websites + `_ + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +now accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to re-examine and maybe change +during the process. + +* The `specialusealways` option is now enabled by default. It must + explicitly be disabled for interoperability with legacy clients that + can't handle RFC 6154 attributes in extended LIST commands. +* The values accepted by `expunge_mode` have changed, please see the + documentation for more information about the changes. +* The legacy GETANNOTATIONS/SETANNOTATIONS IMAP commands will no longer + work unless `annotation_enable_legacy_commands` is enabled. +* The `outbox_sendlater` option and its functionality have been removed. +* The `tzdist` http module now finds its timezone data directory according + to the new `zoneinfo_dir` :cyrusman:`imapd.conf(5)` option, instead of + being hardcoded to "{configdirectory}/zoneinfo". If you are using this + module, you MUST now set this option explicitly. Calendaring services + will use the same timezone definitions. + + +Security fixes +============== + +* Contains fix for `CVE-2017-14230 `_ +* Contains fix for `CVE-2019-18928 `_ +* Contains fix for `CVE-2019-19783 `_ + + +Significant bugfixes +==================== + +* Contains fix for :issue:`2839` + + +.. _Xapian: https://xapian.org +.. _ClamAV: https://www.clamav.net +.. _JMAP: http://jmap.io diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.0-rc1.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.0-rc1.rst new file mode 100644 index 0000000000..125742d807 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.0-rc1.rst @@ -0,0 +1,126 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.2.0-rc1 Release Notes +==================================== + +.. WARNING:: + This is a beta release and may contain fun bugs. Testing it + out and providing feedback would be greatly appreciated, but do + not run this on your production mail stores! + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.0-rc1/cyrus-imapd-3.2.0-rc1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.0-rc1/cyrus-imapd-3.2.0-rc1.tar.gz.sig + +.. _relnotes-3.2.0-rc1-changes: + +Major changes since the 3.0.x series +==================================== + +* Sieve bug fixes and features +* Replication safety improvements +* Caldav and Carddav improvements +* Support for JMAP core protocol (:rfc:`8620`) +* Support for JMAP Mail (:rfc:`8621`) +* Experimental support for JMAP Contacts (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`) +* Experimental support for JMAP Calendars (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`) +* Xapian bug fixes +* Improvements to Annotations handling +* DRAC support has been deprecated +* Support for Prometheus stats +* SNMP stats support has been deprecated +* Removed support for the Sphinx backend to squatter searches +* New cyrus.index format v16 included since 3.1.5 - adds unseen count and + createdmodseq to index header, savedate and createdmodseq to index records +* Support for WebSockets +* Support for HTTP/2.0 +* Experimental support for Zeroskip database format +* Intermediate mailboxes are now recorded in mailboxes database +* Conversations database format update - adds flags and internaldate fields, + and is now versioned for future-compatibility. You will need to rebuild + your conversations databases with :cyrusman:`ctl_conversationsdb(8)` and + the `-b` switch to benefit from this +* IMAP FETCH accepts two new data items, MAILBOXIDS and MAILBOXES, which + respectively return the unique ids or names of the containing mailboxes of + each message in the sequence (for best performance, rebuild your + conversations databases as above) +* :cyrusman:`mbpath(8)` is now much more useful +* Twoskip database format now supports shared locks, and ensures record + headers do not span disk block boundaries +* All Cyrus binaries now use real sysexits exit codes instead of mapping + nearly everything to EX_TEMPFAIL +* CyrusDB errors now syslog the actual error instead of just "cyrusdb error" +* New `allowdeleted` :cyrusman:`imapd.conf(5)` option (default off), which + allows admin users to see deleted mailboxes and expunged messages over IMAP +* :cyrusman:`cyr_virusscan(8)` now supports custom templates for notifications + sent about infected messages that have been deleted +* :cyrusman:`imapd.conf(5)` options that represent a time duration now accept + 'd', 'h', 'm', 's' suffixes rather than arbitrary units +* The `tls_server_cert` and `tls_server_key` :cyrusman:`imapd.conf(5)` options + now allow two certificate/key pairs (e.g. RSA and EC) to be used. Thanks + Дилян Палаузов +* Mailbox create/delete/rename are now performed under a lock on the user's + namespace, to prevent races (especially during big renames) +* The :cyrusman:`cyr_info(8)` `conf-lint` subcommand no longer complains + about channel-prefixed sync options +* New `master_bind_errors_fatal` :cyrusman:`imapd.conf(5)` option (default + off), with which master will refuse to start if any of the configured + services are unable to successfully bind their port. The default and legacy + behaviour is for master to start with the affected services disabled, and + not try to start them again until a SIGHUP is received +* New `autocreate_acl` :cyrusman:`imapd.conf(5)` option, for specifying ACLs + to use when mailboxes are created by `autocreate_inbox_folders` +* New `zoneinfo_dir` :cyrusman:`imapd.conf(5)` option, for specifying the + directory Cyrus should look for timezone definitions in. The default is + to let libical find them itself. If the `tzdist` http module is enabled, + this option is mandatory. +* The iso-8859-1 charset is now treated as an alias for windows-1252, as per + `WHATWG Encoding for emails and websites + `_ + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +now accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to re-examine and maybe change +during the process. + +* The `specialusealways` option is now enabled by default. It must + explicitly be disabled for interoperability with legacy clients that + can't handle RFC 6154 attributes in extended LIST commands. +* The values accepted by `expunge_mode` have changed, please see the + documentation for more information about the changes. +* The legacy GETANNOTATIONS/SETANNOTATIONS IMAP commands will no longer + work unless `annotation_enable_legacy_commands` is enabled. +* The `outbox_sendlater` option and its functionality have been removed. +* The `tzdist` http module now finds its timezone data directory according + to the new `zoneinfo_dir` :cyrusman:`imapd.conf(5)` option, instead of + being hardcoded to "{configdirectory}/zoneinfo". If you are using this + module, you MUST now set this option explicitly. Calendaring services + will use the same timezone definitions. + + +Security fixes +============== + +* Contains fix for `CVE-2017-14230 `_ +* Contains fix for `CVE-2019-18928 `_ +* Contains fix for `CVE-2019-19783 `_ + + +Significant bugfixes +==================== + +* Contains fix for :issue:`2839` + + +.. _Xapian: https://xapian.org +.. _ClamAV: https://www.clamav.net +.. _JMAP: http://jmap.io diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.0.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.0.rst new file mode 100644 index 0000000000..6a8f1ff756 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.0.rst @@ -0,0 +1,122 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.2.0 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.0/cyrus-imapd-3.2.0.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.0/cyrus-imapd-3.2.0.tar.gz.sig + +.. _relnotes-3.2.0-changes: + +Major changes since the 3.0.x series +==================================== + +* Sieve bug fixes and features +* Replication safety improvements +* Caldav and Carddav improvements +* Support for JMAP core protocol (:rfc:`8620`) +* Support for JMAP Mail (:rfc:`8621`) +* Experimental support for JMAP Contacts (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`) +* Experimental support for JMAP Calendars (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`) +* Xapian bug fixes +* Improvements to Annotations handling +* DRAC support has been deprecated +* Support for Prometheus stats +* SNMP stats support has been deprecated +* Removed support for the Sphinx backend to squatter searches +* New cyrus.index format v16 included since 3.1.5 - adds unseen count and + createdmodseq to index header, savedate and createdmodseq to index records +* Support for WebSockets +* Support for HTTP/2.0 +* Support for SCRAM authentication for httpd +* Experimental support for Zeroskip database format +* Intermediate mailboxes are now recorded in mailboxes database +* Conversations database format update - adds flags and internaldate fields, + and is now versioned for future-compatibility. You will need to rebuild + your conversations databases with :cyrusman:`ctl_conversationsdb(8)` and + the `-b` switch to benefit from this +* IMAP FETCH accepts two new data items, MAILBOXIDS and MAILBOXES, which + respectively return the unique ids or names of the containing mailboxes of + each message in the sequence (for best performance, rebuild your + conversations databases as above) +* :cyrusman:`mbpath(8)` is now much more useful +* Twoskip database format now supports shared locks, and ensures record + headers do not span disk block boundaries +* All Cyrus binaries now use real sysexits exit codes instead of mapping + nearly everything to EX_TEMPFAIL +* CyrusDB errors now syslog the actual error instead of just "cyrusdb error" +* New `allowdeleted` :cyrusman:`imapd.conf(5)` option (default off), which + allows admin users to see deleted mailboxes and expunged messages over IMAP +* :cyrusman:`cyr_virusscan(8)` now supports custom templates for notifications + sent about infected messages that have been deleted +* :cyrusman:`imapd.conf(5)` options that represent a time duration now accept + 'd', 'h', 'm', 's' suffixes rather than arbitrary units +* The `tls_server_cert` and `tls_server_key` :cyrusman:`imapd.conf(5)` options + now allow two certificate/key pairs (e.g. RSA and EC) to be used. Thanks + Дилян Палаузов +* Mailbox create/delete/rename are now performed under a lock on the user's + namespace, to prevent races (especially during big renames) +* The :cyrusman:`cyr_info(8)` `conf-lint` subcommand no longer complains + about channel-prefixed sync options +* New `master_bind_errors_fatal` :cyrusman:`imapd.conf(5)` option (default + off), with which master will refuse to start if any of the configured + services are unable to successfully bind their port. The default and legacy + behaviour is for master to start with the affected services disabled, and + not try to start them again until a SIGHUP is received +* New `autocreate_acl` :cyrusman:`imapd.conf(5)` option, for specifying ACLs + to use when mailboxes are created by `autocreate_inbox_folders` +* New `zoneinfo_dir` :cyrusman:`imapd.conf(5)` option, for specifying the + directory Cyrus should look for timezone definitions in. The default is + to let libical find them itself. If the `tzdist` http module is enabled, + this option is mandatory. +* The iso-8859-1 charset is now treated as an alias for windows-1252, as per + `WHATWG Encoding for emails and websites + `_ + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +now accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to re-examine and maybe change +during the process. + +* The `specialusealways` option is now enabled by default. It must + explicitly be disabled for interoperability with legacy clients that + can't handle RFC 6154 attributes in extended LIST commands. +* The values accepted by `expunge_mode` have changed, please see the + documentation for more information about the changes. +* The legacy GETANNOTATIONS/SETANNOTATIONS IMAP commands will no longer + work unless `annotation_enable_legacy_commands` is enabled. +* The `outbox_sendlater` option and its functionality have been removed. +* The `tzdist` http module now finds its timezone data directory according + to the new `zoneinfo_dir` :cyrusman:`imapd.conf(5)` option, instead of + being hardcoded to "{configdirectory}/zoneinfo". If you are using this + module, you MUST now set this option explicitly. Calendaring services + will use the same timezone definitions. + + +Security fixes +============== + +* Contains fix for `CVE-2017-14230 `_ +* Contains fix for `CVE-2019-18928 `_ +* Contains fix for `CVE-2019-19783 `_ + + +Significant bugfixes +==================== + +* Contains fix for :issue:`2839` and :issue:`2854`. + + +.. _Xapian: https://xapian.org +.. _ClamAV: https://www.clamav.net +.. _JMAP: http://jmap.io diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.1.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.1.rst new file mode 100644 index 0000000000..9b26176551 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.1.rst @@ -0,0 +1,65 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.1 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.1/cyrus-imapd-3.2.1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.1/cyrus-imapd-3.2.1.tar.gz.sig + +.. _relnotes-3.2.1-changes: + +Changes since 3.2.0 +=================== + +Build changes +------------- + +* Fixed: build failed on non-x86 platforms (thanks John Paul Adrian Glaubitz) +* Fixed: configure now warns if your `time_t` type is only 32-bit +* Fixed :issue:`3038`: ICU 55 or newer is required (thanks Дилян Палаузов) + +Removed experimental features +----------------------------- + +The following features were incomplete and not ready for use, but snuck in +well before the 3.2 feature freeze. They have been removed in 3.2.1, and +their corresponding :cyrusman:`imapd.conf(5)` options have been deprecated: + +* per-language Xapian indexing and searches for multilingual mailboxes +* separate Xapian indexing and searches of MIME parts/attachments + +Bug fixes +--------- + +* Fixed :issue:`3036`: python2 detection was bad +* Fixed :issue:`2470`: cunit tests used expired certs with tiny keys +* Fixed :issue:`3039`: stats were miscounted on tls connection failure +* Fixed :issue:`3035`: lmtpd aborted on connect when FORTIFY_SOURCE enabled +* Fixed: some cunit tests assumed 64-bit `time_t` without checking +* Fixed: imapd returned OK for slow or cancelled SEARCH requests +* Fixed: mislabelled ISO-2022-JP email bodies now detected +* Fixed: JMAP over-quota errors were reported as `serverFail` rather than + `overQuota` +* Fixed :issue:`3049`: :cyrusman:`promstatsd(8)` had no man page +* Fixed :issue:`3046`: httpd did not declare environ correctly (thanks + OBATA Akio) +* Fixed: cunit timeout infrastructure was broken on big-endian systems +* Fixed: crashes on non-x86 platforms due to unaligned memory accesses + (thanks Xavier Guimard and Stefano Rivera) + +Fixes to nonstandard JMAP extensions +------------------------------------ + +(These extensions are not yet formally standardised, and are only available +with the `jmap_nonstandard_extensions` :cyrusman:`imapd.conf(5)` option +enabled.) + +* Fixed: JMAP Performance Extension - guidsearch requires a folder number for + user inbox +* Fixed: JMAP Calendars Extension - CalendarEvent/get returned wrong results + for date-only recurring events +* Fixed: JMAP Contacts Extension - state was not cleaned up correctly on + Contact/copy failure diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.10.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.10.rst new file mode 100644 index 0000000000..1fdba946bf --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.10.rst @@ -0,0 +1,31 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.2.10 Release Notes +=============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.10/cyrus-imapd-3.2.10.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.10/cyrus-imapd-3.2.10.tar.gz.sig + +.. _relnotes-3.2.10-changes: + +Changes since 3.2.9 +=================== + +Build changes +------------- + +* None + +Bug fixes +--------- + +* None + +Other changes +------------- + +* :issue:`4100`: `ctl_cyrusdb -r` and `reconstruct` now ensure the "uniqueid" + field is present in and synchronised between mailboxes.db and cyrus.header. diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.11.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.11.rst new file mode 100644 index 0000000000..15006556f9 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.11.rst @@ -0,0 +1,46 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.2.11 Release Notes +=============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.11/cyrus-imapd-3.2.11.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.11/cyrus-imapd-3.2.11.tar.gz.sig + +.. _relnotes-3.2.11-changes: + +Changes since 3.2.10 +==================== + +Build changes +------------- + +* Fixed: iCal GEO property is text in new libical versions +* Fixed: docs now build correctly with python3 and Sphinx 3.4 + +Bug fixes +--------- + +* Fixed :issue:`3240`: Seen flag broken on shared mailbox without sharedseen + (thanks Thomas P) +* Fixed :issue:`4189`: sieveshell segfaults after quit command (thanks + Valentin Vidic and Christian Walther) +* Fixed :issue:`4216`: httpd killed by SIGSEGV for calendar request (thanks + Дилян Палаузов) +* Fixed :issue:`4162` :cyrusman:`quota(8)` now correctly accepts -n argument + (thanks Christian Walther) +* Fixed :issue:`4285`: jmap_mail: fix typo in HTML to plain extractor +* Fixed :issue:`3917`: Sieve enotify implementation bugs + +Other changes +------------- + +* Fixed :issue:`4109`: testrunner.pl now exits early if binary components + missing +* Fixed :issue:`4199`: cassandane.ini: don’t choke on repeated params +* Fixed :issue:`4380`: ``backend_version()`` now properly parses the remote + server's version string, and can recognise when it is newer than the local + server. This means XFER to a newer backend no longer requires a local + software update to recognise the new version number first. diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.12.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.12.rst new file mode 100644 index 0000000000..ab13248172 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.12.rst @@ -0,0 +1,50 @@ +:tocdepth: 3 + +=============================== +Cyrus IMAP 3.2.12 Release Notes +=============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.12/cyrus-imapd-3.2.12.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.12/cyrus-imapd-3.2.12.tar.gz.sig + +.. _relnotes-3.2.12-changes: + +Changes since 3.2.11 +==================== + +Build changes +------------- + +* Fixed: Cassandane tests now pass on Debian Bookworm +* PCRE2 is now supported and detected with pkg-config. If both PCRE and PCRE2 + are available, the older PCRE will be preferred. To force use of PCRE2 in + this situation, run configure with the ``--disable-pcre`` option. Please + note that on Debian-based systems, PCRE (the old one, no longer maintained) + is called "pcre3". Yes, this is confusing. +* Fixed :issue:`4770`: missing include when ssl unavailable (thanks Дилян + Палаузов) + +Bug fixes +--------- + +* Fixed :issue:`4123`: XS Perl modules failed to compile against Perl 5.36 +* Fixed :issue:`4309`: incorrect error code used for JMAP + invalidResultReference errors +* Fixed :issue:`4439`: murder frontends now proxy GETMETADATA correctly + (thanks Stéphane GAUBERT) +* Fixed :issue:`4440`: uninitialized value warning from :cyrusman:`cyradm(8)` + ``listmailbox`` command (thanks Stéphane GAUBERT) +* Fixed :issue:`4465`: missing calls to ``mailbox_iter_done()`` (thanks Дилян + Палаузов) +* Fixed :issue:`4717`: pop3d now avoids splitting ``".\r\n"`` across packet + boundaries, which can confuse some clients +* Fixed :issue:`4756`: potential uninitialized access in extract_convdata + +Other changes +------------- + +* Fixed :issue:`4558`: better cyrusdb / ``ctl_cyrusdb -r`` UX +* Fixed :issue:`4790`: some man pages were missing from distribution tarballs + (thanks Jakob Gahde) diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.2.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.2.rst new file mode 100644 index 0000000000..77c47f7cf4 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.2.rst @@ -0,0 +1,49 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.2 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.2/cyrus-imapd-3.2.2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.2/cyrus-imapd-3.2.2.tar.gz.sig + +.. _relnotes-3.2.2-changes: + +Changes since 3.2.1 +=================== + +Build changes +------------- + +* Fixed: configure now reports when chardet dependency is unavailable + +Bug fixes +--------- + +* Fixed: cunit tests for fatal errors performed invalid reads +* Fixed: double-free in JMAP Email/query cleanup +* Fixed: verbatim terms were left out of Xapian query generator +* Fixed :issue:`3060`: lmtpd would crash after Sieve vacation action + if :subject was set by script +* Fixed :issue:`3029`: authenticated iCalendar and vCard streams were missing + Cache-Control: private header +* Fixed :issue:`3057`: empty return path on outgoing messages was formatted + incorrectly +* Fixed :issue:`2620`: warnings from cyradm with recent perl versions (thanks + Jeffrey Goh) +* Fixed: messages snoozed with a :mailboxid were not awakened correctly + +Fixes to nonstandard JMAP extensions +------------------------------------ + +(These extensions are not yet formally standardised, and are only available +with the `jmap_nonstandard_extensions` :cyrusman:`imapd.conf(5)` option +enabled.) + +* Fixed: JMAP Performance Extension - guidsearch disjunctions of non-Xapian + criteria now rejected +* Fixed: Email/matchMime and Blob/get methods are now properly gated behind + the `jmap_nonstandard_extensions` :cyrusman:`imapd.conf(5)` option +* Fixed: JMAP Calendars Extension - tzid was not always set for UTC events diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.3.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.3.rst new file mode 100644 index 0000000000..d3914f84af --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.3.rst @@ -0,0 +1,49 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.3 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.3/cyrus-imapd-3.2.3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.3/cyrus-imapd-3.2.3.tar.gz.sig + +.. _relnotes-3.2.3-changes: + +Changes since 3.2.2 +=================== + +Build changes +------------- + +* Fixed :issue:`3073`: misleading message when Xapian "words" tokenisation + unavailable +* Fixed :issue:`3102`: removed checks for unused CLD2 dependency (thanks + Anatoli) +* Fixed :issue:`3102`: removed unused Castagnoli CRC32 implementation + (thanks Anatoli) +* Upstreamed compatibility patches from OpenBSD (thanks Anatoli) +* Fixed: Cyrus::SIEVE::managesieve was not linked correctly +* Fixed :issue:`3143`: removed unnecessary autoreconf dependency on /bin/bash +* Fixed: support zlib versions that do not provide deflatePending function + +Bug fixes +--------- + +* Fixed: handling of bad HOLDFOR/HOLDUNTIL values in JMAP email submissions +* Fixed: protection against underflow of unseen and recent counts +* Fixed :issue:`3116`: :cyrusman:`cyr_info(8)` now correctly validates + archivepartition- settings +* Fixed :issue:`3115`: imapd/pop3d connection details were lost during TLS + setup +* Fixed: pop3d LOGOUT event was missing clientAddress field (thanks akschu) +* Fixed: Sieve regexes may have optional matches +* Fixed: XFER now correctly distinguishes between 2.3.x releases +* Fixed :issue:`3123`: XFER now recognises 3.1, 3.2 and 3.3 backends +* Fixed: XFER now syslogs a warning when it doesn't recognise the backend + Cyrus version +* Fixed: crash in Sieve "date :regex" matches +* Fixed :issue:`3152`: DAV crash when no displayname and path is one segment + (thanks Felix J. Ogris) +* Fixed: cunit tests no longer depend on nonstandard malloc.h header diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.4.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.4.rst new file mode 100644 index 0000000000..50ed7510ce --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.4.rst @@ -0,0 +1,50 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.4 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.4/cyrus-imapd-3.2.4.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.4/cyrus-imapd-3.2.4.tar.gz.sig + +.. _relnotes-3.2.4-changes: + +Changes since 3.2.3 +=================== + +Build changes +------------- + +* Fixed :issue:`3153`: make sure xml_support.c is included in + :cyrusman:`ctl_zoneinfo(8)` (thanks John M) +* Fixed :issue:`3154`: crash from cyr_qsort_r on some platforms +* Fixed :issue:`3163`: use `uintptr_t` instead of `unsigned long long` + for storing values that may be pointers (thanks OBATA Akio) +* Fixed :issue:`3157`: MKCOL failed via WebDAV +* Fixed :issue:`3174`: handle platforms without `futimes` or + `TIMESPEC_TO_TIMEVAL()` (thanks Andy Fiddaman) +* Fixed :issue:`3183`: typo in handling of systems without `deflatePending()` + (thanks Anatoli) + +Bug fixes +--------- + +* Fixed :issue:`3120`: allow replication to partitions without a corresponding + archive partition +* Fixed :issue:`3169`: sieve scripts replicated from 2.4 with `fulldirhash` + enabled were placed in wrong directory +* Fixed: unescape iCalendar X-parameter TEXT values +* Fixed: server-set JMAP properties now rejected in /set and /setcreate + calls +* Fixed: changing JMAP 'id' property now rejected in /set and /setupdate +* Fixed: crash in :cyrusman:`httpd(8)` from bad Authorization headers +* Fixed: invalid free on error in JMAP Contacts/set +* Fixed :issue:`3212`: wrong usage statement in :cyrusman:`ctl_zoneinfo(8)` + (thanks Дилян Палаузов) +* Fixed :issue:`3210`: uninitialised read on error +* Fixed :issue:`3209`: uninitialised read on error +* Fixed :issue:`2843`: notifications for cancelled events were not handled + correctly +* Fixed :issue:`3191`: sync_client crashed on RESTART when TLS in use diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.5.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.5.rst new file mode 100644 index 0000000000..e5887b5dce --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.5.rst @@ -0,0 +1,46 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.5 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.5/cyrus-imapd-3.2.5.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.5/cyrus-imapd-3.2.5.tar.gz.sig + +.. _relnotes-3.2.5-changes: + +Changes since 3.2.4 +=================== + +Build changes +------------- + +* Fixed :issue:`3172`: add fallback implementation of `memrchr()` (thanks + Andy Fiddaman) +* Fixed :issue:`3128`: many "format specifies type ... but" warnings + on non-Linux/x86_64 platforms (thanks Anatoli) +* Fixed :issue:`3265`: `__attribute__((optimise))` support detection +* Fixed :issue:`3275`: `--enable-srs` configure option was always ignored + (thanks Carlos Velasco) + +Bug fixes +--------- + +* Fixed :issue:`3180`: httpd process hang when using HTTP/2 +* Fixed :issue:`3239`: don't redefine MAXDOMNAME/MAXLOGNAME if already defined + (thanks Anatoli) +* Fixed: lmtpd no longer allows delivery/keep/fileinto to non-IMAP mailboxes +* Fixed: ensure JMAP sinceState is a number +* Fixed :issue:`3260`: addseen failure when moving messages between mailboxes + with different seen settings +* Fixed :issue:`3214`: don't choke on 8-bit MIME parameters +* Fixed :issue:`3287`: tools/translatesive also iterated parent directory + (thanks Daniel O'Connor) +* Fixed :issue:`3272`: add handler stub for JMAP eventSourceUrl + +Other +----- +* Added `--password` and `--execfile` options to :cyrusman:`sieveshell(1)`, + based on patch from Debian in :issue:`3281` (thanks Xavier Guimard) diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.6.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.6.rst new file mode 100644 index 0000000000..54488e46a3 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.6.rst @@ -0,0 +1,25 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.6 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.6/cyrus-imapd-3.2.6.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.6/cyrus-imapd-3.2.6.tar.gz.sig + +.. _relnotes-3.2.6-changes: + +Changes since 3.2.5 +=================== + +Bug fixes +--------- + +* Fixed :issue:`3235`: typo in sieve header verification + (thanks Дилян Палаузов) +* Fixed: XFER now recognises 3.4 and 3.5 backends +* Fixed :issue:`3320`: memory leak during backend auth state cleanup +* Fixed :issue:`3312`: fixed use-after-free segfault in mupdate-client (thanks + Mario Haustein) diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.7.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.7.rst new file mode 100644 index 0000000000..be23e4fbe9 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.7.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.7 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.7/cyrus-imapd-3.2.7.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.7/cyrus-imapd-3.2.7.tar.gz.sig + +.. _relnotes-3.2.7-changes: + +Changes since 3.2.6 +=================== + +Security fixes: +--------------- + +* Fixed CVE-2021-32056_: Remote authenticated users could bypass intended + access restrictions on certain server annotations. Additionally, a + long-standing bug in replication did not allow server annotations to be + replicated. Combining these two bugs, a remote authenticated user could + stall replication, requiring administrator intervention. + +.. _CVE-2021-32056: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-32056 + +Build changes +------------- + +* Fixed: various symbols were missing explicit symbol visibility + +Bug fixes +--------- + +* Fixed :issue:`3225`: xapian get_stopper() did not use the cached stoppers + (thanks Дилян Палаузов) +* Fixed :issue:`2882`: reordered HTTP auth schemes to order expected by browsers +* Fixed :issue:`3456`: per-server annotations were unable to replicate diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.8.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.8.rst new file mode 100644 index 0000000000..21f5f02113 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.8.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.8 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.8/cyrus-imapd-3.2.8.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.8/cyrus-imapd-3.2.8.tar.gz.sig + +.. _relnotes-3.2.8-changes: + +Changes since 3.2.7 +=================== + +Security fixes: +--------------- + +* Fixed CVE-2021-33582_: Certain user inputs are used as hash table keys during + processing. A poorly chosen string hashing algorithm meant that the user + could control which bucket their data was stored in, allowing a malicious + user to direct many inputs to a single bucket. Each subsequent insertion to + the same bucket requires a strcmp of every other entry in it. At tens of + thousands of entries, each new insertion could keep the CPU busy in a strcmp + loop for minutes. + + The string hashing algorithm has been replaced with a better one, and now + also uses a random seed per hash table, so malicious inputs cannot be + precomputed. + + Discovered by Matthew Horsfall, Fastmail + +.. _CVE-2021-33582: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-33582 + +Bug fixes +--------- + +* Fixed: missing CY namespace in some DAV responses diff --git a/docsrc/imap/download/release-notes/3.2/x/3.2.9.rst b/docsrc/imap/download/release-notes/3.2/x/3.2.9.rst new file mode 100644 index 0000000000..ea5af934b2 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.2/x/3.2.9.rst @@ -0,0 +1,49 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.2.9 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.9/cyrus-imapd-3.2.9.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.2.9/cyrus-imapd-3.2.9.tar.gz.sig + +.. _relnotes-3.2.9-changes: + +Changes since 3.2.8 +=================== + +Build changes +------------- + +* Fixed :issue:`3769`: undefined reference when using flock locking rather + than fcntl (thanks Дилян Палаузов) +* Fixed :issue:`3843`: man pages for optional features had been excluded from + release tarballs + +Bug fixes +--------- + +* Fixed :issue:`3605`: don't send unsolicited updates about other mailboxes in + response to STATUS command. This is technically okay but can confuse + clients that don't expect it. +* Fixed :issue:`3664`: flush output when starting IDLE so changes are told + immediately +* Fixed :issue:`2383`: XFER of a single user or mailbox now works again +* Fixed: XFER no longer tries to sync_restart (and hangs) when the destination + backend doesn't support XFER-via-replication +* Fixed: XFER now reports an error when the name argument doesn't match + anything, instead of doing nothing and then reporting that it succeeded at + it. +* Fixed :issue:`3597`: ignore case difference in 'mailto:' prefix when + comparing CalDAV ORGANIZERs +* Fixed :issue:`3839`: `quota -f -u [user]` no longer removes quota information + from other, similarly-named users + +Other changes +------------- + +* The formerly-standalone Cassandane tool has been merged into the + cyrus-imapd repository, in the 'cassandane' subdirectory. +* XFER will now recognise backends from the upcoming 3.6 and 3.7 versions diff --git a/docsrc/imap/download/release-notes/3.3/index.rst b/docsrc/imap/download/release-notes/3.3/index.rst new file mode 100644 index 0000000000..5a9b9c214f --- /dev/null +++ b/docsrc/imap/download/release-notes/3.3/index.rst @@ -0,0 +1,19 @@ +.. _imap-release-notes-3.3: + +=================== +Cyrus IMAP 3.3 Tags +=================== + +.. warning:: + + The 3.3 series are tagged snapshots of the master branch, and should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to tag these + snapshots at coherent development points, but there will generally be **large + breaking changes** occurring between releases in this series. + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.3/x/3.3.0.rst b/docsrc/imap/download/release-notes/3.3/x/3.3.0.rst new file mode 100644 index 0000000000..2fe37ac1e2 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.3/x/3.3.0.rst @@ -0,0 +1,82 @@ +:tocdepth: 3 + +========================== +Cyrus IMAP 3.3.0 Tag Notes +========================== + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to tag these + snapshots at coherent development points, but there will generally be **large + breaking changes** occurring between releases in this series. + +.. _relnotes-3.3.0-changes: + +Major changes since the 3.2 series +================================== + +* DAV improvements +* Improved performance for users with large folders +* LITERAL- maximum size is now honoured (:rfc:`7888`) +* Support for the ESORT (but not CONTEXT) extension from :rfc:`5267` +* Experimental :draft:`JMAP for Sieve Scripts ` support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental :draft:`Handling Message Disposition Notification with JMAP + ` support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Backup extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Notes extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Blob extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Mail extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* New cyrus.index format v17 adds changes_epoch field, needed by JMAP Backups + extension +* New `reverseuniqueids` :cyrusman:`imapd.conf(5)` option (default on), which + improves performance for users with large mailboxes +* Further improvements to the `reverseacls` :cyrusman:`imapd.conf(5)` performance + option +* Improvements to replication reliability and performance +* Experimental vnd.cyrus.log, vnd.cyrus.jmapquery, and vnd.cyrus.snooze + Sieve extensions +* Improvements to conversations +* New `mailbox_maxmessages_addressbook`, `mailbox_maxmessages_calendar`, and + `mailbox_maxmessages_email` :cyrusman:`imapd.conf(5)` options for providing + server-wide limits on the amount of objects in any one mailbox, independently + of quotas. These default to `0` (unlimited) for backward compatibility, but + are highly recommended for protecting your server from misbehaving clients. +* New IMAP create/delete behaviour based on + :draft:`draft-ietf-extra-imap4rev2`: mailboxes containing child mailboxes + can no longer be deleted; and when creating mailboxes, ancestors will be + created as needed. +* CRC32 optimisations + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* None so far + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: Sieve Editheader Extension (:rfc:`5293`) now works correctly with + respect to section 7, Interaction with Other Sieve Extensions. diff --git a/docsrc/imap/download/release-notes/3.3/x/3.3.1.rst b/docsrc/imap/download/release-notes/3.3/x/3.3.1.rst new file mode 100644 index 0000000000..445251572b --- /dev/null +++ b/docsrc/imap/download/release-notes/3.3/x/3.3.1.rst @@ -0,0 +1,106 @@ +:tocdepth: 3 + +========================== +Cyrus IMAP 3.3.1 Tag Notes +========================== + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to + tag these snapshots at coherent development points, but there will + generally be **large breaking changes** occurring between releases in this + series. + +.. _relnotes-3.3.1-changes: + +Major changes since the 3.2 series +================================== + +* DAV improvements +* Improved performance for users with large folders +* LITERAL- maximum size is now honoured (:rfc:`7888`) +* Support for the ESORT (but not CONTEXT) extension from :rfc:`5267` +* Experimental :draft:`JMAP for Sieve Scripts ` + support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental :draft:`Handling Message Disposition Notification with JMAP + ` support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Backup extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Notes extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Blob extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Mail extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* New cyrus.index format v17 adds changes_epoch field, needed by JMAP Backups + extension +* New `reverseuniqueids` :cyrusman:`imapd.conf(5)` option (default on), which + improves performance for users with large mailboxes +* Further improvements to the `reverseacls` :cyrusman:`imapd.conf(5)` + performance option +* Improvements to replication reliability and performance +* Experimental vnd.cyrus.log and vnd.cyrus.jmapquery Sieve extensions +* Experimental Sieve Snooze extension based on + :draft:`draft-ietf-extra-sieve-snooze` +* Experimental Sieve mailboxid extension based on + :draft:`draft-ietf-extra-sieve-mailboxid` +* Improvements to conversations +* New `mailbox_maxmessages_addressbook`, `mailbox_maxmessages_calendar`, and + `mailbox_maxmessages_email` :cyrusman:`imapd.conf(5)` options for providing + server-wide limits on the amount of objects in any one mailbox, independently + of quotas. These default to `0` (unlimited) for backward compatibility, but + are highly recommended for protecting your server from misbehaving clients. +* New IMAP create/delete behaviour based on + :draft:`draft-ietf-extra-imap4rev2`: mailboxes containing child mailboxes + can no longer be deleted; and when creating mailboxes, ancestors will be + created as needed. +* CRC32 optimisations +* :cyrusman:`quota(8)` and :cyrusman:`cyr_expire(8)` arguments are now in + the admin namespace like other tools +* Support for per-language indexing and searching +* SNMP support has been removed, as it was broken and unmaintained +* New `sync_rightnow_channel` :cyrusman:`imapd.conf(5)` option to enable + real-time replication to the specified channel as writes occur. +* Caching of mailbox state for quicker replication turnaround. Configure + `sync_cache_db` and `sync_cache_db_path` in :cyrusman:`imapd.conf(5)` to + enable. +* New `search-fuzzy-always` annotation allows per-user override of the + `search_fuzzy_always` :cyrusman:`imapd.conf(5)` option +* New `lmtp_preparse` :cyrusman:`imapd.conf(5)` option for parsing incoming + messages before locking the mailbox. +* New `search_index_skip_users` and `search_index_skip_domains` + :cyrusman:`imapd.conf(5)` options for skipping indexing of particular + users/domains. +* The HTTP Admin module's Currently Running Services feature now works + on the major BSDs (thanks Felix J. Ogris) + + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `annotation_definitions` file is now loaded case-insensitively + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: Sieve Editheader Extension (:rfc:`5293`) now works correctly with + respect to section 7, Interaction with Other Sieve Extensions. diff --git a/docsrc/imap/download/release-notes/3.4/index.rst b/docsrc/imap/download/release-notes/3.4/index.rst new file mode 100644 index 0000000000..6d057ec83c --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/index.rst @@ -0,0 +1,15 @@ +.. _imap-release-notes-3.4: + +============================= +Cyrus IMAP 3.4 Releases +============================= + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.?.*-alpha* + x/?.?.*-beta* + x/?.?.*-rc* + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta1.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta1.rst new file mode 100644 index 0000000000..5ec506d821 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta1.rst @@ -0,0 +1,103 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.4.0-beta1 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0-beta1/cyrus-imapd-3.4.0-beta1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0-beta1/cyrus-imapd-3.4.0-beta1.tar.gz.sig + +.. _relnotes-3.4.0-beta1-changes: + +Major changes since the 3.2 series +================================== + +* DAV improvements + + * Allow clients to set schedule-default-calendar-URL + +* Improved performance for users with large folders +* LITERAL- maximum size is now honoured (:rfc:`7888`) +* Support for the ESORT (but not CONTEXT) extension from :rfc:`5267` +* Experimental :draft:`JMAP for Sieve Scripts ` + support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental :draft:`Handling Message Disposition Notification with JMAP + ` support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Backup extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Notes extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Blob extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Mail extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* New cyrus.index format v17 adds changes_epoch field, needed by JMAP Backups + extension +* New `reverseuniqueids` :cyrusman:`imapd.conf(5)` option (default on), which + improves performance for users with large mailboxes +* Further improvements to the `reverseacls` :cyrusman:`imapd.conf(5)` + performance option +* Improvements to replication reliability and performance +* Experimental vnd.cyrus.log and vnd.cyrus.jmapquery Sieve extensions +* Experimental Sieve Snooze extension based on + :draft:`draft-ietf-extra-sieve-snooze` +* Experimental Sieve mailboxid extension based on + :draft:`draft-ietf-extra-sieve-mailboxid` +* Improvements to conversations +* New `mailbox_maxmessages_addressbook`, `mailbox_maxmessages_calendar`, and + `mailbox_maxmessages_email` :cyrusman:`imapd.conf(5)` options for providing + server-wide limits on the amount of objects in any one mailbox, independently + of quotas. These default to `0` (unlimited) for backward compatibility, but + are highly recommended for protecting your server from misbehaving clients. +* New IMAP create/delete behaviour based on + :draft:`draft-ietf-extra-imap4rev2`: mailboxes containing child mailboxes + can no longer be deleted; and when creating mailboxes, ancestors will be + created as needed. +* CRC32 optimisations +* :cyrusman:`quota(8)` and :cyrusman:`cyr_expire(8)` arguments are now in + the admin namespace like other tools +* Support for per-language indexing and searching +* SNMP support has been removed, as it was broken and unmaintained +* New `sync_rightnow_channel` :cyrusman:`imapd.conf(5)` option to enable + real-time replication to the specified channel as writes occur. +* Caching of mailbox state for quicker replication turnaround. Configure + `sync_cache_db` and `sync_cache_db_path` in :cyrusman:`imapd.conf(5)` to + enable. +* New `search-fuzzy-always` annotation allows per-user override of the + `search_fuzzy_always` :cyrusman:`imapd.conf(5)` option +* New `lmtp_preparse` :cyrusman:`imapd.conf(5)` option for parsing incoming + messages before locking the mailbox. +* New `search_index_skip_users` and `search_index_skip_domains` + :cyrusman:`imapd.conf(5)` options for skipping indexing of particular + users/domains. +* The HTTP Admin module's Currently Running Services feature now works + on the major BSDs (thanks Felix J. Ogris) +* Prefer SPNEGO over BASIC WWW-Auth in Firefox/Thunderbird :issue:`2882`. + + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `annotation_definitions` file is now loaded case-insensitively + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: Sieve Editheader Extension (:rfc:`5293`) now works correctly with + respect to section 7, Interaction with Other Sieve Extensions. diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta2.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta2.rst new file mode 100644 index 0000000000..2fcdd0054d --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta2.rst @@ -0,0 +1,105 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.4.0-beta2 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0-beta2/cyrus-imapd-3.4.0-beta2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0-beta2/cyrus-imapd-3.4.0-beta2.tar.gz.sig + +.. _relnotes-3.4.0-beta2-changes: + +Major changes since the 3.2 series +================================== + +* DAV improvements + + * Allow clients to set schedule-default-calendar-URL + +* Improved performance for users with large folders +* LITERAL- maximum size is now honoured (:rfc:`7888`) +* Support for the ESORT (but not CONTEXT) extension from :rfc:`5267` +* Experimental :draft:`JMAP for Sieve Scripts ` + support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental :draft:`Handling Message Disposition Notification with JMAP + ` support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Backup extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Notes extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Blob extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Mail extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* New cyrus.index format v17 adds changes_epoch field, needed by JMAP Backups + extension +* New `reverseuniqueids` :cyrusman:`imapd.conf(5)` option (default on), which + improves performance for users with large mailboxes +* Further improvements to the `reverseacls` :cyrusman:`imapd.conf(5)` + performance option +* Improvements to replication reliability and performance +* Experimental vnd.cyrus.log and vnd.cyrus.jmapquery Sieve extensions +* Experimental Sieve Snooze extension based on + :draft:`draft-ietf-extra-sieve-snooze` +* Experimental Sieve mailboxid extension based on + :draft:`draft-ietf-extra-sieve-mailboxid` +* Improvements to conversations +* New `mailbox_maxmessages_addressbook`, `mailbox_maxmessages_calendar`, and + `mailbox_maxmessages_email` :cyrusman:`imapd.conf(5)` options for providing + server-wide limits on the amount of objects in any one mailbox, independently + of quotas. These default to `0` (unlimited) for backward compatibility, but + are highly recommended for protecting your server from misbehaving clients. +* New IMAP create/delete behaviour based on + :draft:`draft-ietf-extra-imap4rev2`: mailboxes containing child mailboxes + can no longer be deleted; and when creating mailboxes, ancestors will be + created as needed. +* CRC32 optimisations +* :cyrusman:`quota(8)` and :cyrusman:`cyr_expire(8)` arguments are now in + the admin namespace like other tools +* Support for per-language indexing and searching +* SNMP support has been removed, as it was broken and unmaintained +* New `sync_rightnow_channel` :cyrusman:`imapd.conf(5)` option to enable + real-time replication to the specified channel as writes occur. +* Caching of mailbox state for quicker replication turnaround. Configure + `sync_cache_db` and `sync_cache_db_path` in :cyrusman:`imapd.conf(5)` to + enable. +* New `search-fuzzy-always` annotation allows per-user override of the + `search_fuzzy_always` :cyrusman:`imapd.conf(5)` option +* New `lmtp_preparse` :cyrusman:`imapd.conf(5)` option for parsing incoming + messages before locking the mailbox. +* New `search_index_skip_users` and `search_index_skip_domains` + :cyrusman:`imapd.conf(5)` options for skipping indexing of particular + users/domains. +* The HTTP Admin module's Currently Running Services feature now works + on the major BSDs (thanks Felix J. Ogris) +* Prefer SPNEGO over BASIC WWW-Auth in Firefox/Thunderbird :issue:`2882`. + + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `annotation_definitions` file is now loaded case-insensitively + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: Sieve Editheader Extension (:rfc:`5293`) now works correctly with + respect to section 7, Interaction with Other Sieve Extensions. +* Fixed :issue:`2598`: indexed search now works correctly with Squat engine + again diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta3.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta3.rst new file mode 100644 index 0000000000..048aef7f88 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.0-beta3.rst @@ -0,0 +1,113 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.4.0-beta3 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0-beta3/cyrus-imapd-3.4.0-beta3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0-beta3/cyrus-imapd-3.4.0-beta3.tar.gz.sig + +.. _relnotes-3.4.0-beta3-changes: + +Major changes since the 3.2 series +================================== + +* DAV improvements + + * Allow clients to set schedule-default-calendar-URL + +* Improved performance for users with large folders +* LITERAL- maximum size is now honoured (:rfc:`7888`) +* Support for the ESORT (but not CONTEXT) extension from :rfc:`5267` +* Experimental :draft:`JMAP for Sieve Scripts ` + support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental :draft:`Handling Message Disposition Notification with JMAP + ` support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Backup extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Notes extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Blob extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Mail extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* New cyrus.index format v17 adds changes_epoch field, needed by JMAP Backups + extension +* New `reverseuniqueids` :cyrusman:`imapd.conf(5)` option (default on), which + improves performance for users with large mailboxes +* Further improvements to the `reverseacls` :cyrusman:`imapd.conf(5)` + performance option +* Improvements to replication reliability and performance +* Experimental vnd.cyrus.log and vnd.cyrus.jmapquery Sieve extensions +* Experimental Sieve Snooze extension based on + :draft:`draft-ietf-extra-sieve-snooze` +* Experimental Sieve mailboxid extension based on + :draft:`draft-ietf-extra-sieve-mailboxid` +* Improvements to conversations +* New `mailbox_maxmessages_addressbook`, `mailbox_maxmessages_calendar`, and + `mailbox_maxmessages_email` :cyrusman:`imapd.conf(5)` options for providing + server-wide limits on the amount of objects in any one mailbox, independently + of quotas. These default to `0` (unlimited) for backward compatibility, but + are highly recommended for protecting your server from misbehaving clients. +* New IMAP create/delete behaviour based on + :draft:`draft-ietf-extra-imap4rev2`: mailboxes containing child mailboxes + can no longer be deleted; and when creating mailboxes, ancestors will be + created as needed. +* CRC32 optimisations +* :cyrusman:`quota(8)` and :cyrusman:`cyr_expire(8)` arguments are now in + the admin namespace like other tools +* Support for per-language indexing and searching +* SNMP support has been removed, as it was broken and unmaintained +* New `sync_rightnow_channel` :cyrusman:`imapd.conf(5)` option to enable + real-time replication to the specified channel as writes occur. +* Caching of mailbox state for quicker replication turnaround. Configure + `sync_cache_db` and `sync_cache_db_path` in :cyrusman:`imapd.conf(5)` to + enable. +* New `search-fuzzy-always` annotation allows per-user override of the + `search_fuzzy_always` :cyrusman:`imapd.conf(5)` option +* New `lmtp_preparse` :cyrusman:`imapd.conf(5)` option for parsing incoming + messages before locking the mailbox. +* New `search_index_skip_users` and `search_index_skip_domains` + :cyrusman:`imapd.conf(5)` options for skipping indexing of particular + users/domains. +* The HTTP Admin module's Currently Running Services feature now works + on the major BSDs (thanks Felix J. Ogris) +* :cyrusman:`squatter(8)` once again supports the ``-s`` option to skip + reindexing mailboxes which were not modified since the last index + (Squat backend only) +* :cyrusman:`squatter(8)` now supports long options +* Improvements to search query normalisation performance +* Prefer SPNEGO over BASIC WWW-Auth in Firefox/Thunderbird :issue:`2882`. + + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `annotation_definitions` file is now loaded case-insensitively +* Implementations may want to revisit their `search_normalisation_max` + settings, but its default value 1000 is a good conservative choice. Current + server-grade hardware may use 20000 or more. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: Sieve Editheader Extension (:rfc:`5293`) now works correctly with + respect to section 7, Interaction with Other Sieve Extensions. +* Fixed :issue:`2598`: indexed search now works correctly with Squat engine + again diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.0-rc1.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.0-rc1.rst new file mode 100644 index 0000000000..001e7f3e38 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.0-rc1.rst @@ -0,0 +1,109 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.4.0-rc1 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0-rc1/cyrus-imapd-3.4.0-rc1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0-rc1/cyrus-imapd-3.4.0-rc1.tar.gz.sig + +.. _relnotes-3.4.0-rc1-changes: + +Major changes since the 3.2 series +================================== + +* DAV improvements +* Improved performance for users with large folders +* LITERAL- maximum size is now honoured (:rfc:`7888`) +* Support for the ESORT (but not CONTEXT) extension from :rfc:`5267` +* Experimental :draft:`JMAP for Sieve Scripts ` + support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental :draft:`Handling Message Disposition Notification with JMAP + ` support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Backup extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Notes extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Blob extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Mail extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* New cyrus.index format v17 adds changes_epoch field, needed by JMAP Backups + extension +* New `reverseuniqueids` :cyrusman:`imapd.conf(5)` option (default on), which + improves performance for users with large mailboxes +* Further improvements to the `reverseacls` :cyrusman:`imapd.conf(5)` + performance option +* Improvements to replication reliability and performance +* Experimental vnd.cyrus.log and vnd.cyrus.jmapquery Sieve extensions +* Experimental Sieve Snooze extension based on + :draft:`draft-ietf-extra-sieve-snooze` +* Experimental Sieve mailboxid extension based on + :draft:`draft-ietf-extra-sieve-mailboxid` +* Improvements to conversations +* New `mailbox_maxmessages_addressbook`, `mailbox_maxmessages_calendar`, and + `mailbox_maxmessages_email` :cyrusman:`imapd.conf(5)` options for providing + server-wide limits on the amount of objects in any one mailbox, independently + of quotas. These default to `0` (unlimited) for backward compatibility, but + are highly recommended for protecting your server from misbehaving clients. +* New IMAP create/delete behaviour based on + :draft:`draft-ietf-extra-imap4rev2`: mailboxes containing child mailboxes + can no longer be deleted; and when creating mailboxes, ancestors will be + created as needed. +* CRC32 optimisations +* :cyrusman:`quota(8)` and :cyrusman:`cyr_expire(8)` arguments are now in + the admin namespace like other tools +* Support for per-language indexing and searching +* SNMP support has been removed, as it was broken and unmaintained +* New `sync_rightnow_channel` :cyrusman:`imapd.conf(5)` option to enable + real-time replication to the specified channel as writes occur. +* Caching of mailbox state for quicker replication turnaround. Configure + `sync_cache_db` and `sync_cache_db_path` in :cyrusman:`imapd.conf(5)` to + enable. +* New `search-fuzzy-always` annotation allows per-user override of the + `search_fuzzy_always` :cyrusman:`imapd.conf(5)` option +* New `lmtp_preparse` :cyrusman:`imapd.conf(5)` option for parsing incoming + messages before locking the mailbox. +* New `search_index_skip_users` and `search_index_skip_domains` + :cyrusman:`imapd.conf(5)` options for skipping indexing of particular + users/domains. +* The HTTP Admin module's Currently Running Services feature now works + on the major BSDs (thanks Felix J. Ogris) +* :cyrusman:`squatter(8)` once again supports the ``-s`` option to skip + reindexing mailboxes which were not modified since the last index + (Squat backend only) +* :cyrusman:`squatter(8)` now supports long options +* Improvements to search query normalisation performance + + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `annotation_definitions` file is now loaded case-insensitively +* Implementations may want to revisit their `search_normalisation_max` + settings, but its default value 1000 is a good conservative choice. Current + server-grade hardware may use 20000 or more. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: Sieve Editheader Extension (:rfc:`5293`) now works correctly with + respect to section 7, Interaction with Other Sieve Extensions. +* Fixed :issue:`2598`: indexed search now works correctly with Squat engine + again diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.0.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.0.rst new file mode 100644 index 0000000000..894c47f0c3 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.0.rst @@ -0,0 +1,109 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.4.0 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0/cyrus-imapd-3.4.0.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.0/cyrus-imapd-3.4.0.tar.gz.sig + +.. _relnotes-3.4.0-changes: + +Major changes since the 3.2 series +================================== + +* DAV improvements +* Improved performance for users with large folders +* LITERAL- maximum size is now honoured (:rfc:`7888`) +* Support for the ESORT (but not CONTEXT) extension from :rfc:`5267` +* Experimental :draft:`JMAP for Sieve Scripts ` + support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental :draft:`Handling Message Disposition Notification with JMAP + ` support + (requires `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Backup extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Notes extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Blob extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* Experimental JMAP Mail extension (requires + `jmap_nonstandard_extensions: yes` in :cyrusman:`imapd.conf(5)`). +* New cyrus.index format v17 adds changes_epoch field, needed by JMAP Backups + extension +* New `reverseuniqueids` :cyrusman:`imapd.conf(5)` option (default on), which + improves performance for users with large mailboxes +* Further improvements to the `reverseacls` :cyrusman:`imapd.conf(5)` + performance option +* Improvements to replication reliability and performance +* Experimental :ref:`vnd.cyrus.log ` and vnd.cyrus.jmapquery Sieve extensions +* Experimental Sieve Snooze extension based on + :draft:`draft-ietf-extra-sieve-snooze` +* Experimental Sieve mailboxid extension based on + :draft:`draft-ietf-extra-sieve-mailboxid` +* Improvements to conversations +* New `mailbox_maxmessages_addressbook`, `mailbox_maxmessages_calendar`, and + `mailbox_maxmessages_email` :cyrusman:`imapd.conf(5)` options for providing + server-wide limits on the amount of objects in any one mailbox, independently + of quotas. These default to `0` (unlimited) for backward compatibility, but + are highly recommended for protecting your server from misbehaving clients. +* New IMAP create/delete behaviour based on + :draft:`draft-ietf-extra-imap4rev2`: mailboxes containing child mailboxes + can no longer be deleted; and when creating mailboxes, ancestors will be + created as needed. +* CRC32 optimisations +* :cyrusman:`quota(8)` and :cyrusman:`cyr_expire(8)` arguments are now in + the admin namespace like other tools +* Support for per-language indexing and searching +* SNMP support has been removed, as it was broken and unmaintained +* New `sync_rightnow_channel` :cyrusman:`imapd.conf(5)` option to enable + real-time replication to the specified channel as writes occur. +* Caching of mailbox state for quicker replication turnaround. Configure + `sync_cache_db` and `sync_cache_db_path` in :cyrusman:`imapd.conf(5)` to + enable. +* New `search-fuzzy-always` annotation allows per-user override of the + `search_fuzzy_always` :cyrusman:`imapd.conf(5)` option +* New `lmtp_preparse` :cyrusman:`imapd.conf(5)` option for parsing incoming + messages before locking the mailbox. +* New `search_index_skip_users` and `search_index_skip_domains` + :cyrusman:`imapd.conf(5)` options for skipping indexing of particular + users/domains. +* The HTTP Admin module's Currently Running Services feature now works + on the major BSDs (thanks Felix J. Ogris) +* :cyrusman:`squatter(8)` once again supports the ``-s`` option to skip + reindexing mailboxes which were not modified since the last index + (Squat backend only) +* :cyrusman:`squatter(8)` now supports long options +* Improvements to search query normalisation performance + + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `annotation_definitions` file is now loaded case-insensitively +* Implementations may want to revisit their `search_normalisation_max` + settings, but its default value 1000 is a good conservative choice. Current + server-grade hardware may use 20000 or more. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed: Sieve Editheader Extension (:rfc:`5293`) now works correctly with + respect to section 7, Interaction with Other Sieve Extensions. +* Fixed :issue:`2598`: indexed search now works correctly with Squat engine + again diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.1.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.1.rst new file mode 100644 index 0000000000..ef6ac8667d --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.1.rst @@ -0,0 +1,53 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.1 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.1/cyrus-imapd-3.4.1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.1/cyrus-imapd-3.4.1.tar.gz.sig + +.. _relnotes-3.4.1-changes: + +Changes since 3.4.0 +=================== + +Security fixes: +--------------- + +* Fixed CVE-2021-32056_: Remote authenticated users could bypass intended + access restrictions on certain server annotations. Additionally, a + long-standing bug in replication did not allow server annotations to be + replicated. Combining these two bugs, a remote authenticated user could + stall replication, requiring administrator intervention. + +.. _CVE-2021-32056: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-32056 + +Build changes +------------- + +* Fixed :issue:`3462`: using GLIBC-only macro to check for GCC features + (thanks Andy Fiddaman) + +Bug fixes +--------- + +* Fixed :issue:`3456`: per-server annotations were unable to replicate +* Fixed :issue:`3468`: `ctl_cyrusdb -r` assertion on startup when mboxlist_db + configured to "skiplist" (thanks Felix J. Ogris) +* Fixed: JMAP email updates must result in non-empty mailboxIds +* Fixed: output JMAP dates as Dates, not UTCDates + +Fixes to nonstandard JMAP extensions +------------------------------------ + +(These extensions are not yet formally standardised, and are only available +with the `jmap_nonstandard_extensions` :cyrusman:`imapd.conf(5)` option +enabled.) + +* Fixed: JMAP Calendars Extension - gracefully handle empty property values +* Fixed: JMAP Calendars Extension - ignore empty string default values in + events +* Fixed: JMAP Calendars Extension - do not use Participant.email for scheduling diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.2.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.2.rst new file mode 100644 index 0000000000..4cfe1a7150 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.2.rst @@ -0,0 +1,50 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.2 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.2/cyrus-imapd-3.4.2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.2/cyrus-imapd-3.4.2.tar.gz.sig + +.. _relnotes-3.4.2-changes: + +Changes since 3.4.1 +=================== + +Security fixes: +--------------- + +* Fixed CVE-2021-33582_: Certain user inputs are used as hash table keys during + processing. A poorly chosen string hashing algorithm meant that the user + could control which bucket their data was stored in, allowing a malicious + user to direct many inputs to a single bucket. Each subsequent insertion to + the same bucket requires a strcmp of every other entry in it. At tens of + thousands of entries, each new insertion could keep the CPU busy in a strcmp + loop for minutes. + + The string hashing algorithm has been replaced with a better one, and now + also uses a random seed per hash table, so malicious inputs cannot be + precomputed. + + Discovered by Matthew Horsfall, Fastmail + +.. _CVE-2021-33582: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-33582 + +Build changes +------------- + +* Fixed :issue:`3527`: build problems when `--without-sieve` configured + +Bug fixes +--------- + +* Fixed: missing CY namespace in some DAV responses +* Fixed: don't allow JMAP uploads if the user does not have r/w access to any + mailbox/calendar/addressbook +* Fixed: Email/query sometimes chose the wrong search algorithm +* Fixed :issue:`3488`: LMTP delivery to shared mailboxes was broken +* Fixed :issue:`3528`: 'lookup' ACL alone was not allowing IMAP LIST +* Fixed: RTF message bodies were treated as plain text in search snippets diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.3.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.3.rst new file mode 100644 index 0000000000..ca81c8e6cf --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.3.rst @@ -0,0 +1,57 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.3 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.3/cyrus-imapd-3.4.3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.3/cyrus-imapd-3.4.3.tar.gz.sig + +.. _relnotes-3.4.3-changes: + +Changes since 3.4.2 +=================== + +Build changes +------------- + +* Fixed :issue:`3854`: miscellaneous build warnings reported by gcc 8.3.0 with + optimisations turned on +* Fixed: typos in some GCC feature detection macros +* Fixed :issue:`3769`: undefined reference when using flock locking rather + than fcntl (thanks Дилян Палаузов) +* Fixed :issue:`3843`: man pages for optional features had been excluded from + release tarballs + +Bug fixes +--------- + +* Fixed :issue:`3605`: don't send unsolicited updates about other mailboxes in + response to STATUS command. This is technically okay but can confuse + clients that don't expect it. +* Fixed :issue:`3664`: flush output when starting IDLE so changes are told + immediately +* Fixed :issue:`2383`: XFER of a single user or mailbox now works again +* Fixed: XFER no longer tries to sync_restart (and hangs) when the destination + backend doesn't support XFER-via-replication +* Fixed: XFER now reports an error when the name argument doesn't match + anything, instead of doing nothing and then reporting that it succeeded at + it. +* Fixed :issue:`3597`: ignore case difference in 'mailto:' prefix when + comparing CalDAV ORGANIZERs +* Fixed: compare ORGANIZER in CalDAV requests against user's schedule addresses +* Fixed: scheduling userid now set correctly when virtdomains is disabled or + a defaultdomain is set +* Fixed: use-after-free in Xapian search error logging +* Fixed :issue:`3750`: use-after-free in JMAP Calendar/set error logging +* Fixed :issue:`3839`: `quota -f -u [user]` no longer removes quota information + from other, similarly-named users + +Other changes +------------- + +* The formerly-standalone Cassandane tool has been merged into the + cyrus-imapd repository, in the 'cassandane' subdirectory. +* XFER will now recognise backends from the upcoming 3.6 and 3.7 versions diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.4.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.4.rst new file mode 100644 index 0000000000..e7439b9168 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.4.rst @@ -0,0 +1,36 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.4 Release Notes +============================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.4/cyrus-imapd-3.4.4.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.4/cyrus-imapd-3.4.4.tar.gz.sig + +.. _relnotes-3.4.4-changes: + +Changes since 3.4.3 +=================== + +Build changes +------------- + +* None + +Bug fixes +--------- + +* Fixed :issue:`3318`: assertion failures when using twoskip for subscriptions + databases (thanks David Murray) +* Fixed :issue:`3941`: sieve implicit keep causing duplicates when include + statement used +* Fixed :issue:`4123`: XS Perl modules failed to compile against Perl 5.36 + +Other changes +------------- + +* :issue:`4100`: `ctl_cyrusdb -r` and `reconstruct` now ensure the "uniqueid" + field is present in and synchronised between mailboxes.db and cyrus.header. +* Cassandane can now read configuration options from the environment diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.5.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.5.rst new file mode 100644 index 0000000000..545093e7d8 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.5.rst @@ -0,0 +1,48 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.5 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.5/cyrus-imapd-3.4.5.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.5/cyrus-imapd-3.4.5.tar.gz.sig + +.. _relnotes-3.4.5-changes: + +Changes since 3.4.4 +=================== + +Build changes +------------- + +* Fixed: iCal GEO property is text in new libical versions +* Fixed: docs now build correctly with python3 and Sphinx 3.4 + +Bug fixes +--------- + +* Fixed :issue:`4154`: JMAP Mailbox/get missed shared accounts when + altnamespace enabled (thanks Qiu Yingbo) +* Fixed :issue:`4189`: sieveshell segfaults after quit command (thanks + Christian Walther) +* Fixed :issue:`4216`: httpd killed by SIGSEGV for calendar request (thanks + Дилян Палаузов) +* Fixed :issue:`4285`: jmap_mail: fix typo in HTML to plain extractor +* Fixed :issue:`4162`: :cyrusman:`quota(8)` now correctly accepts ``-n`` + argument (thanks Christian Walther) +* Fixed :issue:`3240`: Seen flag broken on shared mailbox without sharedseen +* Fixed :issue:`4257`: various memory leaks +* Fixed :issue:`3917`: Sieve enotify implementation bugs + +Other changes +------------- + +* Fixed :issue:`4109`: testrunner.pl now exits early if binary components + missing +* Fixed :issue:`4380`: ``backend_version()`` now properly parses the remote + server's version string, and can recognise when it is newer than the local + server. This means XFER to a newer backend no longer requires a local + software update to recognise the new version number first. +* Fixed :issue:`4199`: cassandane.ini: don't choke on repeated params diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.6.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.6.rst new file mode 100644 index 0000000000..db7b3aff01 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.6.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.6 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.6/cyrus-imapd-3.4.6.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.6/cyrus-imapd-3.4.6.tar.gz.sig + +.. _relnotes-3.4.6-changes: + +Changes since 3.4.5 +=================== + +Build changes +------------- + +* Fixed :issue:`4561`: retired custom manpage generator + +Bug fixes +--------- + +* Fixed :issue:`4437`: murder frontends now proxy DAV PUT correctly +* Fixed :issue:`4439`: murder frontends now proxy GETMETADATA correctly + (thanks Stéphane GAUBERT) +* Fixed :issue:`4440`: uninitialized value warning from :cyrusman:`cyradm(8)` + ``listmailbox`` command (thanks Stéphane GAUBERT) +* Fixed :issue:`4465`: missing calls to ``mailbox_iter_done()`` (thanks + Дилян Палаузов) +* Fixed :issue:`4309`: incorrect error code used for JMAP + invalidResultReference errors +* Fixed :issue:`4574`: potential crash in jmap_email_parse + +Other changes +------------- + +* Fixed :issue:`4558`: better cyrusdb / ``ctl_cyrusdb -r`` UX diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.7.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.7.rst new file mode 100644 index 0000000000..7f219f7fc9 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.7.rst @@ -0,0 +1,48 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.7 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.7/cyrus-imapd-3.4.7.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.7/cyrus-imapd-3.4.7.tar.gz.sig + +.. _relnotes-3.4.7-changes: + +Changes since 3.4.6 +=================== + +Build changes +------------- + +* Fixed: Cassandane tests now pass on Debian Bookworm +* PCRE2 is now supported and detected with pkg-config. If both PCRE and PCRE2 + are available, the older PCRE will be preferred. To force use of PCRE2 in + this situation, run configure with the ``--disable-pcre`` option. Please + note that on Debian-based systems, PCRE (the old one, no longer maintained) + is called "pcre3". Yes, this is confusing. +* Fixed :issue:`4770`: missing include when ssl unavailable (thanks Дилян + Палаузов) + +Bug fixes +--------- + +* Fixed: squat db reindexes are no longer always incremental +* Fixed: squat db corruption from unintentional indexing of fields + intended to be skipped. Squat search databases may benefit from a full + (non-incremental) reindex +* Fixed :issue:`4660`: squat db out of bounds access in incremental reindex + docID map +* Fixed :issue:`4692`: squat db searches now handle unindexed messages + correctly again (thanks Gabriele Bulfon) +* Fixed :issue:`4717`: pop3d now avoids splitting ``".\r\n"`` across packet + boundaries, which can confuse some clients +* Fixed :issue:`4756`: potential uninitialized access in extract_convdata + +Other changes +------------- + +* Fixed :issue:`4790`: some man pages were missing from distribution tarballs + (thanks Jakob Gahde) diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.8.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.8.rst new file mode 100644 index 0000000000..f51044ebee --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.8.rst @@ -0,0 +1,57 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.8 Release Notes +============================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.8/cyrus-imapd-3.4.8.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.8/cyrus-imapd-3.4.8.tar.gz.sig + +.. _relnotes-3.4.8-changes: + +Changes since 3.4.7 +=================== + +Security fixes +-------------- + +* Fixed CVE-2024-34055_: + Cyrus-IMAP through 3.8.2 and 3.10.0-beta2 allow authenticated attackers + to cause unbounded memory allocation by sending many LITERALs in a + single command. + + The IMAP protocol allows for command arguments to be LITERALs of + negotiated length, and for these the server allocates memory to + receive the content before instructing the client to proceed. The + allocated memory is released when the whole command has been received + and processed. + + The IMAP protocol has a number commands that specify an unlimited + number of arguments, for example SEARCH. Each of these arguments can + be a LITERAL, for which memory will be allocated and not released + until the entire command has been received and processed. This can run + a server out of memory, with varying consequences depending on the + server's OOM policy. + + Discovered by Damian Poddebniak. + + Two limits, with corresponding :cyrusman:`imapd.conf(5)` options, have + been added to address this: + + * ``maxargssize`` (default: unlimited): limits the overall length of a + single IMAP command. Deployments should configure this to a size that + suits their system resources and client usage patterns + * ``maxliteral`` (default: 128K): limits the length of individual IMAP + LITERALs + + Connections sending commands that would exceed these limits will see the + command fail, or the connection closed, depending on the specific context. + The error message will contain the ``[TOOBIG]`` response code. + + These limits may be set small without affecting message uploads, as the + APPEND command's message literal is limited by ``maxmessagesize``, not by + these new options. + +.. _CVE-2024-34055: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-34055 diff --git a/docsrc/imap/download/release-notes/3.4/x/3.4.9.rst b/docsrc/imap/download/release-notes/3.4/x/3.4.9.rst new file mode 100644 index 0000000000..9cd1951d4c --- /dev/null +++ b/docsrc/imap/download/release-notes/3.4/x/3.4.9.rst @@ -0,0 +1,21 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.4.9 Release Notes +============================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.9/cyrus-imapd-3.4.9.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.4.9/cyrus-imapd-3.4.9.tar.gz.sig + +.. _relnotes-3.4.9-changes: + +Changes since 3.4.8 +=================== + +Bug fixes +--------- + +* Fixed :issue:`4903`: httpd crashing on CalDAV +* Fixed :issue:`4932`: LITERAL+ broken in mupdate diff --git a/docsrc/imap/download/release-notes/3.5/index.rst b/docsrc/imap/download/release-notes/3.5/index.rst new file mode 100644 index 0000000000..91b5fa6260 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.5/index.rst @@ -0,0 +1,21 @@ +.. _imap-release-notes-3.5: + +=================== +Cyrus IMAP 3.5 Tags +=================== + +.. warning:: + + The 3.5 series are tagged snapshots of the master branch, and should be + considered for **testing purposes** and **bleeding-edge features** only. + We will try to tag these snapshots at coherent development points, but + there will generally be **large breaking changes** occurring between + releases in this series. + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.?.?-alpha* + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.5/x/3.5.0-alpha0.rst b/docsrc/imap/download/release-notes/3.5/x/3.5.0-alpha0.rst new file mode 100644 index 0000000000..147b8602fb --- /dev/null +++ b/docsrc/imap/download/release-notes/3.5/x/3.5.0-alpha0.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +================================= +Cyrus IMAP 3.5.0-alpha0 Tag Notes +================================= + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to + tag these snapshots at coherent development points, but there will + generally be **large breaking changes** occurring between releases in this + series. + +.. _relnotes-3.5.0-alpha0-changes: + +Major changes since the 3.4 series +================================== + +* None yet! + +Updates to default configuration +================================ + +* None yet! + +Security fixes +============== + +* None yet! + +Significant bugfixes +==================== + +* None yet! diff --git a/docsrc/imap/download/release-notes/3.6/index.rst b/docsrc/imap/download/release-notes/3.6/index.rst new file mode 100644 index 0000000000..0b81da2448 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/index.rst @@ -0,0 +1,15 @@ +.. _imap-release-notes-3.6: + +======================= +Cyrus IMAP 3.6 Releases +======================= + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.?.*-alpha* + x/?.?.*-beta* + x/?.?.*-rc* + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta1.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta1.rst new file mode 100644 index 0000000000..75febbd882 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta1.rst @@ -0,0 +1,139 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.6.0-beta1 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-beta1/cyrus-imapd-3.6.0-beta1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-beta1/cyrus-imapd-3.6.0-beta1.tar.gz.sig + +.. _relnotes-3.6.0-beta1-changes: + +Major changes since the 3.4 series +================================== + +* All members of a newly created mailbox hierarchy will now be selectable + mailboxes. In other words, when creating mailbox user.foo.A.B.C, both + user.foo.A and user.foo.A.B will also be created as real mailboxes. +* XFER will no longer move individual mailboxes that would leave unselectable + mailboxes behind in the user heirarchy. +* Update IMAP PREVIEW extension from + :draft:`draft-ietf-extra-imap-fetch-preview-07` to :rfc:`8970`. +* JMAP Blob handling updated to :draft:`draft-ietf-jmap-blob-04` +* Always return 'blobId' and 'size' in Contact/setcreate, Contact/setupdate, + and Contact/get responses. +* Added Bearer authentication for :rfc:`7519` JSON Web Tokens. See + `http_jwt_key` and `http_jwt_max_age` in :cyrusman:`imapd.conf(5)`. +* Added `icalendar_max_size` and `vcard_max_size` :cyrusman:`imapd.conf(5)` + options to limit the size of iCalendar and vCard resources that can be + stored. +* Added `caldav_accept_invalid_rrules` :cyrusman:`imapd.conf(5)` option to + configure whether invalid RRULES should be accepted or rejected. They + were previously always rejected. +* The `event_extra_params` :cyrusman:`imapd.conf(5)` option now supports + a `vnd.fastmail.jmapEmail` selector, which adds a JMAP Email object to + event messages. If you enable this selector, you might consider removing + `vnd.cmu.envelope`, `vnd.cmu.emailid`, and `vnd.cmu.threadid`, as they + would be redundant. +* Added `search_maxsize` :cyrusman:`imapd.conf(5)` option to limit how much + of long message body parts are indexed by Xapian, which can improve indexing + and snippet-generation latency. The default is 4MB. An earlier + implementation applied a hardcoded 4MB limit to entire messages; systems + that had this implementation may notice an increase in index size due to + more text now being indexed. +* The :cyrusman:`squatter(8)` `-s delta` option now requires the delta + argument. +* Added an upper limit to :cyrusman:`sync_client(8)`'s exponential reconnect + backoff, configurable with the `sync_reconnect_maxwait` + :cyrusman:`imapd.conf(5)` option. +* :cyrusman:`sync_client(8)` now recognises when the remote + :cyrusman:`sync_server(8)` has been shut down cleanly, and doesn't log a + bunch of disconnection errors about it. +* Added an inactivity timeout for WebSocket connections, configurable with + the `websocket_timeout` :cyrusman:`imapd.conf(5)` option. +* Mailboxes and user metadata directories are now organised on disk by UUID + rather than by mailbox name. See :ref:`relnotes_3.6.0-beta1_storage_changes` below. +* Sieve scripts are now stored in a special mailbox, rather than in + `sievedir`. Compiled bytecode is still stored in `sievedir`. The name of + the mailbox can be overridden with the `sieve_folder` + :cyrusman:`imapd.conf(5)` option (default: '#sieve'). +* Experimental `processimip` Sieve action, for updating calendar entries + based on iMIP (:rfc:`6047`) messages. +* Support for JMAP Push (:rfc:`8620`) over EventSource, with polling interval + controlled by the `jmap_pushpoll` :cyrusman:`imapd.conf(5)` option + (default: 60s). +* Experimental support for JMAP Push over WebSockets, if Cyrus has been + compiled with WebSockets enabled. Controlled by the same `jmap_pushpoll` + option. +* When a MIME part Content-Type header incorrectly specifies multiple charsets, + Cyrus now checks each charset for validity and uses the last valid one. + Previously, it would just use the first one, which is often junk when + multiple are present. +* JMAP Email/query and XCONV IMAP extension commands no longer ignore the SEEN + state of messages in the "Trash" mailbox when evaluating conversation flags. + Queries must explicitly exclude the "Trash" mailbox to ignore SEEN state of + messages in "Trash". +* JMAP Contact.importance property is now a per-user property rather than + shared. +* JMAP Contacts now uses Apple-style labels on vCard ADR/TEL/EMAIL properties + (via property grouping). +* JMAP Contacts avatars can now reference any valid blob, not only blobs + originally uploaded via JMAP. + +.. _relnotes_3.6.0-beta1_storage_changes: + +Storage changes +=============== + +Mailboxes and user metadata directories are now organised on disk by UUID +rather than by mailbox name. + +At startup (or when you run `ctl_cyrusdb -r` manually), +:cyrusman:`ctl_cyrusdb(8)` will upgrade mailboxes.db to accommodate both +old-style and new-style storage. + +By default, new top-level mailboxes will be created in the new style. +Mailboxes that already exist will remain in the old style until you convert +them with :cyrusman:`relocate_by_id(8)`. New mailboxes below the top level +will be created in the same style as their parent mailbox. + +The new :cyrusman:`cyr_ls(8)` tool can be used to examine the on-disk +contents of a given mailbox name. :cyrusman:`mbpath(8)` can be used to find +where on disk a given mailbox and its metadata are. + +If you want new top level mailboxes to be created in the old style, you +can enable the `mailbox_legacy_dirs` :cyrusman:`imapd.conf(5)` option, which +defaults to **off**. With this turned on, you may still use `relocate_by_id` +to convert them to the new style. + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `reverseuniqueids` :cyrusman:`imapd.conf(5)` option is now deprecated + and unused. Reverse UNIQUEID records are now standard and cannot be turned + off. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`3325`: email addresses with quoted-string names were stored in + the cache and search indexes without quotes, which could cause + inconsistencies in handling. Affected mailboxes can be fixed by first + running :cyrusman:`reconstruct(8)` to repair the cache, and then + :cyrusman:`squatter(8)` to reindex the mailbox. +* Fixed :issue:`3421`: PROPFIND now returns an + XML element instead of text. (thanks Дилян Палаузов) diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta2.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta2.rst new file mode 100644 index 0000000000..1e415c42f5 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta2.rst @@ -0,0 +1,146 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.6.0-beta2 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-beta2/cyrus-imapd-3.6.0-beta2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-beta2/cyrus-imapd-3.6.0-beta2.tar.gz.sig + +.. _relnotes-3.6.0-beta2-changes: + +Major changes since the 3.4 series +================================== + +* All members of a newly created mailbox hierarchy will now be selectable + mailboxes. In other words, when creating mailbox user.foo.A.B.C, both + user.foo.A and user.foo.A.B will also be created as real mailboxes. +* XFER will no longer move individual mailboxes that would leave unselectable + mailboxes behind in the user heirarchy. +* Update IMAP PREVIEW extension from + :draft:`draft-ietf-extra-imap-fetch-preview-07` to :rfc:`8970`. +* JMAP Blob handling updated to :draft:`draft-ietf-jmap-blob-04` +* Always return 'blobId' and 'size' in Contact/setcreate, Contact/setupdate, + and Contact/get responses. +* Added Bearer authentication for :rfc:`7519` JSON Web Tokens. See + `http_jwt_key` and `http_jwt_max_age` in :cyrusman:`imapd.conf(5)`. +* Added `icalendar_max_size` and `vcard_max_size` :cyrusman:`imapd.conf(5)` + options to limit the size of iCalendar and vCard resources that can be + stored. +* Added `caldav_accept_invalid_rrules` :cyrusman:`imapd.conf(5)` option to + configure whether invalid RRULES should be accepted or rejected. They + were previously always rejected. +* The `event_extra_params` :cyrusman:`imapd.conf(5)` option now supports + a `vnd.fastmail.jmapEmail` selector, which adds a JMAP Email object to + event messages. If you enable this selector, you might consider removing + `vnd.cmu.envelope`, `vnd.cmu.emailid`, and `vnd.cmu.threadid`, as they + would be redundant. +* Added `search_maxsize` :cyrusman:`imapd.conf(5)` option to limit how much + of long message body parts are indexed by Xapian, which can improve indexing + and snippet-generation latency. The default is 4MB. An earlier + implementation applied a hardcoded 4MB limit to entire messages; systems + that had this implementation may notice an increase in index size due to + more text now being indexed. +* The :cyrusman:`squatter(8)` `-s delta` option now requires the delta + argument. +* Added an upper limit to :cyrusman:`sync_client(8)`'s exponential reconnect + backoff, configurable with the `sync_reconnect_maxwait` + :cyrusman:`imapd.conf(5)` option. +* :cyrusman:`sync_client(8)` now recognises when the remote + :cyrusman:`sync_server(8)` has been shut down cleanly, and doesn't log a + bunch of disconnection errors about it +* :cyrusman:`sync_client(8)` `-A` mode now keeps processing subsequent + mailboxes after errors (reconnecting to the replica if necessary), instead + of bailing out at the first error. This means everything that can be + replicated is replicated. +* New :cyrusman:`sync_client(8)` `-N` option to skip users that are currently + being replicated by another process, rather than blocking on the lock only + to have nothing left to do once it's been obtained. +* Added an inactivity timeout for WebSocket connections, configurable with + the `websocket_timeout` :cyrusman:`imapd.conf(5)` option. +* Mailboxes and user metadata directories are now organised on disk by UUID + rather than by mailbox name. See :ref:`relnotes_3.6.0-beta2_storage_changes` below. +* Sieve scripts are now stored in a special mailbox, rather than in + `sievedir`. Compiled bytecode is still stored in `sievedir`. The name of + the mailbox can be overridden with the `sieve_folder` + :cyrusman:`imapd.conf(5)` option (default: '#sieve'). +* Experimental `processimip` Sieve action, for updating calendar entries + based on iMIP (:rfc:`6047`) messages. +* Support for JMAP Push (:rfc:`8620`) over EventSource, with polling interval + controlled by the `jmap_pushpoll` :cyrusman:`imapd.conf(5)` option + (default: 60s). +* Experimental support for JMAP Push over WebSockets, if Cyrus has been + compiled with WebSockets enabled. Controlled by the same `jmap_pushpoll` + option. +* When a MIME part Content-Type header incorrectly specifies multiple charsets, + Cyrus now checks each charset for validity and uses the last valid one. + Previously, it would just use the first one, which is often junk when + multiple are present. +* JMAP Email/query and XCONV IMAP extension commands no longer ignore the SEEN + state of messages in the "Trash" mailbox when evaluating conversation flags. + Queries must explicitly exclude the "Trash" mailbox to ignore SEEN state of + messages in "Trash". +* JMAP Contact.importance property is now a per-user property rather than + shared. +* JMAP Contacts now uses Apple-style labels on vCard ADR/TEL/EMAIL properties + (via property grouping). +* JMAP Contacts avatars can now reference any valid blob, not only blobs + originally uploaded via JMAP. + +.. _relnotes_3.6.0-beta2_storage_changes: + +Storage changes +=============== + +Mailboxes and user metadata directories are now organised on disk by UUID +rather than by mailbox name. + +At startup (or when you run `ctl_cyrusdb -r` manually), +:cyrusman:`ctl_cyrusdb(8)` will upgrade mailboxes.db to accommodate both +old-style and new-style storage. + +By default, new top-level mailboxes will be created in the new style. +Mailboxes that already exist will remain in the old style until you convert +them with :cyrusman:`relocate_by_id(8)`. New mailboxes below the top level +will be created in the same style as their parent mailbox. + +The new :cyrusman:`cyr_ls(8)` tool can be used to examine the on-disk +contents of a given mailbox name. :cyrusman:`mbpath(8)` can be used to find +where on disk a given mailbox and its metadata are. + +If you want new top level mailboxes to be created in the old style, you +can enable the `mailbox_legacy_dirs` :cyrusman:`imapd.conf(5)` option, which +defaults to **off**. With this turned on, you may still use `relocate_by_id` +to convert them to the new style. + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `reverseuniqueids` :cyrusman:`imapd.conf(5)` option is now deprecated + and unused. Reverse UNIQUEID records are now standard and cannot be turned + off. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`3325`: email addresses with quoted-string names were stored in + the cache and search indexes without quotes, which could cause + inconsistencies in handling. Affected mailboxes can be fixed by first + running :cyrusman:`reconstruct(8)` to repair the cache, and then + :cyrusman:`squatter(8)` to reindex the mailbox. +* Fixed :issue:`3421`: PROPFIND now returns an + XML element instead of text. (thanks Дилян Палаузов) diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta3.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta3.rst new file mode 100644 index 0000000000..729ffdc89d --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.0-beta3.rst @@ -0,0 +1,160 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.6.0-beta3 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-beta3/cyrus-imapd-3.6.0-beta3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-beta3/cyrus-imapd-3.6.0-beta3.tar.gz.sig + +.. _relnotes-3.6.0-beta3-changes: + +Major changes since the 3.4 series +================================== + +* All members of a newly created mailbox hierarchy will now be selectable + mailboxes. In other words, when creating mailbox user.foo.A.B.C, both + user.foo.A and user.foo.A.B will also be created as real mailboxes. +* XFER will no longer move individual mailboxes that would leave unselectable + mailboxes behind in the user heirarchy. +* Update IMAP PREVIEW extension from + :draft:`draft-ietf-extra-imap-fetch-preview-07` to :rfc:`8970`. +* JMAP Blob handling updated to :draft:`draft-ietf-jmap-blob-04` +* Always return 'blobId' and 'size' in Contact/setcreate, Contact/setupdate, + and Contact/get responses. +* Added Bearer authentication for :rfc:`7519` JSON Web Tokens. See + `http_jwt_key` and `http_jwt_max_age` in :cyrusman:`imapd.conf(5)`. +* Added `icalendar_max_size` and `vcard_max_size` :cyrusman:`imapd.conf(5)` + options to limit the size of iCalendar and vCard resources that can be + stored. +* Added `caldav_accept_invalid_rrules` :cyrusman:`imapd.conf(5)` option to + configure whether invalid RRULES should be accepted or rejected. They + were previously always rejected. +* The `event_extra_params` :cyrusman:`imapd.conf(5)` option now supports + a `vnd.fastmail.jmapEmail` selector, which adds a JMAP Email object to + event messages. If you enable this selector, you might consider removing + `vnd.cmu.envelope`, `vnd.cmu.emailid`, and `vnd.cmu.threadid`, as they + would be redundant. +* Added `search_maxsize` :cyrusman:`imapd.conf(5)` option to limit how much + of long message body parts are indexed by Xapian, which can improve indexing + and snippet-generation latency. The default is 4MB. An earlier + implementation applied a hardcoded 4MB limit to entire messages; systems + that had this implementation may notice an increase in index size due to + more text now being indexed. +* The :cyrusman:`squatter(8)` `-s delta` option now requires the delta + argument. +* Added an upper limit to :cyrusman:`sync_client(8)`'s exponential reconnect + backoff, configurable with the `sync_reconnect_maxwait` + :cyrusman:`imapd.conf(5)` option. +* :cyrusman:`sync_client(8)` now recognises when the remote + :cyrusman:`sync_server(8)` has been shut down cleanly, and doesn't log a + bunch of disconnection errors about it. +* :cyrusman:`sync_client(8)` `-A` mode now keeps processing subsequent + mailboxes after errors (reconnecting to the replica if necessary), instead + of bailing out at the first error. This means everything that can be + replicated is replicated. +* New :cyrusman:`sync_client(8)` `-N` option to skip users that are currently + being replicated by another process, rather than blocking on the lock only + to have nothing left to do once it's been obtained. +* Added an inactivity timeout for WebSocket connections, configurable with + the `websocket_timeout` :cyrusman:`imapd.conf(5)` option. +* Mailboxes and user metadata directories are now organised on disk by UUID + rather than by mailbox name. See :ref:`relnotes_3.6.0-beta3_storage_changes` below. +* Sieve scripts are now stored in a special mailbox, rather than in + `sievedir`. Compiled bytecode is still stored in `sievedir`. The name of + the mailbox can be overridden with the `sieve_folder` + :cyrusman:`imapd.conf(5)` option (default: '#sieve'). +* Experimental `processimip` Sieve action, for updating calendar entries + based on iMIP (:rfc:`6047`) messages. +* Support for JMAP Push (:rfc:`8620`) over EventSource, with polling interval + controlled by the `jmap_pushpoll` :cyrusman:`imapd.conf(5)` option + (default: 60s). +* Experimental support for JMAP Push over WebSockets, if Cyrus has been + compiled with WebSockets enabled. Controlled by the same `jmap_pushpoll` + option. +* When a MIME part Content-Type header incorrectly specifies multiple charsets, + Cyrus now checks each charset for validity and uses the last valid one. + Previously, it would just use the first one, which is often junk when + multiple are present. +* JMAP Email/query and XCONV IMAP extension commands no longer ignore the SEEN + state of messages in the "Trash" mailbox when evaluating conversation flags. + Queries must explicitly exclude the "Trash" mailbox to ignore SEEN state of + messages in "Trash". +* JMAP Contact.importance property is now a per-user property rather than + shared. +* JMAP Contacts now uses Apple-style labels on vCard ADR/TEL/EMAIL properties + (via property grouping). +* JMAP Contacts avatars can now reference any valid blob, not only blobs + originally uploaded via JMAP. +* Preliminary support for building with OpenSSL 3. + + +.. _relnotes_3.6.0-beta3_storage_changes: + +Storage changes +=============== + +.. Note:: Please consult :ref:`upgrade` prior to upgrading. + +Mailboxes and user metadata directories are now organised on disk by UUID +rather than by mailbox name. + +At startup (or when you run `ctl_cyrusdb -r` manually), +:cyrusman:`ctl_cyrusdb(8)` will upgrade mailboxes.db to accommodate both +old-style and new-style storage. + +By default, new top-level mailboxes will be created in the new style. +Mailboxes that already exist will remain in the old style until you convert +them with :cyrusman:`relocate_by_id(8)`. New mailboxes below the top level +will be created in the same style as their parent mailbox. + +The new :cyrusman:`cyr_ls(8)` tool can be used to examine the on-disk +contents of a given mailbox name. :cyrusman:`mbpath(8)` can be used to find +where on disk a given mailbox and its metadata are. + +If you want new top level mailboxes to be created in the old style, you +can enable the `mailbox_legacy_dirs` :cyrusman:`imapd.conf(5)` option, which +defaults to **off**. With this turned on, you may still use `relocate_by_id` +to convert them to the new style. + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `reverseuniqueids` :cyrusman:`imapd.conf(5)` option is now deprecated + and unused. Reverse UNIQUEID records are now standard and cannot be turned + off. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`3325`: email addresses with quoted-string names were stored in + the cache and search indexes without quotes, which could cause + inconsistencies in handling. Affected mailboxes can be fixed by first + running :cyrusman:`reconstruct(8)` to repair the cache, and then + :cyrusman:`squatter(8)` to reindex the mailbox. +* Fixed :issue:`3421`: PROPFIND now returns an + XML element instead of text. (thanks Дилян Палаузов) +* Fixed :issue:`3896`: the `-d` (dump) and `-u` (undump) options to + :cyrusman:`ctl_mboxlist(8)` now correctly dump and undump all fields in + mailboxes.db entries. The intermediary file format is now JSON. This change + makes it possible to follow the procedure + for switching `improved_mboxlist_sort` described in + :ref:`enabling improved mboxlist sort`. +* Fixed :issue:`4035`: `ctl_cyrusdb -r` now recovers from mailboxes.db + records with missing uniqueids, instead of crashing. A new `-P` option to + :cyrusman:`reconstruct(8)` enables repairing mailboxes whose header files + are missing uniqueids. diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.0-rc1.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.0-rc1.rst new file mode 100644 index 0000000000..82beb489f9 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.0-rc1.rst @@ -0,0 +1,160 @@ +:tocdepth: 3 + +================================== +Cyrus IMAP 3.6.0-rc1 Release Notes +================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-rc1/cyrus-imapd-3.6.0-rc1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-rc1/cyrus-imapd-3.6.0-rc1.tar.gz.sig + +.. _relnotes-3.6.0-rc1-changes: + +Major changes since the 3.4 series +================================== + +* All members of a newly created mailbox hierarchy will now be selectable + mailboxes. In other words, when creating mailbox user.foo.A.B.C, both + user.foo.A and user.foo.A.B will also be created as real mailboxes. +* XFER will no longer move individual mailboxes that would leave unselectable + mailboxes behind in the user heirarchy. +* Update IMAP PREVIEW extension from + :draft:`draft-ietf-extra-imap-fetch-preview-07` to :rfc:`8970`. +* JMAP Blob handling updated to :draft:`draft-ietf-jmap-blob-04` +* Always return 'blobId' and 'size' in Contact/setcreate, Contact/setupdate, + and Contact/get responses. +* Added Bearer authentication for :rfc:`7519` JSON Web Tokens. See + `http_jwt_key` and `http_jwt_max_age` in :cyrusman:`imapd.conf(5)`. +* Added `icalendar_max_size` and `vcard_max_size` :cyrusman:`imapd.conf(5)` + options to limit the size of iCalendar and vCard resources that can be + stored. +* Added `caldav_accept_invalid_rrules` :cyrusman:`imapd.conf(5)` option to + configure whether invalid RRULES should be accepted or rejected. They + were previously always rejected. +* The `event_extra_params` :cyrusman:`imapd.conf(5)` option now supports + a `vnd.fastmail.jmapEmail` selector, which adds a JMAP Email object to + event messages. If you enable this selector, you might consider removing + `vnd.cmu.envelope`, `vnd.cmu.emailid`, and `vnd.cmu.threadid`, as they + would be redundant. +* Added `search_maxsize` :cyrusman:`imapd.conf(5)` option to limit how much + of long message body parts are indexed by Xapian, which can improve indexing + and snippet-generation latency. The default is 4MB. An earlier + implementation applied a hardcoded 4MB limit to entire messages; systems + that had this implementation may notice an increase in index size due to + more text now being indexed. +* The :cyrusman:`squatter(8)` `-s delta` option now requires the delta + argument. +* Added an upper limit to :cyrusman:`sync_client(8)`'s exponential reconnect + backoff, configurable with the `sync_reconnect_maxwait` + :cyrusman:`imapd.conf(5)` option. +* :cyrusman:`sync_client(8)` now recognises when the remote + :cyrusman:`sync_server(8)` has been shut down cleanly, and doesn't log a + bunch of disconnection errors about it. +* :cyrusman:`sync_client(8)` `-A` mode now keeps processing subsequent + mailboxes after errors (reconnecting to the replica if necessary), instead + of bailing out at the first error. This means everything that can be + replicated is replicated. +* New :cyrusman:`sync_client(8)` `-N` option to skip users that are currently + being replicated by another process, rather than blocking on the lock only + to have nothing left to do once it's been obtained. +* Added an inactivity timeout for WebSocket connections, configurable with + the `websocket_timeout` :cyrusman:`imapd.conf(5)` option. +* Mailboxes and user metadata directories are now organised on disk by UUID + rather than by mailbox name. See :ref:`relnotes_3.6.0-rc1_storage_changes` below. +* Sieve scripts are now stored in a special mailbox, rather than in + `sievedir`. Compiled bytecode is still stored in `sievedir`. The name of + the mailbox can be overridden with the `sieve_folder` + :cyrusman:`imapd.conf(5)` option (default: '#sieve'). +* Experimental `processimip` Sieve action, for updating calendar entries + based on iMIP (:rfc:`6047`) messages. +* Support for JMAP Push (:rfc:`8620`) over EventSource, with polling interval + controlled by the `jmap_pushpoll` :cyrusman:`imapd.conf(5)` option + (default: 60s). +* Experimental support for JMAP Push over WebSockets, if Cyrus has been + compiled with WebSockets enabled. Controlled by the same `jmap_pushpoll` + option. +* When a MIME part Content-Type header incorrectly specifies multiple charsets, + Cyrus now checks each charset for validity and uses the last valid one. + Previously, it would just use the first one, which is often junk when + multiple are present. +* JMAP Email/query and XCONV IMAP extension commands no longer ignore the SEEN + state of messages in the "Trash" mailbox when evaluating conversation flags. + Queries must explicitly exclude the "Trash" mailbox to ignore SEEN state of + messages in "Trash". +* JMAP Contact.importance property is now a per-user property rather than + shared. +* JMAP Contacts now uses Apple-style labels on vCard ADR/TEL/EMAIL properties + (via property grouping). +* JMAP Contacts avatars can now reference any valid blob, not only blobs + originally uploaded via JMAP. +* Preliminary support for building with OpenSSL 3. + + +.. _relnotes_3.6.0-rc1_storage_changes: + +Storage changes +=============== + +.. Note:: Please consult :ref:`upgrade` prior to upgrading. + +Mailboxes and user metadata directories are now organised on disk by UUID +rather than by mailbox name. + +At startup (or when you run `ctl_cyrusdb -r` manually), +:cyrusman:`ctl_cyrusdb(8)` will upgrade mailboxes.db to accommodate both +old-style and new-style storage. + +By default, new top-level mailboxes will be created in the new style. +Mailboxes that already exist will remain in the old style until you convert +them with :cyrusman:`relocate_by_id(8)`. New mailboxes below the top level +will be created in the same style as their parent mailbox. + +The new :cyrusman:`cyr_ls(8)` tool can be used to examine the on-disk +contents of a given mailbox name. :cyrusman:`mbpath(8)` can be used to find +where on disk a given mailbox and its metadata are. + +If you want new top level mailboxes to be created in the old style, you +can enable the `mailbox_legacy_dirs` :cyrusman:`imapd.conf(5)` option, which +defaults to **off**. With this turned on, you may still use `relocate_by_id` +to convert them to the new style. + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `reverseuniqueids` :cyrusman:`imapd.conf(5)` option is now deprecated + and unused. Reverse UNIQUEID records are now standard and cannot be turned + off. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`3325`: email addresses with quoted-string names were stored in + the cache and search indexes without quotes, which could cause + inconsistencies in handling. Affected mailboxes can be fixed by first + running :cyrusman:`reconstruct(8)` to repair the cache, and then + :cyrusman:`squatter(8)` to reindex the mailbox. +* Fixed :issue:`3421`: PROPFIND now returns an + XML element instead of text. (thanks Дилян Палаузов) +* Fixed :issue:`3896`: the `-d` (dump) and `-u` (undump) options to + :cyrusman:`ctl_mboxlist(8)` now correctly dump and undump all fields in + mailboxes.db entries. The intermediary file format is now JSON. This change + makes it possible to follow the procedure + for switching `improved_mboxlist_sort` described in + :ref:`enabling improved mboxlist sort`. +* Fixed :issue:`4035`: `ctl_cyrusdb -r` now recovers from mailboxes.db + records with missing uniqueids, instead of crashing. A new `-P` option to + :cyrusman:`reconstruct(8)` enables repairing mailboxes whose header files + are missing uniqueids. diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.0-rc2.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.0-rc2.rst new file mode 100644 index 0000000000..4b2437ed41 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.0-rc2.rst @@ -0,0 +1,160 @@ +:tocdepth: 3 + +================================== +Cyrus IMAP 3.6.0-rc2 Release Notes +================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-rc2/cyrus-imapd-3.6.0-rc2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0-rc2/cyrus-imapd-3.6.0-rc2.tar.gz.sig + +.. _relnotes-3.6.0-rc2-changes: + +Major changes since the 3.4 series +================================== + +* All members of a newly created mailbox hierarchy will now be selectable + mailboxes. In other words, when creating mailbox user.foo.A.B.C, both + user.foo.A and user.foo.A.B will also be created as real mailboxes. +* XFER will no longer move individual mailboxes that would leave unselectable + mailboxes behind in the user heirarchy. +* Update IMAP PREVIEW extension from + :draft:`draft-ietf-extra-imap-fetch-preview-07` to :rfc:`8970`. +* JMAP Blob handling updated to :draft:`draft-ietf-jmap-blob-04` +* Always return 'blobId' and 'size' in Contact/setcreate, Contact/setupdate, + and Contact/get responses. +* Added Bearer authentication for :rfc:`7519` JSON Web Tokens. See + `http_jwt_key` and `http_jwt_max_age` in :cyrusman:`imapd.conf(5)`. +* Added `icalendar_max_size` and `vcard_max_size` :cyrusman:`imapd.conf(5)` + options to limit the size of iCalendar and vCard resources that can be + stored. +* Added `caldav_accept_invalid_rrules` :cyrusman:`imapd.conf(5)` option to + configure whether invalid RRULES should be accepted or rejected. They + were previously always rejected. +* The `event_extra_params` :cyrusman:`imapd.conf(5)` option now supports + a `vnd.fastmail.jmapEmail` selector, which adds a JMAP Email object to + event messages. If you enable this selector, you might consider removing + `vnd.cmu.envelope`, `vnd.cmu.emailid`, and `vnd.cmu.threadid`, as they + would be redundant. +* Added `search_maxsize` :cyrusman:`imapd.conf(5)` option to limit how much + of long message body parts are indexed by Xapian, which can improve indexing + and snippet-generation latency. The default is 4MB. An earlier + implementation applied a hardcoded 4MB limit to entire messages; systems + that had this implementation may notice an increase in index size due to + more text now being indexed. +* The :cyrusman:`squatter(8)` `-s delta` option now requires the delta + argument. +* Added an upper limit to :cyrusman:`sync_client(8)`'s exponential reconnect + backoff, configurable with the `sync_reconnect_maxwait` + :cyrusman:`imapd.conf(5)` option. +* :cyrusman:`sync_client(8)` now recognises when the remote + :cyrusman:`sync_server(8)` has been shut down cleanly, and doesn't log a + bunch of disconnection errors about it. +* :cyrusman:`sync_client(8)` `-A` mode now keeps processing subsequent + mailboxes after errors (reconnecting to the replica if necessary), instead + of bailing out at the first error. This means everything that can be + replicated is replicated. +* New :cyrusman:`sync_client(8)` `-N` option to skip users that are currently + being replicated by another process, rather than blocking on the lock only + to have nothing left to do once it's been obtained. +* Added an inactivity timeout for WebSocket connections, configurable with + the `websocket_timeout` :cyrusman:`imapd.conf(5)` option. +* Mailboxes and user metadata directories are now organised on disk by UUID + rather than by mailbox name. See :ref:`relnotes_3.6.0-rc2_storage_changes` below. +* Sieve scripts are now stored in a special mailbox, rather than in + `sievedir`. Compiled bytecode is still stored in `sievedir`. The name of + the mailbox can be overridden with the `sieve_folder` + :cyrusman:`imapd.conf(5)` option (default: '#sieve'). +* Experimental `processimip` Sieve action, for updating calendar entries + based on iMIP (:rfc:`6047`) messages. +* Support for JMAP Push (:rfc:`8620`) over EventSource, with polling interval + controlled by the `jmap_pushpoll` :cyrusman:`imapd.conf(5)` option + (default: 60s). +* Experimental support for JMAP Push over WebSockets, if Cyrus has been + compiled with WebSockets enabled. Controlled by the same `jmap_pushpoll` + option. +* When a MIME part Content-Type header incorrectly specifies multiple charsets, + Cyrus now checks each charset for validity and uses the last valid one. + Previously, it would just use the first one, which is often junk when + multiple are present. +* JMAP Email/query and XCONV IMAP extension commands no longer ignore the SEEN + state of messages in the "Trash" mailbox when evaluating conversation flags. + Queries must explicitly exclude the "Trash" mailbox to ignore SEEN state of + messages in "Trash". +* JMAP Contact.importance property is now a per-user property rather than + shared. +* JMAP Contacts now uses Apple-style labels on vCard ADR/TEL/EMAIL properties + (via property grouping). +* JMAP Contacts avatars can now reference any valid blob, not only blobs + originally uploaded via JMAP. +* Preliminary support for building with OpenSSL 3. + + +.. _relnotes_3.6.0-rc2_storage_changes: + +Storage changes +=============== + +.. Note:: Please consult :ref:`upgrade` prior to upgrading. + +Mailboxes and user metadata directories are now organised on disk by UUID +rather than by mailbox name. + +At startup (or when you run `ctl_cyrusdb -r` manually), +:cyrusman:`ctl_cyrusdb(8)` will upgrade mailboxes.db to accommodate both +old-style and new-style storage. + +By default, new top-level mailboxes will be created in the new style. +Mailboxes that already exist will remain in the old style until you convert +them with :cyrusman:`relocate_by_id(8)`. New mailboxes below the top level +will be created in the same style as their parent mailbox. + +The new :cyrusman:`cyr_ls(8)` tool can be used to examine the on-disk +contents of a given mailbox name. :cyrusman:`mbpath(8)` can be used to find +where on disk a given mailbox and its metadata are. + +If you want new top level mailboxes to be created in the old style, you +can enable the `mailbox_legacy_dirs` :cyrusman:`imapd.conf(5)` option, which +defaults to **off**. With this turned on, you may still use `relocate_by_id` +to convert them to the new style. + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `reverseuniqueids` :cyrusman:`imapd.conf(5)` option is now deprecated + and unused. Reverse UNIQUEID records are now standard and cannot be turned + off. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`3325`: email addresses with quoted-string names were stored in + the cache and search indexes without quotes, which could cause + inconsistencies in handling. Affected mailboxes can be fixed by first + running :cyrusman:`reconstruct(8)` to repair the cache, and then + :cyrusman:`squatter(8)` to reindex the mailbox. +* Fixed :issue:`3421`: PROPFIND now returns an + XML element instead of text. (thanks Дилян Палаузов) +* Fixed :issue:`3896`: the `-d` (dump) and `-u` (undump) options to + :cyrusman:`ctl_mboxlist(8)` now correctly dump and undump all fields in + mailboxes.db entries. The intermediary file format is now JSON. This change + makes it possible to follow the procedure + for switching `improved_mboxlist_sort` described in + :ref:`enabling improved mboxlist sort`. +* Fixed :issue:`4035`: `ctl_cyrusdb -r` now recovers from mailboxes.db + records with missing uniqueids, instead of crashing. A new `-P` option to + :cyrusman:`reconstruct(8)` enables repairing mailboxes whose header files + are missing uniqueids. diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.0.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.0.rst new file mode 100644 index 0000000000..dffb849126 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.0.rst @@ -0,0 +1,165 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.6.0 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0/cyrus-imapd-3.6.0.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.0/cyrus-imapd-3.6.0.tar.gz.sig + +.. _relnotes-3.6.0-changes: + +Major changes since the 3.4 series +================================== + +* All members of a newly created mailbox hierarchy will now be selectable + mailboxes. In other words, when creating mailbox user.foo.A.B.C, both + user.foo.A and user.foo.A.B will also be created as real mailboxes. +* XFER will no longer move individual mailboxes that would leave unselectable + mailboxes behind in the user heirarchy. +* Update IMAP PREVIEW extension from + :draft:`draft-ietf-extra-imap-fetch-preview-07` to :rfc:`8970`. +* Update IMAP SAVEDATE extension from + :draft:`draft-ietf-extra-imap-savedate` to :rfc:`8514`. +* Update IMAP OBJECTID extension from + :draft:`draft-ietf-extra-imap-objectid` to :rfc:`8474`. +* JMAP Blob handling updated to :draft:`draft-ietf-jmap-blob-04` +* Always return 'blobId' and 'size' in Contact/setcreate, Contact/setupdate, + and Contact/get responses. +* Added Bearer authentication for :rfc:`7519` JSON Web Tokens. See + `http_jwt_key` and `http_jwt_max_age` in :cyrusman:`imapd.conf(5)`. +* Added `icalendar_max_size` and `vcard_max_size` :cyrusman:`imapd.conf(5)` + options to limit the size of iCalendar and vCard resources that can be + stored. +* Added `caldav_accept_invalid_rrules` :cyrusman:`imapd.conf(5)` option to + configure whether invalid RRULES should be accepted or rejected. They + were previously always rejected. +* The `event_extra_params` :cyrusman:`imapd.conf(5)` option now supports + a `vnd.fastmail.jmapEmail` selector, which adds a JMAP Email object to + event messages. If you enable this selector, you might consider removing + `vnd.cmu.envelope`, `vnd.cmu.emailid`, and `vnd.cmu.threadid`, as they + would be redundant. +* Added `search_maxsize` :cyrusman:`imapd.conf(5)` option to limit how much + of long message body parts are indexed by Xapian, which can improve indexing + and snippet-generation latency. The default is 4MB. An earlier + implementation applied a hardcoded 4MB limit to entire messages; systems + that had this implementation may notice an increase in index size due to + more text now being indexed. +* The :cyrusman:`squatter(8)` `-s delta` option now requires the delta + argument. +* Added an upper limit to :cyrusman:`sync_client(8)`'s exponential reconnect + backoff, configurable with the `sync_reconnect_maxwait` + :cyrusman:`imapd.conf(5)` option. +* :cyrusman:`sync_client(8)` now recognises when the remote + :cyrusman:`sync_server(8)` has been shut down cleanly, and doesn't log a + bunch of disconnection errors about it. +* :cyrusman:`sync_client(8)` `-A` mode now keeps processing subsequent + mailboxes after errors (reconnecting to the replica if necessary), instead + of bailing out at the first error. This means everything that can be + replicated is replicated. +* New :cyrusman:`sync_client(8)` `-N` option to skip users that are currently + being replicated by another process, rather than blocking on the lock only + to have nothing left to do once it's been obtained. +* Added an inactivity timeout for WebSocket connections, configurable with + the `websocket_timeout` :cyrusman:`imapd.conf(5)` option. +* Mailboxes and user metadata directories are now organised on disk by UUID + rather than by mailbox name. See :ref:`relnotes_3.6.0_storage_changes` below. +* Sieve scripts are now stored in a special mailbox, rather than in + `sievedir`. Compiled bytecode is still stored in `sievedir`. The name of + the mailbox can be overridden with the `sieve_folder` + :cyrusman:`imapd.conf(5)` option (default: '#sieve'). +* Experimental :ref:`processimip` Sieve action, for updating calendar entries + based on iMIP (:rfc:`6047`) messages. +* Support for JMAP Push (:rfc:`8620`) over EventSource, with polling interval + controlled by the `jmap_pushpoll` :cyrusman:`imapd.conf(5)` option + (default: 60s). +* Experimental support for JMAP Push over WebSockets, if Cyrus has been + compiled with WebSockets enabled. Controlled by the same `jmap_pushpoll` + option. +* When a MIME part Content-Type header incorrectly specifies multiple charsets, + Cyrus now checks each charset for validity and uses the last valid one. + Previously, it would just use the first one, which is often junk when + multiple are present. +* JMAP Email/query and XCONV IMAP extension commands no longer ignore the SEEN + state of messages in the "Trash" mailbox when evaluating conversation flags. + Queries must explicitly exclude the "Trash" mailbox to ignore SEEN state of + messages in "Trash". +* JMAP Contact.importance property is now a per-user property rather than + shared. +* JMAP Contacts now uses Apple-style labels on vCard ADR/TEL/EMAIL properties + (via property grouping). +* JMAP Contacts avatars can now reference any valid blob, not only blobs + originally uploaded via JMAP. +* Preliminary support for building with OpenSSL 3. +* Support converting vCards 3.0⇔4.0 based on CARDDAV:address-data. + + +.. _relnotes_3.6.0_storage_changes: + +Storage changes +=============== + +.. Note:: Please consult :ref:`upgrade` prior to upgrading. + +Mailboxes and user metadata directories are now organised on disk by UUID +rather than by mailbox name. + +At startup (or when you run `ctl_cyrusdb -r` manually), +:cyrusman:`ctl_cyrusdb(8)` will upgrade mailboxes.db to accommodate both +old-style and new-style storage. + +By default, new top-level mailboxes will be created in the new style. +Mailboxes that already exist will remain in the old style until you convert +them with :cyrusman:`relocate_by_id(8)`. New mailboxes below the top level +will be created in the same style as their parent mailbox. + +The new :cyrusman:`cyr_ls(8)` tool can be used to examine the on-disk +contents of a given mailbox name. :cyrusman:`mbpath(8)` can be used to find +where on disk a given mailbox and its metadata are. + +If you want new top level mailboxes to be created in the old style, you +can enable the `mailbox_legacy_dirs` :cyrusman:`imapd.conf(5)` option, which +defaults to **off**. With this turned on, you may still use `relocate_by_id` +to convert them to the new style. + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* The `reverseuniqueids` :cyrusman:`imapd.conf(5)` option is now deprecated + and unused. Reverse UNIQUEID records are now standard and cannot be turned + off. + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`3325`: email addresses with quoted-string names were stored in + the cache and search indexes without quotes, which could cause + inconsistencies in handling. Affected mailboxes can be fixed by first + running :cyrusman:`reconstruct(8)` to repair the cache, and then + :cyrusman:`squatter(8)` to reindex the mailbox. +* Fixed :issue:`3421`: PROPFIND now returns an + XML element instead of text. (thanks Дилян Палаузов) +* Fixed :issue:`3896`: the `-d` (dump) and `-u` (undump) options to + :cyrusman:`ctl_mboxlist(8)` now correctly dump and undump all fields in + mailboxes.db entries. The intermediary file format is now JSON. This change + makes it possible to follow the procedure + for switching `improved_mboxlist_sort` described in + :ref:`enabling improved mboxlist sort`. +* Fixed :issue:`4035`: `ctl_cyrusdb -r` now recovers from mailboxes.db + records with missing uniqueids, instead of crashing. A new `-P` option to + :cyrusman:`reconstruct(8)` enables repairing mailboxes whose header files + are missing uniqueids. diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.1.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.1.rst new file mode 100644 index 0000000000..b23b347cb8 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.1.rst @@ -0,0 +1,23 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.6.1 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.1/cyrus-imapd-3.6.1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.1/cyrus-imapd-3.6.1.tar.gz.sig + +.. _relnotes-3.6.1-changes: + +Changes since 3.6.0 +=================== + +Other changes +------------- + +* Fixed :issue:`4380`: ``backend_version()`` now properly parses the remote + server's version string, and can recognise when it is newer than the local + server. This means XFER to a newer backend no longer requires a local + software update to recognise the new version number first. diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.2.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.2.rst new file mode 100644 index 0000000000..b3440588a3 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.2.rst @@ -0,0 +1,47 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.6.2 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.2/cyrus-imapd-3.6.2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.2/cyrus-imapd-3.6.2.tar.gz.sig + +.. _relnotes-3.6.2-changes: + +Changes since 3.6.1 +=================== + +Bug fixes +--------- + +* Fixed :issue:`3771`: XFER to 3.4 destination no longer loses specialuse + annotations +* Fixed :issue:`3892`: :cyrusman:`squatter(8)` no longer crashes on invalid + mailbox names (thanks Martin Osvald) +* Fixed :issue:`4383`: :cyrusman:`squatter(8)` in rolling mode now + periodically compacts databases, rather than only at shutdown +* Fixed :issue:`4401`: JMAP no longer permits moving a mailbox under a + deleted one +* Fixed :issue:`4415`: sieve path lookup errors no longer lead to writes + to root directory +* Fixed :issue:`4426`: deleting mailboxes no longer leaves behind orphan + ``I`` records in mailboxes.db +* Fixed :issue:`4437`: murder frontends now proxy DAV PUT correctly +* Fixed :issue:`4439`: murder frontends now proxy GETMETADATA correctly + (thanks Stéphane GAUBERT) +* Fixed :issue:`4442`: :cyrusman:`httpd(8)` no longer crashes on precondition + failure during deletion of calendar collection +* Fixed :issue:`4440`: uninitialized value warning from :cyrusman:`cyradm(8)` + ``listmailbox`` command (thanks Stéphane GAUBERT) +* Fixed :issue:`4465`: missing calls to ``mailbox_iter_done()`` (thanks + Дилян Палаузов) + + +Other changes +------------- + +* Fixed :issue:`4187`: :cyrusman:`ctl_mboxlist(8)` ``-v`` option now detects + and reports broken UUID mailboxes (thanks Matthias Hunstock) diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.3.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.3.rst new file mode 100644 index 0000000000..9eaf40f0f5 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.3.rst @@ -0,0 +1,36 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.6.3 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.3/cyrus-imapd-3.6.3.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.3/cyrus-imapd-3.6.3.tar.gz.sig + +.. _relnotes-3.6.3-changes: + +Changes since 3.6.2 +=================== + +Bug fixes +--------- + +* Fixed :issue:`4309`: incorrect error code used for JMAP + invalidResultReference errors +* Fixed :issue:`4577`: fixed use of uninitialised value +* Fixed :issue:`4537`: timsieved shut_down crash +* Fixed :issue:`4544`: leaked SSL_SESSION during backend disconnect +* Fixed :issue:`4293`: cyr_cd.sh is bash, not sh +* Fixed :issue:`4359`: lock ordering fixes (also fixes :issue:`4611`) +* Fixed :issue:`4370`: XFER did not fully remove source mailbox +* Fixed :issue:`4574`: potential crash in jmap_email_parse +* Fixed :issue:`4611`: assertion when setting sharedseen on a shared mailbox +* Fixed :issue:`4567`: invalid FETCH BINARY response for sections with + unknown Content-Transfer-Encoding + +Other changes +------------- + +* Fixed :issue:`4558`: better cyrusdb / ``ctl_cyrusdb -r`` UX diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.4.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.4.rst new file mode 100644 index 0000000000..d41ed83715 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.4.rst @@ -0,0 +1,67 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.6.4 Release Notes +============================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.4/cyrus-imapd-3.6.4.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.4/cyrus-imapd-3.6.4.tar.gz.sig + +.. _relnotes-3.6.4-changes: + +Changes since 3.6.3 +=================== + +Build changes +------------- + +* Fixed: Cassandane tests now pass on Debian Bookworm +* Fixed :issue:`3974`: cunit crash when built with newer compilers +* PCRE2 is now supported and detected with pkg-config. If both PCRE and PCRE2 + are available, the older PCRE will be preferred. To force use of PCRE2 in + this situation, run configure with the ``--disable-pcre`` option. Please + note that on Debian-based systems, PCRE (the old one, no longer maintained) + is called "pcre3". Yes, this is confusing. +* Fixed :issue:`4770`: build failure when ssl.h unavailable (thanks Дилян + Палаузов) + +Bug fixes +--------- + +* Fixed: sieve tester jmapquery +* Fixed :issue:`4570`: :cyrusman:`ctl_mboxlist(8)` crash +* Fixed: squat db reindexes are no longer always incremental +* Fixed: squat db corruption from unintentional indexing of fields + intended to be skipped. Squat search databases may benefit from a full + (non-incremental) reindex +* Fixed :issue:`4660`: squat db out of bounds access in incremental reindex + docID map +* Fixed :issue:`4692`: squat db searches now handle unindexed messages + correctly again (thanks Gabriele Bulfon) +* Fixed :issue:`4710`: crash on copy/append fail in mailbox with custom + user flags +* Fixed: GETMETADATA no longer shows internal DAV mailboxes (unless + ``imapmagicplus`` is enabled and the user is authenticated as + ``username+dav@domain``) +* Fixed :issue:`4717`: pop3d now avoids splitting ``".\r\n"`` across packet + boundaries, which can confuse some clients +* Fixed :issue:`4663`: :cyrusman:`lmtpd(8)` ``processimip`` sieve action now + correctly strips known timezones from iCalendar objects (thanks Дилян + Палаузов) +* Fixed :issue:`4756`: potential uninitialized access in extract_convdata +* Fixed :issue:`4771`: potential invalid read in message_parse_received_date + (thanks Дилян Палаузов) +* Fixed :issue:`4424`: DAV requests now respond with 507 rather than 500 when + mailbox_maxmessages limits exceeded +* Fixed :issue:`4804`: mailbox_maxmessages limits now applied correctly +* Fixed :issue:`4820`: PREVIEW message attribute must be qstring/literal, + not an atom +* Fixed :issue:`4828`: the default addressbook can no longer be deleted + +Other changes +------------- + +* Fixed :issue:`4790`: some man pages were missing from distribution tarballs + (thanks Jakob Gahde) diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.5.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.5.rst new file mode 100644 index 0000000000..ba2d22f43b --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.5.rst @@ -0,0 +1,57 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.6.5 Release Notes +============================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.5/cyrus-imapd-3.6.5.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.5/cyrus-imapd-3.6.5.tar.gz.sig + +.. _relnotes-3.6.5-changes: + +Changes since 3.6.4 +=================== + +Security fixes +-------------- + +* Fixed CVE-2024-34055_: + Cyrus-IMAP through 3.8.2 and 3.10.0-beta2 allow authenticated attackers + to cause unbounded memory allocation by sending many LITERALs in a + single command. + + The IMAP protocol allows for command arguments to be LITERALs of + negotiated length, and for these the server allocates memory to + receive the content before instructing the client to proceed. The + allocated memory is released when the whole command has been received + and processed. + + The IMAP protocol has a number commands that specify an unlimited + number of arguments, for example SEARCH. Each of these arguments can + be a LITERAL, for which memory will be allocated and not released + until the entire command has been received and processed. This can run + a server out of memory, with varying consequences depending on the + server's OOM policy. + + Discovered by Damian Poddebniak. + + Two limits, with corresponding :cyrusman:`imapd.conf(5)` options, have + been added to address this: + + * ``maxargssize`` (default: unlimited): limits the overall length of a + single IMAP command. Deployments should configure this to a size that + suits their system resources and client usage patterns + * ``maxliteral`` (default: 128K): limits the length of individual IMAP + LITERALs + + Connections sending commands that would exceed these limits will see the + command fail, or the connection closed, depending on the specific context. + The error message will contain the ``[TOOBIG]`` response code. + + These limits may be set small without affecting message uploads, as the + APPEND command's message literal is limited by ``maxmessagesize``, not by + these new options. + +.. _CVE-2024-34055: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-34055 diff --git a/docsrc/imap/download/release-notes/3.6/x/3.6.6.rst b/docsrc/imap/download/release-notes/3.6/x/3.6.6.rst new file mode 100644 index 0000000000..43090349b6 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.6/x/3.6.6.rst @@ -0,0 +1,22 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.6.6 Release Notes +============================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.6/cyrus-imapd-3.6.6.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.6.6/cyrus-imapd-3.6.6.tar.gz.sig + +.. _relnotes-3.6.6-changes: + +Changes since 3.6.5 +=================== + +Bug fixes +--------- + +* Fixed :issue:`4932`: LITERAL+ broken in mupdate +* Fixed :issue:`4947`: updating a script on a pre-sieve mailbox server + resulted in an empty script on a sieve-mailbox replica diff --git a/docsrc/imap/download/release-notes/3.7/index.rst b/docsrc/imap/download/release-notes/3.7/index.rst new file mode 100644 index 0000000000..ddfcb68403 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.7/index.rst @@ -0,0 +1,21 @@ +.. _imap-release-notes-3.7: + +=================== +Cyrus IMAP 3.7 Tags +=================== + +.. warning:: + + The 3.7 series are tagged snapshots of the master branch, and should be + considered for **testing purposes** and **bleeding-edge features** only. + We will try to tag these snapshots at coherent development points, but + there will generally be **large breaking changes** occurring between + releases in this series. + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.?.?-alpha* + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.7/x/3.7.0-alpha0.rst b/docsrc/imap/download/release-notes/3.7/x/3.7.0-alpha0.rst new file mode 100644 index 0000000000..c153ffa669 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.7/x/3.7.0-alpha0.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +================================= +Cyrus IMAP 3.7.0-alpha0 Tag Notes +================================= + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to + tag these snapshots at coherent development points, but there will + generally be **large breaking changes** occurring between releases in this + series. + +.. _relnotes-3.7.0-alpha0-changes: + +Major changes since the 3.6 series +================================== + +* None yet! + +Updates to default configuration +================================ + +* None yet! + +Security fixes +============== + +* None yet! + +Significant bugfixes +==================== + +* None yet! diff --git a/docsrc/imap/download/release-notes/3.8/index.rst b/docsrc/imap/download/release-notes/3.8/index.rst new file mode 100644 index 0000000000..14f4261210 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/index.rst @@ -0,0 +1,15 @@ +.. _imap-release-notes-3.8: + +======================= +Cyrus IMAP 3.8 Releases +======================= + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.?.*-alpha* + x/?.?.*-beta* + x/?.?.*-rc* + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.0-alpha0.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.0-alpha0.rst new file mode 100644 index 0000000000..f39eeb65ec --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.0-alpha0.rst @@ -0,0 +1,46 @@ +:tocdepth: 3 + +===================================== +Cyrus IMAP 3.8.0-alpha0 Release Notes +===================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0-alpha0/cyrus-imapd-3.8.0-alpha0.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0-alpha0/cyrus-imapd-3.8.0-alpha0.tar.gz.sig + +.. _relnotes-3.8.0-alpha0_changes: + +Major changes since the 3.6 series +================================== + +* None so far + +.. _relnotes_3.8.0-alpha0_storage_changes: + +Storage changes +=============== + +* None so far + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* None so far + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* None so far diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.0-beta1.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.0-beta1.rst new file mode 100644 index 0000000000..f826b7bd1d --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.0-beta1.rst @@ -0,0 +1,112 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.8.0-beta1 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0-beta1/cyrus-imapd-3.8.0-beta1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0-beta1/cyrus-imapd-3.8.0-beta1.tar.gz.sig + +.. _relnotes-3.8.0-beta1_changes: + +Major changes since the 3.6 series +================================== + +* Adds the ability for replication to stage message uploads to the + archive partition rather than the spool partition. +* Adds long-options support to various command line tools. +* Adds a new BYTESIZE smart type for imapoptions that set sizes. +* Removes empty lines from :cyrusman:`cyr_expire(8)` verbose output. If you + parse this output with external tools, those may need updating to match. +* Adds a module to ptloader which speaks HTTP. See the "pts_module" and + "httppts_uri" options in :cyrusman:`imapd.conf(5)`. +* Adds support for IMAP Multimailbox Search (:rfc:`7377`). +* Adds support for IMAP Saved Search Results (:rfc:`5182`). +* Advertise support for IMAP URL-PARTIAL (:rfc:`5550`). +* Implements the JMAP calendars specification + (:draft:`draft-ietf-jmap-calendars`). +* Adds support for a new read-only ``\Scheduled`` mailbox that contains + emails created via JMAP EmailSubmission/set that are to be sent + at a later date/time. Also extends the JMAP EmailSubmission object + with optional instructions for moving the message into another mailbox + after it has been sent. +* Maps JMAP CalendarEvent privacy to the newly introduced iCalendar + X-JMAP-PRIVACY property rather than CLASS. See + :ref:`upgrade_3.8.0_jmap_caldav_changes` in the upgrade instructions. +* Improves error handling and reporting from :cyrusman:`mbexamine(8)`. If you + have custom tooling that calls mbexamine, it may need updating. +* Sieve: Remove support for creating scripts with the deprecated + ``imapflags`` capability and ``mark`` / ``unmark`` actions. See + :ref:`upgrade_3.8.0_sieve_changes` in the upgrade instructions. +* Lock ordering fixes should result in fewer "resource deadlock avoided" + errors. + +.. _relnotes_3.8.0-beta1_storage_changes: + +Storage changes +=============== + +* None so far + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* Adds a new BYTESIZE smart type for imapoptions that set sizes. This allows + sizes to be specified in "B", "KB", "MB", "GB" for better readability. + + These :cyrusman:`imapd.conf(5)` options are changed in some way: + + * archive_maxsize + * autocreate_quota + * autocreatequota + * backup_compact_minsize + * backup_compact_maxsize + * event_content_size + * icalendar_max_size + * jmap_preview_length + * jmap_max_size_upload + * jmap_max_size_blob_set + * jmap_max_size_request + * jmap_mail_max_size_attachments_per_email + * maxmessagesize + * maxquoted + * maxword + * quotawarn -> quotawarnpercent + * quotawarnkb -> quotawarnsize + * search_maxsize + * sieve_maxscriptsize + * vcard_max_size + * webdav_attachments_max_binary_attach_size + + This feature is transparent over upgrade and downgrade, provided the + imapd.conf remains unchanged. + + Admins may update their imapd.conf to take advantage of the readability of + the new smart type, but after doing so will no longer be able to downgrade + to a version without this feature (unless they also revert their + imapd.conf). + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`4380`: XFER to newer backends now assumes at least the current + mailbox version, rather than the oldest supported mailbox version. +* Fixed :issue:`3771`: Special-Use annotations lost on XFER +* Fixed :issue:`4187`: :cyrusman:`ctl_mboxlist(8)` can now detect and report + broken UUID mailboxes. Thanks Matthias Hunstock. +* Fixed :issue:`4383`: rolling :cyrusman:`squatter(8)` only compacted its index + databases at shutdown. diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.0-beta2.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.0-beta2.rst new file mode 100644 index 0000000000..879423a7ae --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.0-beta2.rst @@ -0,0 +1,114 @@ +:tocdepth: 3 + +==================================== +Cyrus IMAP 3.8.0-beta2 Release Notes +==================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0-beta2/cyrus-imapd-3.8.0-beta2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0-beta2/cyrus-imapd-3.8.0-beta2.tar.gz.sig + +.. _relnotes-3.8.0-beta2_changes: + +Major changes since the 3.6 series +================================== + +* Adds the ability for replication to stage message uploads to the + archive partition rather than the spool partition. +* Adds long-options support to various command line tools. +* Adds a new BYTESIZE smart type for imapoptions that set sizes. +* Removes empty lines from :cyrusman:`cyr_expire(8)` verbose output. If you + parse this output with external tools, those may need updating to match. +* Adds a module to ptloader which speaks HTTP. See the "pts_module" and + "httppts_uri" options in :cyrusman:`imapd.conf(5)`. +* Adds support for IMAP Multimailbox Search (:rfc:`7377`). +* Adds support for IMAP Saved Search Results (:rfc:`5182`). +* Advertise support for IMAP URL-PARTIAL (:rfc:`5550`). +* Implements the JMAP calendars specification + (:draft:`draft-ietf-jmap-calendars`). +* Adds support for a new read-only ``\Scheduled`` mailbox that contains + emails created via JMAP EmailSubmission/set that are to be sent + at a later date/time. Also extends the JMAP EmailSubmission object + with optional instructions for moving the message into another mailbox + after it has been sent. +* Maps JMAP CalendarEvent privacy to the newly introduced iCalendar + X-JMAP-PRIVACY property rather than CLASS. See + :ref:`upgrade_3.8.0_jmap_caldav_changes` in the upgrade instructions. +* Improves error handling and reporting from :cyrusman:`mbexamine(8)`. If you + have custom tooling that calls mbexamine, it may need updating. +* Sieve: Remove support for creating scripts with the deprecated + ``imapflags`` capability and ``mark`` / ``unmark`` actions. See + :ref:`upgrade_3.8.0_sieve_changes` in the upgrade instructions. +* Lock ordering fixes should result in fewer "resource deadlock avoided" + errors. + +.. _relnotes_3.8.0-beta2_storage_changes: + +Storage changes +=============== + +* None so far + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* Adds a new BYTESIZE smart type for imapoptions that set sizes. This allows + sizes to be specified in "B", "KB", "MB", "GB" for better readability. + + These :cyrusman:`imapd.conf(5)` options are changed in some way: + + * archive_maxsize + * autocreate_quota + * autocreatequota + * backup_compact_minsize + * backup_compact_maxsize + * event_content_size + * icalendar_max_size + * jmap_preview_length + * jmap_max_size_upload + * jmap_max_size_blob_set + * jmap_max_size_request + * jmap_mail_max_size_attachments_per_email + * maxmessagesize + * maxquoted + * maxword + * quotawarn -> quotawarnpercent + * quotawarnkb -> quotawarnsize + * search_maxsize + * sieve_maxscriptsize + * vcard_max_size + * webdav_attachments_max_binary_attach_size + + This feature is transparent over upgrade and downgrade, provided the + imapd.conf remains unchanged. + + Admins may update their imapd.conf to take advantage of the readability of + the new smart type, but after doing so will no longer be able to downgrade + to a version without this feature (unless they also revert their + imapd.conf). + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`4380`: XFER to newer backends now assumes at least the current + mailbox version, rather than the oldest supported mailbox version. +* Fixed :issue:`3771`: Special-Use annotations lost on XFER +* Fixed :issue:`4187`: :cyrusman:`ctl_mboxlist(8)` can now detect and report + broken UUID mailboxes. Thanks Matthias Hunstock. +* Fixed :issue:`4383`: rolling :cyrusman:`squatter(8)` only compacted its index + databases at shutdown. +* Fixed :issue:`4439`: GETMETADATA wasn't proxied correctly to murder backends. + Thanks Stéphane GAUBERT. diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.0-rc1.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.0-rc1.rst new file mode 100644 index 0000000000..e54f0c884a --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.0-rc1.rst @@ -0,0 +1,114 @@ +:tocdepth: 3 + +================================== +Cyrus IMAP 3.8.0-rc1 Release Notes +================================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0-rc1/cyrus-imapd-3.8.0-rc1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0-rc1/cyrus-imapd-3.8.0-rc1.tar.gz.sig + +.. _relnotes-3.8.0-rc1_changes: + +Major changes since the 3.6 series +================================== + +* Adds the ability for replication to stage message uploads to the + archive partition rather than the spool partition. +* Adds long-options support to various command line tools. +* Adds a new BYTESIZE smart type for imapoptions that set sizes. +* Removes empty lines from :cyrusman:`cyr_expire(8)` verbose output. If you + parse this output with external tools, those may need updating to match. +* Adds a module to ptloader which speaks HTTP. See the "pts_module" and + "httppts_uri" options in :cyrusman:`imapd.conf(5)`. +* Adds support for IMAP Multimailbox Search (:rfc:`7377`). +* Adds support for IMAP Saved Search Results (:rfc:`5182`). +* Advertise support for IMAP URL-PARTIAL (:rfc:`5550`). +* Implements the JMAP calendars specification + (:draft:`draft-ietf-jmap-calendars`). +* Adds support for a new read-only ``\Scheduled`` mailbox that contains + emails created via JMAP EmailSubmission/set that are to be sent + at a later date/time. Also extends the JMAP EmailSubmission object + with optional instructions for moving the message into another mailbox + after it has been sent. +* Maps JMAP CalendarEvent privacy to the newly introduced iCalendar + X-JMAP-PRIVACY property rather than CLASS. See + :ref:`upgrade_3.8.0_jmap_caldav_changes` in the upgrade instructions. +* Improves error handling and reporting from :cyrusman:`mbexamine(8)`. If you + have custom tooling that calls mbexamine, it may need updating. +* Sieve: Remove support for creating scripts with the deprecated + ``imapflags`` capability and ``mark`` / ``unmark`` actions. See + :ref:`upgrade_3.8.0_sieve_changes` in the upgrade instructions. +* Lock ordering fixes should result in fewer "resource deadlock avoided" + errors. + +.. _relnotes_3.8.0-rc1_storage_changes: + +Storage changes +=============== + +* None so far + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* Adds a new BYTESIZE smart type for imapoptions that set sizes. This allows + sizes to be specified in "B", "KB", "MB", "GB" for better readability. + + These :cyrusman:`imapd.conf(5)` options are changed in some way: + + * archive_maxsize + * autocreate_quota + * autocreatequota + * backup_compact_minsize + * backup_compact_maxsize + * event_content_size + * icalendar_max_size + * jmap_preview_length + * jmap_max_size_upload + * jmap_max_size_blob_set + * jmap_max_size_request + * jmap_mail_max_size_attachments_per_email + * maxmessagesize + * maxquoted + * maxword + * quotawarn -> quotawarnpercent + * quotawarnkb -> quotawarnsize + * search_maxsize + * sieve_maxscriptsize + * vcard_max_size + * webdav_attachments_max_binary_attach_size + + This feature is transparent over upgrade and downgrade, provided the + imapd.conf remains unchanged. + + Admins may update their imapd.conf to take advantage of the readability of + the new smart type, but after doing so will no longer be able to downgrade + to a version without this feature (unless they also revert their + imapd.conf). + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`4380`: XFER to newer backends now assumes at least the current + mailbox version, rather than the oldest supported mailbox version. +* Fixed :issue:`3771`: Special-Use annotations lost on XFER +* Fixed :issue:`4187`: :cyrusman:`ctl_mboxlist(8)` can now detect and report + broken UUID mailboxes. Thanks Matthias Hunstock. +* Fixed :issue:`4383`: rolling :cyrusman:`squatter(8)` only compacted its index + databases at shutdown. +* Fixed :issue:`4439`: GETMETADATA wasn't proxied correctly to murder backends. + Thanks Stéphane GAUBERT. diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.0.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.0.rst new file mode 100644 index 0000000000..51a2aecf64 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.0.rst @@ -0,0 +1,115 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.8.0 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0/cyrus-imapd-3.8.0.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.0/cyrus-imapd-3.8.0.tar.gz.sig + +.. _relnotes-3.8.0_changes: + +Major changes since the 3.6 series +================================== + +* Adds the ability for replication to stage message uploads to the + archive partition rather than the spool partition. +* Adds long-options support to various command line tools. +* Adds a new BYTESIZE smart type for imapoptions that set sizes. +* Removes empty lines from :cyrusman:`cyr_expire(8)` verbose output. If you + parse this output with external tools, those may need updating to match. +* Adds a module to ptloader which speaks HTTP. See the "pts_module" and + "httppts_uri" options in :cyrusman:`imapd.conf(5)`. +* Adds support for IMAP Multimailbox Search (:rfc:`7377`). +* Adds support for IMAP Saved Search Results (:rfc:`5182`). +* Advertise support for IMAP URL-PARTIAL (:rfc:`5550`). +* Implements the JMAP calendars specification + (:draft:`draft-ietf-jmap-calendars`). +* Adds support for a new read-only ``\Scheduled`` mailbox that contains + emails created via JMAP EmailSubmission/set that are to be sent + at a later date/time. Also extends the JMAP EmailSubmission object + with optional instructions for moving the message into another mailbox + after it has been sent. +* Maps JMAP CalendarEvent privacy to the newly introduced iCalendar + X-JMAP-PRIVACY property rather than CLASS. See + :ref:`upgrade_3.8.0_jmap_caldav_changes` in the upgrade instructions. +* Improves error handling and reporting from :cyrusman:`mbexamine(8)`. If you + have custom tooling that calls mbexamine, it may need updating. +* Sieve: Remove support for creating scripts with the deprecated + ``imapflags`` capability and ``mark`` / ``unmark`` actions. See + :ref:`upgrade_3.8.0_sieve_changes` in the upgrade instructions. +* Lock ordering fixes should result in fewer "resource deadlock avoided" + errors. + +.. _relnotes_3.8.0_storage_changes: + +Storage changes +=============== + +* None in 3.8. But if your upgrade is skipping over 3.6, please do not miss + :ref:`3.6.0 Storage changes ` + +Updates to default configuration +================================ + +The :cyrusman:`cyr_info(8)` `conf`, `conf-all` and `conf-default` subcommands +accept an `-s ` argument to highlight :cyrusman:`imapd.conf(5)` +options that are new or whose behaviour has changed since the specified +version. We recommend using this when evaluating a new Cyrus version to +check which configuration options you will need to examine and maybe set or +change during the process. + +* Adds a new BYTESIZE smart type for imapoptions that set sizes. This allows + sizes to be specified in "B", "KB", "MB", "GB" for better readability. + + These :cyrusman:`imapd.conf(5)` options are changed in some way: + + * archive_maxsize + * autocreate_quota + * autocreatequota + * backup_compact_minsize + * backup_compact_maxsize + * event_content_size + * icalendar_max_size + * jmap_preview_length + * jmap_max_size_upload + * jmap_max_size_blob_set + * jmap_max_size_request + * jmap_mail_max_size_attachments_per_email + * maxmessagesize + * maxquoted + * maxword + * quotawarn -> quotawarnpercent + * quotawarnkb -> quotawarnsize + * search_maxsize + * sieve_maxscriptsize + * vcard_max_size + * webdav_attachments_max_binary_attach_size + + This feature is transparent over upgrade and downgrade, provided the + imapd.conf remains unchanged. + + Admins may update their imapd.conf to take advantage of the readability of + the new smart type, but after doing so will no longer be able to downgrade + to a version without this feature (unless they also revert their + imapd.conf). + +Security fixes +============== + +* None so far + +Significant bugfixes +==================== + +* Fixed :issue:`4380`: XFER to newer backends now assumes at least the current + mailbox version, rather than the oldest supported mailbox version. +* Fixed :issue:`3771`: Special-Use annotations lost on XFER +* Fixed :issue:`4187`: :cyrusman:`ctl_mboxlist(8)` can now detect and report + broken UUID mailboxes. Thanks Matthias Hunstock. +* Fixed :issue:`4383`: rolling :cyrusman:`squatter(8)` only compacted its index + databases at shutdown. +* Fixed :issue:`4439`: GETMETADATA wasn't proxied correctly to murder backends. + Thanks Stéphane GAUBERT. diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.1.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.1.rst new file mode 100644 index 0000000000..1440787056 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.1.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.8.1 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.1/cyrus-imapd-3.8.1.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.1/cyrus-imapd-3.8.1.tar.gz.sig + +.. _relnotes-3.8.1-changes: + +Changes since 3.8.0 +=================== + +Build changes +------------- + +* Fixed :issue:`4071`: libical >= 3.0.10 is required +* Fixed :issue:`4561`: retired custom manpage generator + +Bug fixes +--------- + +* Fixed :issue:`4309`: incorrect error code used for JMAP + invalidResultReference errors +* Fixed :issue:`4523`: httpd crash with SIGSEGV when listing calendar +* Fixed :issue:`4544`: leaked SSL_SESSION during backend disconnect +* Fixed :issue:`4293`: cyr_cd.sh is bash, not sh +* Fixed :issue:`4574`: potential crash in jmap_email_parse +* Fixed :issue:`4567`: invalid FETCH BINARY response for sections with + unknown Content-Transfer-Encoding +* Fixed :issue:`4370`: XFER did not fully remove source mailbox + +Other changes +------------- + +* Fixed :issue:`4558`: better cyrusdb / ``ctl_cyrusdb -r`` UX diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.2.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.2.rst new file mode 100644 index 0000000000..95d471e57e --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.2.rst @@ -0,0 +1,71 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.8.2 Release Notes +============================== + +Download from GitHub: + + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.2/cyrus-imapd-3.8.2.tar.gz + * https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.2/cyrus-imapd-3.8.2.tar.gz.sig + +.. _relnotes-3.8.2-changes: + +Changes since 3.8.1 +=================== + +Build changes +------------- + +* Fixed: Cassandane tests now pass on Debian Bookworm +* PCRE2 is now supported and detected with pkg-config. If both PCRE and PCRE2 + are available, the older PCRE will be preferred. To force use of PCRE2 in + this situation, run configure with the ``--disable-pcre`` option. Please + note that on Debian-based systems, PCRE (the old one, no longer maintained) + is called "pcre3". Yes, this is confusing. +* Fixed :issue:`4770`: missing include when ssl unavailable (thanks Дилян + Палаузов) + +Bug fixes +--------- + +* Fixed :issue:`4650`: :cyrusman:`cyr_info(8)` ``conf``, ``conf-all``, and + ``conf-default`` subcommands no longer crash +* Fixed: squat db reindexes are no longer always incremental +* Fixed: squat db corruption from unintentional indexing of fields + intended to be skipped. Squat search databases may benefit from a full + (non-incremental) reindex +* Fixed :issue:`4660`: squat db out of bounds access in incremental reindex + docID map +* Fixed :issue:`4692`: squat db searches now handle unindexed messages + correctly again (thanks Gabriele Bulfon) +* Fixed :issue:`4710`: crash on copy/append fail in mailbox with custom + user flags +* Fixed: GETMETADATA no longer shows internal DAV mailboxes (unless + ``imapmagicplus`` is enabled and the user is authenticated as + ``username+dav@domain``) +* Fixed :issue:`4717`: pop3d now avoids splitting ``".\r\n"`` across packet + boundaries, which can confuse some clients +* Fixed :issue:`4756`: potential uninitialized access in extract_convdata +* Fixed :issue:`4771`: potential invalid read in message_parse_received_date + (thanks Дилян Палаузов) +* Fixed :issue:`4663`: strip known-timezones from iCalendar object (thanks + Дилян Палаузов) +* Fixed :issue:`4722`: failure in :cyrusman:`dav_reconstruct(1)` when last + message in mailbox was expunged (thanks Дилян Палаузов) +* Fixed :issue:`4758`: fix renaming mailbox between users +* Fixed :issue:`4424`: DAV requests now respond with 507 rather than 500 when + mailbox_maxmessages limits exceeded +* Fixed :issue:`4804`: mailbox_maxmessages limits now applied correctly +* Fixed :issue:`4785`: crashes during TLS shutdown (thanks Дилян Палаузов) +* Fixed :issue:`4820`: PREVIEW message attribute must be qstring/literal, + not an atom +* Fixed :issue:`4828`: the default addressbook can no longer be deleted + +Other changes +------------- + +* Fixed :issue:`4671`: leniently handle unencoded valid UTF-8 strings in MIME + headers +* Fixed :issue:`4790`: some man pages were missing from distribution tarballs + (thanks Jakob Gahde) diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.3.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.3.rst new file mode 100644 index 0000000000..89a478da50 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.3.rst @@ -0,0 +1,57 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.8.3 Release Notes +============================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.3/cyrus-imapd-3.8.3.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.3/cyrus-imapd-3.8.3.tar.gz.sig + +.. _relnotes-3.8.3-changes: + +Changes since 3.8.2 +=================== + +Security fixes +-------------- + +* Fixed CVE-2024-34055_: + Cyrus-IMAP through 3.8.2 and 3.10.0-beta2 allow authenticated attackers + to cause unbounded memory allocation by sending many LITERALs in a + single command. + + The IMAP protocol allows for command arguments to be LITERALs of + negotiated length, and for these the server allocates memory to + receive the content before instructing the client to proceed. The + allocated memory is released when the whole command has been received + and processed. + + The IMAP protocol has a number commands that specify an unlimited + number of arguments, for example SEARCH. Each of these arguments can + be a LITERAL, for which memory will be allocated and not released + until the entire command has been received and processed. This can run + a server out of memory, with varying consequences depending on the + server's OOM policy. + + Discovered by Damian Poddebniak. + + Two limits, with corresponding :cyrusman:`imapd.conf(5)` options, have + been added to address this: + + * ``maxargssize`` (default: unlimited): limits the overall length of a + single IMAP command. Deployments should configure this to a size that + suits their system resources and client usage patterns + * ``maxliteral`` (default: 128K): limits the length of individual IMAP + LITERALs + + Connections sending commands that would exceed these limits will see the + command fail, or the connection closed, depending on the specific context. + The error message will contain the ``[TOOBIG]`` response code. + + These limits may be set small without affecting message uploads, as the + APPEND command's message literal is limited by ``maxmessagesize``, not by + these new options. + +.. _CVE-2024-34055: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-34055 diff --git a/docsrc/imap/download/release-notes/3.8/x/3.8.4.rst b/docsrc/imap/download/release-notes/3.8/x/3.8.4.rst new file mode 100644 index 0000000000..4d2495e601 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.8/x/3.8.4.rst @@ -0,0 +1,31 @@ +:tocdepth: 3 + +============================== +Cyrus IMAP 3.8.4 Release Notes +============================== + +Download from GitHub: + +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.4/cyrus-imapd-3.8.4.tar.gz +* https://github.com/cyrusimap/cyrus-imapd/releases/download/cyrus-imapd-3.8.4/cyrus-imapd-3.8.4.tar.gz.sig + +.. _relnotes-3.8.4-changes: + +Changes since 3.8.3 +=================== + +Build changes +------------- + +Bug fixes +--------- + +* Fixed :issue:`4932`: LITERAL+ broken in mupdate +* Fixed :issue:`4955`: email headers in iMIP messages were not correctly + encoded +* Fixed :issue:`4947`: updating a script on a pre-sieve mailbox server + resulted in an empty script on a sieve-mailbox replica + +Other changes +------------- + diff --git a/docsrc/imap/download/release-notes/3.9/index.rst b/docsrc/imap/download/release-notes/3.9/index.rst new file mode 100644 index 0000000000..e474ddd063 --- /dev/null +++ b/docsrc/imap/download/release-notes/3.9/index.rst @@ -0,0 +1,21 @@ +.. _imap-release-notes-3.9: + +=================== +Cyrus IMAP 3.9 Tags +=================== + +.. warning:: + + The 3.9 series are tagged snapshots of the master branch, and should be + considered for **testing purposes** and **bleeding-edge features** only. + We will try to tag these snapshots at coherent development points, but + there will generally be **large breaking changes** occurring between + releases in this series. + +.. toctree:: + :maxdepth: 1 + :glob: + + x/?.?.?-alpha* + x/?.?.? + x/?.?.?? diff --git a/docsrc/imap/download/release-notes/3.9/x/3.9.0-alpha0.rst b/docsrc/imap/download/release-notes/3.9/x/3.9.0-alpha0.rst new file mode 100644 index 0000000000..35b688fe7d --- /dev/null +++ b/docsrc/imap/download/release-notes/3.9/x/3.9.0-alpha0.rst @@ -0,0 +1,39 @@ +:tocdepth: 3 + +================================= +Cyrus IMAP 3.9.0-alpha0 Tag Notes +================================= + +Unavailable for download as this is a development branch only. + +Access is via git. + +.. warning:: + + This should be considered for + **testing purposes** and **bleeding-edge features** only. We will try to + tag these snapshots at coherent development points, but there will + generally be **large breaking changes** occurring between releases in this + series. + +.. _relnotes-3.9.0-alpha0-changes: + +Major changes since the 3.8 series +================================== + +* None yet! + +Updates to default configuration +================================ + +* None yet! + +Security fixes +============== + +* None yet! + +Significant bugfixes +==================== + +* None yet! diff --git a/docsrc/imap/download/release-notes/index.rst b/docsrc/imap/download/release-notes/index.rst index 75b8565bb6..79d154c25b 100644 --- a/docsrc/imap/download/release-notes/index.rst +++ b/docsrc/imap/download/release-notes/index.rst @@ -2,12 +2,12 @@ Release Notes ============= -Cyrus has switched to an `odd-even release cycle `_. +Cyrus uses an `odd-even release cycle `_. -With our versioning system using ``major.minor.micro`` numbering, the minor number -reveals whether a version is stable (even), or development-only (odd). +With our versioning system using ``major.minor.micro`` numbering, the minor (middle) +number reveals whether a version is stable (even), or development-only (odd). -A 3.0.1 release is stable, but a 3.1.4 release is developmental only. +A 3.\ **0**\ .1 release is stable, but a 3.\ **1**\ .4 release is developmental only. Stable Version ============== @@ -22,20 +22,64 @@ Latest development snapshot is |imap_development_release_notes|. Documentation a .. warning:: - These are tagged snapshots of the master branch, and should be considered for - **testing purposes** and **bleeding-edge features** only. We will try to tag these - snapshots at coherent development points, but there will generally be **large - breaking changes** occurring between releases in this series. + Development versions are tagged snapshots of the master branch, and should + be considered for **testing purposes** and **bleeding-edge features** only. + We will try to tag these snapshots at coherent development points, but + there will generally be **large breaking changes** occurring between + releases in this series. Supported Product Series ======================== -.. Hide the release notes for unstable series +Series 3.10 +----------- + +Documentation at :cyrus-3.10:`/`. .. toctree:: - :hidden: + :maxdepth: 1 - 3.1/index + 3.10/index + +Series 3.8 +---------- + +Documentation at :cyrus-3.8:`/`. + +.. toctree:: + :maxdepth: 1 + + 3.8/index + +Series 3.6 +---------- + +Documentation at :cyrus-3.6:`/`. + +.. toctree:: + :maxdepth: 1 + + 3.6/index + +Series 3.4 +---------- + +Documentation at :cyrus-3.4:`/`. + +.. toctree:: + :maxdepth: 1 + + 3.4/index + +Series 3.2 +---------- + +Documentation at :cyrus-3.2:`/`. + +.. toctree:: + :maxdepth: 1 + + 3.2/index Series 3.0 ---------- @@ -44,7 +88,6 @@ Documentation at :cyrus-3.0:`/`. .. toctree:: :maxdepth: 1 - :glob: 3.0/index @@ -55,20 +98,19 @@ Documentation at :cyrus-2.5:`/`. .. toctree:: :maxdepth: 1 - :glob: 2.5/index -Series 2.4 ----------- +Development snapshots +===================== .. toctree:: :maxdepth: 1 - :glob: - - 2.4/index - 2.4-dav/index + 3.7/index + 3.5/index + 3.3/index + 3.1/index Older Versions ============== @@ -78,27 +120,18 @@ Series 1 .. toctree:: :maxdepth: 1 - :glob: 1/1.x.x -Series 2: 2.0 - 2.3 +Series 2: 2.0 - 2.4 ------------------- .. toctree:: :maxdepth: 1 - :glob: 2.0/2.0.x 2.1/2.1.x 2.2/2.2.x 2.3/index - - -.. toctree:: - :hidden: - 2.4/index 2.4-dav/index - 2.5/index - 3.0/index diff --git a/docsrc/imap/download/upgrade.rst b/docsrc/imap/download/upgrade.rst index fd8fd54815..e52b5d5589 100644 --- a/docsrc/imap/download/upgrade.rst +++ b/docsrc/imap/download/upgrade.rst @@ -1,49 +1,277 @@ +.. highlight:: none + .. _upgrade: -================ -Upgrading to 3.0 -================ +================= +Upgrading to 3.10 +================= .. note:: - This guide assumes that you are familiar and comfortable with administration of a - Cyrus installation, and system administration in general. + This guide assumes that you are familiar and comfortable with administration + of a Cyrus installation, and system administration in general. - It assumes you are installing from source or tarball. If you want to install from package, - use the upgrade instructions from the package provider. + It assumes you are installing from source or tarball. If you want to install + from package, use the upgrade instructions from the package provider. .. contents:: Upgrading: an overview :local: -.. note:: - - .. include:: /assets/cyrus-more-memory-post23.rst - 1. Preparation -------------- Things to consider **before** you begin: +Versions to upgrade from +######################## + +Before upgrading to 3.10, your deployment should be running one of: + +* 3.6.3 (or later) +* 3.8.1 (or later) + +If your existing deployment predates these releases, you should first upgrade +to one of these versions, let it run for a while, resolve any issues that +come up, and only then upgrade to 3.10. + Installation from tarball ######################### -You will need to install from our packaged tarball. We provide a full list of libraries that Debian requires, but we aren't able to test all platforms: you may find you need to install additional or different libraries to support v3.0. +You will need to install from our packaged tarball. We provide a full list of +libraries that Debian requires, but we aren't able to test all platforms: you +may find you need to install additional or different libraries to support v3.8. + +Storage changes +############### + +In 3.6 and later, mailboxes and user metadata directories are organised on +disk by UUID rather than by mailbox name. + +At startup (or when you first run the updated `ctl_cyrusdb -r` manually), +:cyrusman:`ctl_cyrusdb(8)` will upgrade mailboxes.db to accommodate both +old-style and new-style storage, if it didn't already. + +By default, new top-level mailboxes will be created in the new style. +Mailboxes that already exist in the old style will remain in the old style +until you convert them with :cyrusman:`relocate_by_id(8)`. New mailboxes +below the top level will be created in the same style as their parent mailbox. + +The new :cyrusman:`cyr_ls(8)` tool can be used to examine the on-disk +contents of a given mailbox name. :cyrusman:`mbpath(8)` can be used to find +where on disk a given mailbox and its metadata are. + +If you want new top level mailboxes to be created in the old style, you +can enable the `mailbox_legacy_dirs` :cyrusman:`imapd.conf(5)` option, which +defaults to **off**. With this turned on, you may still use `relocate_by_id` +to convert them to the new style. + +Since 3.6, sieve scripts are stored in the '#sieve' mailbox (configurable with +the `sieve_folder` :cyrusman:`imapd.conf(5)` option). No manual steps are +necessary for upgrade: Cyrus recognises the old style storage and will +convert to the new style automatically as necessary. + + +JMAP/CalDAV changes +################### + +.. _upgrade_3.8.0_jmap_caldav_changes: + +X-JMAP-PRIVACY (since 3.8) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Prior to 3.8, Cyrus determined the JMAP CalendarEvent privacy of an iCalendar +VEVENT by the CLASS property. Since 3.8, this now gets determined by the newly +introduced X-JMAP-PRIVACY property, but the CalDAV indexes may already have +entries for the old mapping and need to be upgraded. + +Sites that use JMAP should upgrade their CalDAV database index by calling +the newly introduced JMAP method `Admin/rewriteCalendarEventPrivacy`. +This method: + +- requires the `https://cyrusimap.org/ns/jmap/admin` request capability +- must be called as an admin user (regular user calls are rejected) +- takes the optional `userIds` argument, whis is a JSON array of + userids to migrate. In absence of this argument, all users are migrated + +Site that do not use JMAP should upgrade their CalDAV database by + +- calling ``DELETE FROM ical_objs WHERE comp_flags >= 1024;`` on a user's + dav.db +- followed by calling `dav_reconstruct` for that user + +.. _upgrade_jmap_default_alarms: + +Default alarms +~~~~~~~~~~~~~~ + +Prior to 3.10, JMAP default alarms were stored on a calendar mailbox +in the following annotations: + +- ``{urn:ietf:params:xml:ns:caldav}default-alarm-vevent-datetime`` +- ``{urn:ietf:params:xml:ns:caldav}default-alarm-vevent-date`` + +When upgrading to 3.10, installations that use the experimental JMAP calendars +API must run a migration tool to separate CalDAV default alarm annotations from +JMAP annotations. This tool will remove the annotations from the calendar +mailbox and move their contents to the Cyrus-internal annotation +``/vendor/cmu/cyrus-jmap/defaultalerts`` + +CalDAV annotations on the calendar home are left as-is and are not migrated. +Typically, Apple CalDAV clients store default alarms at this location. + +To migrate, call the ``Admin/migrateCalendarDefaultAlarms`` JMAP method as an +admin user. JMAP clients need to use the +``https://cyrusimap.org/ns/jmap/admin`` capability for this method. + +This method has the following arguments: + +- ``userIds: Id[]|null (default: null)``: the list of users for which to + migrate default alarms. If null, then alarms are migrated for all users. + +- ``keepCaldavAlarms: Boolean (default: false)``: If true, the DAV annotations + are migrated but not removed from the calendar mailbox. There should be + no need to keep them, except if installations or their CalDAV clients + made use of these CalDAV annotations themselves. + +The method response contains: + +- ``migrated: Id[String[SetError|null]]``: For each userid, this is a map of + calendar id to either null on success, or an error. + +- ``notMigrated: Id[SetError]``: For each userid, contains an error that + prevented migrating this users default alarms. + +CalendarEventNotifications +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The default maximum count of CalendarEventNotifications is set to 200 +per account. Installations that need any other count or want to not +prune notifications must update the ``jmap_max_calendareventnotifs`` +:cyrusman:`imapd.conf(5)` option. + +.. _upgrade_3.8.0_sieve_changes: + +.. _upgrade_sieve_changes: + +Sieve changes +############# + +3.10 no longer supports the deprecated ``denotify`` action or ``notify`` +actions using the legacy (pre-:rfc:`5435`) syntax. + +Existing bytecode containing these actions will still be executed. + +Scripts that contain the deprecated ``denotify`` action should be rewritten +to remove them. + +Scripts that contain notify actions using the legacy syntax should be rewritten +to use the syntax in :rfc:`5435`. + +.. _upgrade_master_pid_ready_files: + +master.pid and master.ready files +################################# + +If you have something that monitors syslog looking for master's "ready for +work" message, you might consider switching to monitoring the master.ready +file instead, perhaps using Linux inotify. + +The master pidfile name is now read from imapd.conf, and defaults to +``{configdirectory}/master.pid``. If you have something that looks for this +file, you should either update it to look in the new default location, or set +``master_pid_file`` in :cyrusman:`imapd.conf(5)` to override the default. The +``-p`` option to :cyrusman:`master(8)` can still be used to override it. + +.. _upgrade_pcre2_support: + +PCRE2 support +############# + +Cyrus 3.10 will prefer PCRE2 over PCRE if both are installed. If you have both +installed and wish to use PCRE rather than PCRE2, run configure with +``--disable-pcre2``. + +If you haven't specifically installed libpcre2-dev (or whatever your system's +equivalent is), you might still have parts of pcre2 installed due to other +packages on your system depending on it. This can confuse configure into +thinking you have a usable PCRE2 when you don't. Either properly install +libpcre2-dev so Cyrus can use it, or configure Cyrus with ``--disable-pcre2`` +so that it ignores the partial installation. + +Please note that on Debian-based systems, PCRE (the old one, no longer +maintained) is called "pcre3". Yes, this is confusing. + +Cyrus Backups feature is deprecated as of 3.10 +############################################## + +Deployments that rely on the experimental Cyrus Backup feature should +start planning for an alternative backup solution, as this feature will +be removed in the future. How are you planning on upgrading? ################################## -Ideally, you will do a sandboxed test installation of 3.0 using a snapshot of your existing data before you switch off your existing installation. The rest of the instructions are assuming a sandboxed 3.0 installation. +Ideally, you will do a sandboxed test installation of 3.10 using a snapshot of +your existing data before you switch off your existing installation. The rest +of the instructions are assuming a sandboxed 3.10 installation. -If you're familiar with replication, and your current installation is 2.4 or newer, you can set up your existing -installation to replicate data to a new 3.0 installation and failover to the new installation when you're -ready. The replication protocol has been kept backwards compatible. +Upgrade by replicating +~~~~~~~~~~~~~~~~~~~~~~ + +If you're familiar with replication, and your current installation is 2.4 or +newer, you can set up your existing installation to replicate data to a new +3.10 installation and failover to the new installation when you're ready. The +replication protocol has been kept backwards compatible. + +If your old installation contains mailboxes or messages that are older than +2.4, they may not have GUID fields in their indexes (index version too old), +or they may have their GUID field set to zero. 3.10 will not accept message +replications without valid matching GUIDs, so you need to fix this on your +old installation first. + +You can check for affected mailboxes by examining the output from the +:cyrusman:`mbexamine(8)` tool: + +* mailboxes that report a 'Minor Version:' less than 10 will need to have + their index upgraded using :cyrusman:`reconstruct(8)` with the + `-V ` parameter to be at least 10. +* mailboxes containing messages that report 'GUID:0' will need to have + their GUIDs recalculated using :cyrusman:`reconstruct(8)` with the `-G` + parameter. + +If you have a large amount of data, these reconstructs will take a long time, +so it's better to identify the mailboxes needing attention and target them +specifically. But if you have a small amount of data, it might be less work +to just `reconstruct -G -V max` everything. + +Upgrade in place +~~~~~~~~~~~~~~~~ If you are upgrading in place, you will need to shut down Cyrus entirely while you install the new package. If your old installation was using Berkeley DB format databases, you will need to convert or -upgrade the databases **before** you upgrade. Cyrus v3.0 does not +upgrade the databases **before** you upgrade. Cyrus 3.10 does not support Berkeley DB at all. +.. note:: + + If you are upgrading from Cyrus version 2.5 or earlier, + and your system is configured with the following combination + in :cyrusman:`imapd.conf(5)`:: + + fulldirhash: yes + hashimapspool: either yes or no + unixhierarchysep: yes + + then you will not be able to upgrade-in-place. This is due to + a change in how directory hashes are calculated for users whose + localpart contains a dot, which was introduced in 3.0.0. After + an in-place upgrade, Cyrus will not be able to find these users' + metadata and/or mailboxes. + + If you have this configuration, you will need to upgrade by + replicating, not in place. + Do What As Who? ############### @@ -67,25 +295,33 @@ commands, such as ``rsync`` or ``scp``. We strongly recommend that you read this entire document before upgrading. -2. Install new 3.0 Cyrus ------------------------- - -Download the release :ref:`3.0 package tarball `. - -Fetch the libraries for your platform. The full list (including all optional packages) for Debian is:: +2. Install new 3.10 Cyrus +------------------------- - sudo apt-get install -y autoconf automake autotools-dev bash-completion bison build-essential comerr-dev \ - debhelper flex g++ git gperf groff heimdal-dev libbsd-resource-perl libclone-perl libconfig-inifiles-perl \ - libcunit1-dev libdatetime-perl libdb-dev libdigest-sha-perl libencode-imaputf7-perl libfile-chdir-perl \ - libglib2.0-dev libical-dev libio-socket-inet6-perl libio-stringy-perl libjansson-dev libldap2-dev \ - libmysqlclient-dev libnet-server-perl libnews-nntpclient-perl libpam0g-dev libpcre3-dev libsasl2-dev \ - libsnmp-dev libsqlite3-dev libssl-dev libtest-unit-perl libtool libunix-syslog-perl liburi-perl \ - libxapian-dev libxml-generator-perl libxml-xpath-perl libxml2-dev libwrap0-dev libzephyr-dev lsb-base \ - net-tools perl php-cli php-curl pkg-config po-debconf tcl-dev \ - transfig uuid-dev vim wamerican wget xutils-dev zlib1g-dev sasl2-bin rsyslog sudo acl telnet +Download the release :ref:`3.10 package tarball `. + +Fetch the libraries for your platform. The full list (including all optional +packages) for Debian is:: + + sudo apt-get install -y autoconf automake autotools-dev bash-completion \ + bison build-essential comerr-dev debhelper flex g++ git gperf groff \ + heimdal-dev libbsd-resource-perl libclone-perl libconfig-inifiles-perl \ + libcunit1-dev libdatetime-perl libdigest-sha-perl libencode-imaputf7-perl \ + libfile-chdir-perl libglib2.0-dev libical-dev libio-socket-inet6-perl \ + libio-stringy-perl libjansson-dev libldap2-dev libmysqlclient-dev \ + libnet-server-perl libnews-nntpclient-perl libpam0g-dev libpcre2-dev \ + libsasl2-dev libsqlite3-dev libssl-dev libtest-unit-perl libtool \ + libunix-syslog-perl liburi-perl libxapian-dev libxml-generator-perl \ + libxml-xpath-perl libxml2-dev libwrap0-dev libzephyr-dev lsb-base \ + net-tools perl php-cli php-curl pkg-config po-debconf tcl-dev transfig \ + uuid-dev vim wamerican wget xutils-dev zlib1g-dev sasl2-bin rsyslog sudo \ + acl telnet If you're on another platform and can provide the list of dependencies, please -let us know via a `GitHub issue `_ or documentation pull request or send mail to the :ref:`developer list`. +let us know via a +`GitHub issue `_ +or documentation pull request, or send mail to the +:ref:`developer list`. Follow the :ref:`general install instructions `. @@ -94,8 +330,9 @@ Follow the :ref:`general install instructions `. It's best to ensure your new Cyrus *will not* start up automatically if your server restarts in the middle of the upgrade. - How this is best achieved will depend upon your OS and distro, but may involve - something like ``systemctl disable cyrus-imapd`` or ``update-rc.d cyrus-imapd disable`` + How this is best achieved will depend upon your OS and distro, but may + involve something like ``systemctl disable cyrus-imapd`` or + ``update-rc.d cyrus-imapd disable`` 3. Shut down existing Cyrus --------------------------- @@ -115,9 +352,6 @@ We recommend backing up all your data before continuing. * Mail spool * :ref:`Cyrus Databases ` -(You do already have a backup strategy in place, right? Once you're on 3.0, you can -consider using the new inbuilt :ref:`backup tools `.) - Copy all of this to the new instance, using ``rsync`` or similar tools. .. note:: @@ -135,7 +369,7 @@ server):: rsync -aHv oldimap:/var/lib/cyrus/. /var/lib/cyrus/. rsync -aHv oldimap:/var/spool/cyrus/. /var/spool/cyrus/. -You don't need to copy the following databases as Cyrus 3.0 will +You don't need to copy the following databases as Cyrus 3.10 will recreate these for you automatically: * duplicate delivery (deliver.db), @@ -149,15 +383,6 @@ recreate these for you automatically: or whatever suitable tmpfs is provided on your distro. It will place less IO load on your disks and run faster. -.. warning:: - Please be warned that some packages place tasks such as ``tlsprune`` - (:cyrusman:`tls_prune(8)`) in the ``START{}`` stanza of - :cyrusman:`cyrus.conf(5)`. This will cause a startup problem if the - ``tls_sessions_db`` is not present. The solution to this is to - remove the ``tlsprune`` task from ``START{}`` and schedule it in - ``EVENTS{}``, further down. - - 5. Copy config files and update ------------------------------- @@ -184,21 +409,23 @@ you have provided overrides for in your config files:: cyr_info conf-all -C -M **Important config** options: ``unixhierarchysep:`` and ``altnamespace:`` -defaults have changed in :cyrusman:`imapd.conf(5)`. Implications are -outlined in the Note in :ref:`imap-admin-namespaces-mode` and +defaults in :cyrusman:`imapd.conf(5)` changed in 3.0, which will affect you +if you are upgrading to 3.10 from something earlier than 3.0. Implications +are outlined in the Note in :ref:`imap-admin-namespaces-mode` and :ref:`imap-switching-alt-namespace-mode`. Please also see "Sieve Scripts," below. * unixhierarchysep: on * altnamespace: on -.. note:: - If your installation is using groups, don't turn ``reverseacls:`` on. Reverseacl support - only works well for sites without groups. - In :cyrusman:`cyrus.conf(5)` move idled from the START section to the DAEMON section. +Installations that passed fractional durations such as "1.5d" to any of the +-E, -X, -D, or -A :cyrusman:`cyr_expire(8)` arguments must adapt these to only +use integer durations such as "1d12h". You may have such entries in the EVENTS +section of :cyrusman:`cyrus.conf(5)`, or cron etc. + 6. Upgrade specific items ------------------------- @@ -208,68 +435,51 @@ DAEMON section. directive(s), you can convert these to per-user special-use annotations in your new install with the :cyrusman:`cvt_xlist_specialuse(8)` tool -* Sieve Scripts - - Since defaults for options: ``unixhierarchysep:`` and - ``altnamespace:`` have changed in :cyrusman:`imapd.conf(5)`, you - may very likely need to modify any sieve scripts already on your - system. Fear not, there's a tool for this task, called - :cyrusman:`translatesieve(8)`. This tool can handle situations - where either or both of these settings need change. Please consult - the man page for details. - - Consider the following example, where the prior configuration was - already using ``altnamespace: on``, but was *not* using - ``unixhierarchysep: on``:: - - # su cyrus -c "/usr/lib/cyrus/upgrade/translatesieve -a" - you are using /var/lib/imap/sieve as your sieve directory. - translating sievedir /var/lib/imap/sieve... converting separator from '.' to '/' - not changing name space. - done - .. warning:: - **Berkeley db format no longer supported** + **Berkeley db format no longer supported since 3.0** If you have any databases using Berkeley db, they'll need to be converted to skiplist or flat *in your existing installation*. And then optionally converted to whatever final format you'd like in - your 3.0 installation. + your 3.10 installation. - Databases potentially affected: mailboxes, annotations, conversations, quotas. + Databases potentially affected: mailboxes, annotations, conversations, + quotas. On old install, prior to migration:: cvt_cyrusdb /mailboxes.db berkeley /tmp/new-mailboxes.db skiplist - If you don't want to use flat or skiplist for 3.0, you can use the - new 3.0 :cyrusman:`cvt_cyrusdb(8)` to swap to new format:: + If you don't want to use flat or skiplist for 3.10, you can use + :cyrusman:`cvt_cyrusdb(8)` to swap to new format:: cvt_cyrusdb /tmp/new-mailboxes.db skiplist //mailboxes.db .. note:: The :cyrusman:`cvt_cyrusdb(8)` command does not accept relative paths. -7. Start new 3.0 Cyrus and verify ---------------------------------- +7. Start new 3.10 Cyrus and verify +---------------------------------- :: sudo ./master/master -d -Check ``/var/log/syslog`` for errors so you can quickly understand potential problems. +Check ``/var/log/syslog`` for errors so you can quickly understand potential +problems. -When you're satisfied version 3.0 is running and can see all its data correctly, -start the new Cyrus up with your regular init script. +When you're satisfied version 3.10 is running and can see all its data +correctly, start the new Cyrus up with your regular init script. -If something has gone wrong, contact us on the :ref:`mailing list `. +If something has gone wrong, contact us on the +:ref:`mailing list `. You can revert to backups and keep processing mail using your old version -until you're able to finish your 3.0 installation. +until you're able to finish your 3.10 installation. .. note:: - If you've disable your system startup scripts, as recommended in + If you've disabled your system startup scripts, as recommended in step 2, remember to re-enable them. Use something like ``systemctl enable cyrus-imapd`` or ``update-rc.d cyrus-imapd enable`` @@ -279,52 +489,70 @@ until you're able to finish your 3.0 installation. The following steps can each take a long time, so we recommend running them one at a time (to reduce locking contention and high I/O load). -To upgrade all the mailboxes to the latest version. This will take hours, possibly days. +To upgrade all the mailboxes to the latest version. This will take hours, +possibly days. :: reconstruct -V max -New configuration: if turning on conversations, you need to create conversations.db for each user. -(This is required for jmap).:: +3.10 contains fixes for conversations bugs. The fixes are all backwards +compatible, but a conversations DB rebuild will be good, e.g. +``ctl_conversationsdb -R -r -v``. - ctl_conversationsdb -b -r +If a user's conversations remain broken, you can wipe and recreate all their +CIDs with ``ctl_conversationsdb -z $username`` followed by +``ctl_conversationsdb -b $username`` To check (and correct) quota usage:: quota -f -If you've been using CalDAV/CardDAV/all of the DAV from earlier releases, then the user.dav -databases need to be reconstructed due to format changes.:: +If you've been using CalDAV/CardDAV/all of the DAV from earlier releases, then +the user.dav databases need to be reconstructed due to format changes.:: dav_reconstruct -a +If have the `reverseacls` feature enabled in :cyrusman:`imapd.conf(5)`, you may +need to regenerate the data it uses (which is stored in `mailboxes.db`). This +is automatically regenerated at startup by ``ctl_cyrusdb -r`` if the +`reverseacls` setting has changed. So, to force a regeneration: + + 1. Shut down Cyrus + 2. Change `reverseacls` to `0` in :cyrusman:`imapd.conf(5)` + 3. Run :cyrusman:`ctl_cyrusdb(8)` with the `-r` switch (or just start + Cyrus, assuming your :cyrusman:`cyrus.conf(5)` contains a + `ctl_cyrusdb -r` entry in the START section). The old RACL entries + will be removed + 4. (If you started Cyrus, shut it down again) + 5. Change `reverseacls` back to `1` + 6. Start up Cyrus (or run `ctl_cyrusdb -r`). The RACL entries will + be rebuilt + +There were fixes and improvements to caching and search indexing in 3.6. If +you are upgrading to 3.10 from something earlier than 3.6, you should consider +running :cyrusman:`reconstruct(8)` across all mailboxes to rebuild caches, and +:cyrusman:`squatter(8)` to rebuild search indexes. This will probably take a +long time, so you may wish to only do it per-mailbox as inconsistencies are +discovered. However, if you have been running a 3.5 development version, you +should make sure to do this for all mailboxes, due to bugs that were introduced +and then fixed during 3.5 development. + +3.10 contains fixes to bugs in the Squat search backend. If you use the Squat +search backend, your search indexes may benefit from a full (not incremental) +reindex using :cyrusman:`squatter(8)`. + 9. Do you want any new features? -------------------------------- -3.0 comes with many lovely new features. Consider which ones you want to enable. -Here are some which may interest you. Check the :ref:`3.0 release notes ` -for the full list. - -* :ref:`JMAP ` -* :ref:`Backups ` -* :ref:`Xapian for searching ` -* Cross-domain support. See ``crossdomains`` in :cyrusman:`imapd.conf(5)` +3.10 comes with many lovely new features. Consider which ones you want to +enable. Check the :ref:`3.10 release notes ` for the +full list. 10. Upgrade complete -------------------- -Your upgrade is complete! We have a super-quick survey (3 questions only, -anonymous responses) we would love for you to fill out, so we can get a feel for -how many Cyrus installations are out there, and how the upgrade process went. - -|3.0 survey link| - -.. |3.0 survey link| raw:: html - - - I'll fill in the survey right now (opens in a new window) - +Your upgrade is complete, congratulations! Special note for Murder configurations -------------------------------------- @@ -338,10 +566,19 @@ upgrade all your back end servers first. This can be done one at a time. Upgrade your mupdate master and front ends last. -If you are upgrading from 2.4, and wish to use XFER to transfer your -mailboxes to your new 3.0 server, please consider first upgrading your -2.4 setup to version 2.4.19 or later. Earlier versions of 2.4 do not -correctly recognise the 2.5 and 3.0 mailbox versions, and will -downgrade mailboxes (losing metadata) in transit. 2.4.19 and later -versions correctly recognise 2.5 and 3.0 servers, and will not -downgrade mailbox versions in transit. +Please note that you will be unable to set ANNOTATION-STORAGE or MAILBOX +quotas (formerly known as X-ANNOTATION-STORAGE and X-NUM_FOLDERS) in a +mixed-version murder environment until your frontends are upgraded to 3.10 +(or later). Upgraded frontends know how to negotiate with older backends, but +older frontends do not know how to negotiate with newer backends. + +If you wish to use XFER to transfer mailboxes from an existing backend to your +new 3.10 backend, you should first upgrade your existing backends to 3.8, 3.6.1, +3.4.5, 3.2.11, or 3.0.18. These releases contain a patch such that XFER will +correctly recognise 3.8 and later destinations. Without this patch, XFER will +not recognise 3.10, and will downgrade mailboxes to the oldest supported format +(losing metadata) in transit. + +If your existing backends are 2.4 or 2.5, there are equivalent patches for +recognising 3.8+ on the cyrus-imapd-2.4 and cyrus-imapd-2.5 git branches, but +these are not in any released version. diff --git a/docsrc/imap/installing.rst b/docsrc/imap/installing.rst index 731cd0254f..39cfebe822 100644 --- a/docsrc/imap/installing.rst +++ b/docsrc/imap/installing.rst @@ -76,15 +76,19 @@ Authentication with SASL .. include:: /assets/setup-sasl-sasldb.rst +.. _mta_lda_delivery: + Mail delivery from your MTA --------------------------- Your Cyrus IMAP server will want to receive the emails accepted by your -SMTP server (ie Sendmail, Postfix, etc). In Cyrus, this happens via a +SMTP server (ie Sendmail, Postfix, Exim). In Cyrus, this happens via a protocol called LMTP, which is usually supported by your SMTP server. .. include:: /assets/setup-sendmail.rst +.. include:: /assets/setup-postfix.rst + Protocol ports -------------- @@ -127,52 +131,19 @@ structure: use :cyrusman:`mkimap(8)`. sudo -u cyrus ./tools/mkimap -Optional: Setting up SSL certificates +Optional: Setting up TLS certificates ------------------------------------- -Create a TLS certificate using OpenSSL. Generate the certificate and -store it in the /var/lib/cyrus/server.pem file: - -:: - - sudo openssl req -new -x509 -nodes -out /var/lib/cyrus/server.pem \ - -keyout /var/lib/cyrus/server.pem -days 365 \ - -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=localhost" - -This creates a TLS certificate (`-out`) and private key (`-keyout`) in -the `X.509 `_ format (`-x509`). -The certificate is set to expire in 365 days (`-days`) and has default -information set up (`-subj ...`). The contents of the -subj is -non-trivial and defined in `RFC 5280 -`_, a brief summary is available -on `stackoverflow -`_ -which is enough to decode our sample above. - -Great! You should now have a file at /var/lib/cyrus/server.pem. Give -Cyrus access to this file: - -:: - - sudo chown cyrus:mail /var/lib/cyrus/server.pem - -Awesome! Almost done. We will now configure the Cyrus IMAP server to -actually use this TLS certificate. Open your Cyrus configuration file -``/etc/imapd.conf`` and add the following two lines at the end of it: - -:: - - tls_server_cert: /var/lib/cyrus/server.pem - tls_server_key: /var/lib/cyrus/server.pem - -This tells the server where to find the TLS certificate and the key. It -may seem weird to specify the same file twice, but since the file has -the x509 format, the server will know what to do. Cyrus is there for -you, always (unless your hard drive burns down) ! :-) +Obtain a certificate, e.g. from +`Let’s Encrypt `_. You need a file with +the full chain and a private key in +`X.509 `_ format. Adjust the file +owner on these files with ``sudo chown cyrus:mail``. Set the options +``tls_server_cert`` and ``tls_server_key`` in :cyrusman:`imapd.conf(5)` +to point to these files. -The other configuration file we have to edit is ``/etc/cyrus.conf``. -Open it up with your favorite text editor and in the **SERVICES** -section, add (or uncomment) this line: +Open ``/etc/cyrus.conf`` and in the **SERVICES** section, add (or +uncomment) this line: :: @@ -375,8 +346,8 @@ You can now configure a mail client to access your new mailserver and connect to the mailbox for example@localhost via IMAP and see the message. -Checking CardDAV and CardDAV -============================ +Checking CardDAV and CalDAV +=========================== Modify ``/etc/cyrus.conf`` and add (or uncomment) this line in the SERVICES section:: @@ -454,7 +425,7 @@ More information is almost always logged to **syslog**. Make sure you start sysl .. rubric:: My question isn't answered here -Join us in the :ref:`#cyrus IRC channel on Freenode ` or on the -:ref:`mailing lists ` if you need help or just want to chat about Cyrus, IMAP, etc. +Join us on the :ref:`mailing lists ` if you need help +or just want to chat about Cyrus, IMAP, etc. -.. _FastMail : https://www.fastmail.com +.. _Fastmail : https://www.fastmail.com diff --git a/docsrc/imap/reference/admin/backups.rst b/docsrc/imap/reference/admin/backups.rst index c365f24c6e..fad25d4ff8 100644 --- a/docsrc/imap/reference/admin/backups.rst +++ b/docsrc/imap/reference/admin/backups.rst @@ -4,15 +4,18 @@ Cyrus Backups ============= -.. contents:: +.. warning:: + This experimental feature is no longer under active development. It + is considered deprecated as of 3.10, and will be removed entirely in + a future version. +.. contents:: Introduction ======================== Cyrus Backups are a replication-based backup service for Cyrus IMAP servers. -This is currently an experimental feature. If you have the resources to try it -out alongside your existing backup solutions, feedback would be appreciated. +This is a deprecated experimental feature. This document is intended to be a guide to the configuration and administration of Cyrus Backups. @@ -29,7 +32,8 @@ This document assumes a passing familiarity with Limitations =========== -Cyrus Backups are experimental and incomplete. +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. The following components exist and appear to work: @@ -69,6 +73,9 @@ The following types of information are not currently backed up Architecture ============ +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. + Cyrus Backups are designed to run on one or more standalone, dedicated backup servers, with suitably-sized storage partitions. These servers generally do not run an IMAP daemon, nor do they have conventional mailbox storage. @@ -87,6 +94,9 @@ locations Installation ============ +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. + Requirements ------------ @@ -142,6 +152,11 @@ Cyrus Backups server :cyrusman:`imapd.conf(5)` #. Set up a :cyrusman:`cyrus.conf(5)` file for it:: + START { + # this is required + recover cmd="ctl_cyrusdb -r" + } + SERVICES { # backupd is probably the only service entry your backup server needs backupd cmd="backupd" listen="csync" prefork=0 @@ -200,7 +215,7 @@ sync\_log\_channels: *channel* means livelier backups but more network I/O. Larger value reduces I/O. Update :cyrusman:`cyrus.conf(5)` to add a :cyrusman:`sync_client(8)` invocation -to the SERVICES section specifying (at least) the ``-r`` and ``-n channel`` +to the DAEMON section specifying (at least) the ``-r`` and ``-n channel`` options. See :cyrusman:`imapd.conf(5)` for additional *sync\_* settings that can @@ -258,6 +273,9 @@ This replicates all users to the channel *backup*. Administration ============== +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. + Storage requirements -------------------- @@ -401,6 +419,9 @@ of interesting configuration possibilities to shake out. Have a rummage in the Tools ===== +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. + ctl\_backups ------------ diff --git a/docsrc/imap/reference/admin/eventsource.rst b/docsrc/imap/reference/admin/eventsource.rst index 1e4b66ddb1..dd1d4ff5d1 100644 --- a/docsrc/imap/reference/admin/eventsource.rst +++ b/docsrc/imap/reference/admin/eventsource.rst @@ -69,7 +69,7 @@ Apple Push Service ================== While Cyrus supports the Apple Push Service, Apple has only licensed Apple Push -for mail to a couple of large mail providers: FastMail and Yahoo. If you own an +for mail to a couple of large mail providers: Fastmail and Yahoo. If you own an OS X Server license, you also get a key for personal use. But it's not generally a supported option for third party developers that we're aware of, unfortunately. diff --git a/docsrc/imap/reference/admin/locations/searchtiers.rst b/docsrc/imap/reference/admin/locations/searchtiers.rst index a2e94832b7..8712b2045f 100644 --- a/docsrc/imap/reference/admin/locations/searchtiers.rst +++ b/docsrc/imap/reference/admin/locations/searchtiers.rst @@ -8,7 +8,7 @@ once, creating a tiered database structure. To use Xapian, these tiers must be defined in :cyrusman:`imapd.conf(5)` with the `defaultsearchtier` and `searchpartition-name` settings. -Default Search Tier name +Default Search Tier Name ------------------------ Specify the name of the default search tier using the `defaultsearchtier` @@ -18,7 +18,7 @@ setting: :start-after: startblob defaultsearchtier :end-before: endblob defaultsearchtier -Search Tier Partition location +Search Tier Partition Location ------------------------------ Each search tier to be used requires a partition location be specified diff --git a/docsrc/imap/reference/admin/monitoring.rst b/docsrc/imap/reference/admin/monitoring.rst index a97dfea037..cfc9d6d0db 100644 --- a/docsrc/imap/reference/admin/monitoring.rst +++ b/docsrc/imap/reference/admin/monitoring.rst @@ -16,12 +16,13 @@ Setup * Set the `prometheus_enabled` setting in :cyrusman:`imapd.conf(5)` to "yes" * Add the `prometheus` module to your `httpmodules` in :cyrusman:`imapd.conf(5)` - * Set the `prometheus_need_auth`, `prometheus_update_freq` and `prometheus_stats_dir` - settings in :cyrusman:`imapd.conf(5)` to taste + * Set the `prometheus_need_auth`, `prometheus_service_update_freq`, + `prometheus_master_update_freq`, `prometheus_usage_update_freq`, and + `prometheus_stats_dir` settings in :cyrusman:`imapd.conf(5)` to taste + * Add a job to run :cyrusman:`promstatsd(8)` to the DAEMON section of + :cyrusman:`cyrus.conf(5)` (the actual daemon process) * Add a job to run ``promstatsd -c`` to the START section of :cyrusman:`cyrus.conf(5)` (this cleans up the stats files from the previous run) - * Add a job to run ``promstatsd`` to the DAEMON section of :cyrusman:`cyrus.conf(5)` - (the actual daemon process) * Configure your Prometheus server to scrape http://yourserver.example.com/metrics Configuration options @@ -36,8 +37,16 @@ Configuration options :end-before: endblob prometheus_need_auth .. include:: /imap/reference/manpages/configs/imapd.conf.rst - :start-after: startblob prometheus_update_freq - :end-before: endblob prometheus_update_freq + :start-after: startblob prometheus_service_update_freq + :end-before: endblob prometheus_service_update_freq + + .. include:: /imap/reference/manpages/configs/imapd.conf.rst + :start-after: startblob prometheus_master_update_freq + :end-before: endblob prometheus_master_update_freq + + .. include:: /imap/reference/manpages/configs/imapd.conf.rst + :start-after: startblob prometheus_usage_update_freq + :end-before: endblob prometheus_usage_update_freq .. include:: /imap/reference/manpages/configs/imapd.conf.rst :start-after: startblob prometheus_stats_dir diff --git a/docsrc/imap/reference/admin/murder/murder-concepts.rst b/docsrc/imap/reference/admin/murder/murder-concepts.rst index 3f5995e095..6e3c469b27 100644 --- a/docsrc/imap/reference/admin/murder/murder-concepts.rst +++ b/docsrc/imap/reference/admin/murder/murder-concepts.rst @@ -43,8 +43,8 @@ acceptable compromise; however, trying to share mailboxes becomes difficult or even impossible. Specific examples can be found in `Appendix A: DNS Name Load Balancing`_ and `Appendix B: IMAP Multiplexing`_). -We propose a new approach to overcome these problems. We call it the the -Cyrus IMAP Aggregator. The Cyrus aggregator takes a :ref:`murder ` of IMAP +We propose a new approach to overcome these problems. We call it the Cyrus +IMAP Aggregator. The Cyrus aggregator takes a :ref:`murder ` of IMAP servers and presents a server independent view to the clients. That is, **all the mailboxes across all the IMAP servers are aggregated to a single image**, thereby appearing to be only one IMAP server to the clients. @@ -58,7 +58,7 @@ The Cyrus IMAP Aggregator has three classes of servers: 3. MUPDATE. The frontend servers act as the primary communication point between the -end user clients and the backendservers. The frontends use the MUPDATE +end user clients and the backend servers. The frontends use the MUPDATE server as an authoritative source for mailbox names, locations, and permissions. The backend servers store the actual IMAP data (and keep the MUPDATE server appraised as to changes in the Mailbox list). @@ -270,7 +270,7 @@ COPY is somewhat special as it acts upon messages in the currently SELECT'd mailbox but then interacts with another mailbox. In the case where the destination mailbox is on the same backend server -as the the source folder, the COPY command is issued to the backend +as the source folder, the COPY command is issued to the backend server and the backend server takes care of the command. If the destination folder is on a different backend server, the @@ -579,7 +579,7 @@ IMAP connection client A client is a process on a remote computer that communicates with the set of servers distributing mail data, be they ACAP, IMAP, - LDAP, or IMSP servers. A client opens one or more connections to + or LDAP servers. A client opens one or more connections to various servers. mailbox tree diff --git a/docsrc/imap/reference/admin/murder/murder-installation.rst b/docsrc/imap/reference/admin/murder/murder-installation.rst index 812df12260..f26b7c9ab3 100644 --- a/docsrc/imap/reference/admin/murder/murder-installation.rst +++ b/docsrc/imap/reference/admin/murder/murder-installation.rst @@ -201,7 +201,6 @@ The front end SERVICES section should now look like this:: imaps cmd="imap -s" listen="imaps" prefork=1 pop3 cmd="pop3d" listen="pop3" prefork=0 pop3s cmd="pop3d -s" listen="pop3s" prefork=0 - kpop cmd="pop3d -k" listen="kpop" prefork=0 nntp cmd="nntpd" listen="nntp" prefork=0 nntps cmd="nntpd -s" listen="nntps" prefork=0 http cmd="httpd" listen="http" prefork=0 @@ -439,3 +438,10 @@ Troubleshooting **Databases are never created on the frontends/slaves** Check to ensure that the mupdate slave process is started, (is prefork=1) + +**mupdate crashes with SIGSERV when using STARTTLS** + The OpenSSL code in Cyrus Imap is for single-threaded applications and + mupdate is a multi-threaded application. Do not encrypt the communication + with mupdate. See also the discussion “SIGSEGV in cyrus-imapd 3.0.7 mupdate” + on `cyrus-devel from July 2018 `_ + and https://github.com/cyrusimap/cyrus-imapd/issues/2774 . diff --git a/docsrc/imap/reference/admin/quotas.rst b/docsrc/imap/reference/admin/quotas.rst index 20f6f5b293..d2e2f401f8 100644 --- a/docsrc/imap/reference/admin/quotas.rst +++ b/docsrc/imap/reference/admin/quotas.rst @@ -42,13 +42,13 @@ sites, via the use of several settings in :cyrusman:`imapd.conf(5)`: .. include:: /imap/reference/manpages/configs/imapd.conf.rst - :start-after: startblob quotawarn - :end-before: endblob quotawarn + :start-after: startblob quotawarnpercent + :end-before: endblob quotawarnpercent .. include:: /imap/reference/manpages/configs/imapd.conf.rst - :start-after: startblob quotawarnkb - :end-before: endblob quotawarnkb + :start-after: startblob quotawarnsize + :end-before: endblob quotawarnsize .. include:: /imap/reference/manpages/configs/imapd.conf.rst diff --git a/docsrc/imap/reference/admin/sieve.rst b/docsrc/imap/reference/admin/sieve.rst index 57deeeb2a7..4ae4569e2c 100644 --- a/docsrc/imap/reference/admin/sieve.rst +++ b/docsrc/imap/reference/admin/sieve.rst @@ -10,46 +10,69 @@ Cyrus Sieve Introduction ============ -Cyrus Sieve is an implementation of the Sieve mail filtering language ( :rfc:`3028` ). It allows a series of tests to be applied against an incoming message, with actions to take place if there is a match. +Cyrus Sieve is an implementation of the Sieve mail filtering language +( :rfc:`3028` ). It allows a series of tests to be applied against an incoming +message, with actions to take place if there is a match. Mail filtering occurs on delivery of the message (within lmtpd). -Cyrus compiles sieve scripts to bytecode to reduce the overhead of parsing the scripts fully inside of lmtpd. This occurs automatically if :cyrusman:`sieveshell(1)` is used to place the scripts on the server. +Cyrus compiles sieve scripts to bytecode to reduce the overhead of parsing the +scripts fully inside of lmtpd. This occurs automatically if +:cyrusman:`sieveshell(1)` is used to place the scripts on the server. -Sieve scripts can be placed either by the :cyrusman:`timsieved(8)` daemon (implementing the ManageSieve protocol :rfc:`5804`; this is the preferred options since it allows for syntax checking) or in the user's home directory as a .sieve file. +Sieve scripts can be placed either by the :cyrusman:`timsieved(8)` daemon +(implementing the ManageSieve protocol :rfc:`5804`; this is the preferred +options since it allows for syntax checking) or in the user's home directory +as a .sieve file. Installing Sieve ================ -This section assumes that you :ref:`compiled Cyrus ` with sieve support. If you specified ``--disable-sieve`` when running ``./configure``, you did NOT compile the server with sieve support. +This section assumes that you :ref:`compiled Cyrus ` with sieve +support. If you specified ``--disable-sieve`` when running ``./configure``, +you did NOT compile the server with sieve support. Configure sieve --------------- -Depending on what's in your ``/etc/services`` file, sieve will usually be set to listen on port 2000 (old convention) or port 4190 (as specified by :rfc:`5804`). +Depending on what's in your ``/etc/services`` file, sieve will usually be set +to listen on port 2000 (old convention) or port 4190 (as specified by :rfc:`5804`). -Add lines to :cyrusman:`cyrus.conf(5)` to make the server listen to the right ports for sieveshell commands:: +Add lines to the SERVICES section of :cyrusman:`cyrus.conf(5)` to make the +server listen to the right ports for sieveshell commands:: sieve cmd="timsieved" listen="servername:sieve" prefork=0 managesieve cmd="timsieved" listen="servername:4190" prefork=0 +Sieve scripts are stored in the directory hierarchy specified by the +**sievedir** :cyrusman:`imapd.conf(5)` option (default: ``/usr/sieve``). +This directory must exist and be writeable by the cyrus user for ``timsieved`` +to function, so organise that now. + Configure outgoing mail ----------------------- Some Sieve actions (redirect, vacation) can send outgoing mail. -You'll need to make sure that lmtpd can send outgoing messages. Currently, it invokes ``/usr/lib/sendmail`` by default to send messages. Change this by adding a line like:: +You'll need to make sure that lmtpd can send outgoing messages. Currently, it +invokes ``/usr/lib/sendmail`` by default to send messages. Change this by +adding a line like:: sendmail: /usr/sbin/sendmail -in your :cyrusman:`imapd.conf(5)`. If you're using Postfix or another MTA, make sure that the sendmail referenced in "/etc/imapd.conf" is Sendmail-compatible. +in your :cyrusman:`imapd.conf(5)`. If you're using Postfix or another MTA, make +sure that the sendmail referenced in "/etc/imapd.conf" is Sendmail-compatible. Managing Sieve Scripts ====================== -Since Cyrus is based around the concept of a sealed-server, the normal way for users to manipulate Sieve scripts is through the :cyrusman:`sieveshell(1)` utility. +Since Cyrus is based around the concept of a sealed-server, the normal way for +users to manipulate Sieve scripts is through the :cyrusman:`sieveshell(1)` +utility, in communication with the :cyrusman:`timsieved(8)` service. -If, for some reason, you do have user home directories on the server, you can use the **sieveusehomedir** option in :cyrusman:`imapd.conf(5)` and have the sieve script stored in the home directory of the user as ``~/.sieve``. +If, for some reason, you do have user home directories on the server, you can +use the **sieveusehomedir** option in :cyrusman:`imapd.conf(5)` and have the +sieve script stored in the home directory of the user as ``~/.sieve``. Sieve scripts in shared folders ------------------------------- @@ -57,16 +80,26 @@ Sieve scripts in shared folders Cyrus has two types of repositories where Sieve scripts can live: 1. **Personal** is per user and -2. **Global** is for every user. Global scripts aren’t applied on incoming messages by default: users must include them in their scripts. - * Note that there are two types of Global scripts: **global** and **global per domain**. +2. **Global** is for every user. Global scripts aren't applied on incoming + messages by default: users must include them in their scripts. Note that + there are two types of Global scripts: **global** and **global per domain**. -When you log into Cyrus IMAP with :cyrusman:`sieveshell(1)` you have the following combinations (Assuming there is ``manager`` and ``manager@example.com`` as admin in :cyrusman:`imapd.conf(5)`): +When you log into Cyrus IMAP with :cyrusman:`sieveshell(1)` you have the +following combinations (Assuming there is ``manager`` and +``manager@example.com`` as admin in :cyrusman:`imapd.conf(5)`): * ``sieveshell -a manager -u manager localhost`` - To edit global scripts. -* ``sieveshell -a manager@example.com -u manager@example.com localhost`` - To edit global script of example.com domain. -* ``sieveshell -a user@example.com -u user@example.com localhost`` - To edit personal scripts of some user. +* ``sieveshell -a manager@example.com -u manager@example.com localhost`` - To + edit global script of example.com domain. +* ``sieveshell -a user@example.com -u user@example.com localhost`` - To edit + personal scripts of some user. -Scripts for shared folders work different from user scripts. The last ones are loaded to the user’s repository and attached to the inbox when activated The first ones must be loaded to the global domain repository and attached to a shared folder by a user that has permission on it. Use the second combination listed above to load them and cyradm (or another compatible client) to do the attach:: +Scripts for shared folders work different from user scripts. The last ones are +loaded to the user's repository and attached to the inbox when activated. The +first ones must be loaded to the global domain repository and attached to a +shared folder by a user that has permission on it. Use the second combination +listed above to load them and cyradm (or another compatible client) to do the +attach:: sieveshell -u manager@example.com -a manager@example.com localhost @@ -75,12 +108,16 @@ Scripts for shared folders work different from user scripts. The last ones are l localhost.localdomain> mboxcfg shared.folder@example.com sieve my_script -Testing the sieve server +Testing the Sieve Server ======================== -The Sieve server, :cyrusman:`timsieved(8)`, is used for transporting user Sieve scripts to the sealed IMAP server. It is incompatible with the **sieveusehomedir** option. It is named after the principal author, Tim Martin, who desperately wanted something named after him in the Cyrus distribution. +The Sieve server, :cyrusman:`timsieved(8)`, is used for transporting user Sieve +scripts to the sealed IMAP server. It is incompatible with the +**sieveusehomedir** option. It is named after the principal author, Tim Martin, +who desperately wanted something named after him in the Cyrus distribution. -From your normal account, telnet to the sieve port on the server you're setting up:: +From your normal account, telnet to the sieve port on the server you're setting +up:: telnet servername sieve @@ -89,16 +126,25 @@ If your server is running, you'll get a message similar to the following one:: Trying 128.2.10.192... Connected to servername.domain.tld. Escape character is '^]'. - "IMPLEMENTATION" "Cyrus timsieved v1.1.0" + "IMPLEMENTATION" "Cyrus timsieved v3.8.3" + "VERSION" "1.0" "SASL" "ANONYMOUS PLAIN KERBEROS_V4 GSSAPI" "SIEVE" "fileinto reject envelope vacation imapflags notify subaddress regex" + "NOTIFY" "mailto" + "UNAUTHENTICATE" OK -Any message other than one similar to the one above means there is a problem. Make sure all of authentication methods you wish to support are listed. This list should be identical to the one listed by "imapd" earlier. Next terminate the connection, by typing:: +Any message other than one similar to the one above means there is a problem. +Make sure all of authentication methods you wish to support are listed. This +list should be identical to the one listed by "imapd" earlier. Next terminate +the connection, by typing:: logout -Next test authenticating to the sieve server. To do this run the :cyrusman:`sieveshell(1)` utility. You must specify the server. If you run this utility from a different machine without the "sieve" entry in "/etc/services", port 2000 will be used. +Next test authenticating to the sieve server. To do this run the +:cyrusman:`sieveshell(1)` utility. You must specify the server. If you run this +utility from a different machine without the "sieve" entry in "/etc/services", +port 2000 will be used. :: @@ -106,9 +152,13 @@ Next test authenticating to the sieve server. To do this run the :cyrusman:`siev Please enter your password: ****** > quit -This should produce the message "Authentication failed" with a description of the failure if there was a problem. +This should produce the message "Authentication failed" with a description of +the failure if there was a problem. -Next you should attempt to place a sieve script on the server. To do this create a file named ``myscript.script`` with the following lines. Replace "foo@example.org" with an email address you can send mail from, but that is not the one you are working on now. +Next you should attempt to place a sieve script on the server. To do this +create a file named ``myscript.script`` with the following lines. Replace +"foo@example.org" with an email address you can send mail from, but that is +not the one you are working on now. :: @@ -128,7 +178,9 @@ To place this script on the server run the following command:: This should place your script on the server and make it the active script. -Test that the sieve script is actually run. Send a message to the address you're working on from the address mentioned in the sieve script. The message should be rejected. +Test that the sieve script is actually run. Send a message to the address +you're working on from the address mentioned in the sieve script. The message +should be rejected. When you're done, don't forget to delete your testing script:: @@ -145,7 +197,10 @@ Cyrus Sieve Support Special use folders ------------------- -Some mail clients allow users to rename the system folders, such as Archive and Trash. This can make sieve scripts break if they are using folder names explicitly. Fortunately such folders have a special use flag, allowing you to access them from sieve without needing to know their current titles. +Some mail clients allow users to rename the system folders, such as Archive and +Trash. This can make sieve scripts break if they are using folder names +explicitly. Fortunately such folders have a special use flag, allowing you to +access them from sieve without needing to know their current titles. * \\Archive * \\Drafts @@ -157,7 +212,9 @@ Some mail clients allow users to rename the system folders, such as Archive and Supported extensions -------------------- -Sieve has a lot of `extensions `_. Cyrus supports a subset of these: +Sieve has a lot of +`extensions `_. +Cyrus supports a subset of these: * Sieve language reference :rfc:`5228` * Vacation Extension :rfc:`5230` @@ -165,7 +222,7 @@ Sieve has a lot of `extensions `_ +* Regular Expression Extension :draft:`draft-ietf-sieve-regex` * Checking Mailbox Status and Accessing Mailbox Metadata :rfc:`5490` * Notify Extension :rfc:`5435` * Include :rfc:`6609` @@ -177,28 +234,108 @@ Sieve has a lot of `extensions `_ -* IMAP flag Extension `Draft imap flags RFC `_ -* Body Extension `Draft body extension RFC `_ +* Delivering to Special-Use Mailboxes :rfc:`8579` +* IMAP flag Extension :rfc:`5232` +* Body Extension :rfc:`5173` + +Cyrus IMAP Specific Extensions +------------------------------ + +.. _vnd.cyrus.log: + +log +^^^ + +Usage:: + + require "vnd.cyrus.log"; + log ; + +The **log** action sends the string to syslog with INFO priority. + +.. _processimip: + +processimip +^^^^^^^^^^^ + +Usage:: + + require "vnd.cyrus.imip"; + processimip [ ":invitesonly" / ":deletecanceled" ] [ ":outcome" ] [ ":errstr" ] [ ":calendarid" ]; + processimip ":updatesonly" [ ":deletecanceled" ] [ ":outcome" ] [ ":errstr" ]; + +The **processimip** action processes iMIP messages during LMTP delivery. It handles the first possibly nested *text/calendar* MIME part and ignores the *application/ics* MIME part. + +If present, the variable pointed after the ``:outcome`` parameter contains the enacted action. Problems are communicated with the variable named after the ``:errstr`` parameter. The ``:errstr`` and ``:outcome`` parameters can be used only with ``require "variables";``. + +**processimip** does not affect the implicit keep. The action sends to syslog with INFO priority *outcome* and *errstr*, even when these parameters were not used. **processimip** does not change the *PARTSTAT* property parameter value and in turn does not send replies to the *ORGANIZER*. **processimip** does not produce runtime errors, if it is used together with the *[e]reject* action. The handled *text/calendar* MIME part is stored in the scheduling Inbox. + +The string after the ``:calendarid`` parameter indicates in which calendar to create new iCalendar messages. The default destination depends on the scheduling Inbox’s **CALDAV:schedule-default-calendar-URL** WebDAV property. + +When method CANCEL is received, by default the iCalendar object is retained and its STATUS property is changed to CANCELLED. With parameter ``:deletecanceled`` the iCalendar object is deleted on method CANCEL. + +.. code-block:: none + :caption: Example + + require ["ereject", "variables", "vnd.cyrus.imip"]; + if envelope "to" "me+imip@domain" { + processimip :outcome "outcome" :errstr "errstr"; + if string "${outcome}" "error" { + ereject "iMIP handling failed: ${errstr}"; + } + } -Note that the final RFCs of these last sieve extensions have significant changes that are not currently supported. +After **processimip** returns the *outcome* and *errstr* variables have one of these values: + +========= =========================================== ======= +outcome errstr Remark +========= =========================================== ======= +error could not autoprovision calendars Default calendars cannot be created. +error no component to schedule +error missing UID property +error invalid iCalendar data: … +error missing ORGANIZER property When method is ADD, CANCEL, POLLSTATUS or REQUEST. +error missing ATTENDEE property When method is REPLY. +error unsupported method: … E.g. when VPOLL component is used with method ADD. +error unsupported component: … +error failed to deliver iMIP message: … +error could not find matching ATTENDEE property When method is ADD, CANCEL, POLLSTATUS, PUBLISH or REQUEST. +no_action unable to parse iMIP message The email cannot be parsed. +no_action unable to find & parse text/calendar part +no_action missing METHOD property +no_action configured to NOT process updates When method is ADD, CANCEL or POLLSTATUS and ``:invitesonly`` is provided. +no_action configured to NOT process replies When method is REPLY and ``:invitesonly`` is provided. +no_action +added +updated Also on method CANCEL. +========= =========================================== ======= Sieve Tools ----------- -* :cyrusman:`timsieved(8)` - server side daemon to accept requests from sieveshell +* :cyrusman:`timsieved(8)` - server side daemon to accept requests from + sieveshell * :cyrusman:`sievec(8)` - compile a script into bytecode. See sieved. * :cyrusman:`sieved(8)` - decompile a script back from bytecode. See sievec. -* :cyrusman:`masssievec(8)` - compiles all the scripts in **sievedir** from ``imapd.conf``. -* :cyrusman:`sivtest(1)` - authenticate and test against a MANAGESIEVE server such as timsieved. -* :cyrusman:`sieveshell(1)` - allow users to manage scripts on a remote server, via MANAGESIEVE -* :cyrusman:`translatesieve(8)` - utility script to translate sieve scripts to use **unixhierarchysep** and/or **altnamespace** +* :cyrusman:`masssievec(8)` - compiles all the scripts in **sievedir** from + ``imapd.conf``. +* :cyrusman:`sivtest(1)` - authenticate and test against a MANAGESIEVE server + such as timsieved. +* :cyrusman:`sieveshell(1)` - allow users to manage scripts on a remote server, + via MANAGESIEVE +* :cyrusman:`translatesieve(8)` - utility script to translate sieve scripts to + use **unixhierarchysep** and/or **altnamespace** Writing Sieve ============= -Sieve scripts can be used to automatically delete or forward messages; to send autoreplies; to sort them in folders; to mark messages as read or flagged; to test messages for spam or viruses; or to reject messages at or after delivery. `Sieve.info `_ has more information on sieve and its uses. +Sieve scripts can be used to automatically delete or forward messages; to send +autoreplies; to sort them in folders; to mark messages as read or flagged; to +test messages for spam or viruses; or to reject messages at or after delivery. +`Sieve.info `_ has more information on sieve and its uses. -There's a `good sieve reference `_ online which describes the language. +There's a `good sieve reference `_ +online which describes the language. -For those who prefer a client to write code in, Sieve.info has a `list of desktop, web and command line clients `_. +For those who prefer a client to write code in, Sieve.info has a +`list of desktop, web and command line clients `_. diff --git a/docsrc/imap/reference/admin/sop/replication.rst b/docsrc/imap/reference/admin/sop/replication.rst index 821460f6b9..3c52a5b853 100644 --- a/docsrc/imap/reference/admin/sop/replication.rst +++ b/docsrc/imap/reference/admin/sop/replication.rst @@ -411,7 +411,7 @@ Other Considerations .. Important:: This section is currently under development. If you believe you are impacted by these considerations, please check back with each - release, follow the mailing list and check in on IRC. + release and follow the mailing list. The infrastructure provided by ``sync_log`` has now been leveraged by the Rolling Indexing capability introduced in v3.0. See diff --git a/docsrc/imap/reference/admin/sop/squatter.rst b/docsrc/imap/reference/admin/sop/squatter.rst index 341d65d817..2d8032259e 100644 --- a/docsrc/imap/reference/admin/sop/squatter.rst +++ b/docsrc/imap/reference/admin/sop/squatter.rst @@ -1,18 +1,24 @@ Using Squatter for Faster IMAP SEARCH ===================================== -IMAP SEARCH, as described in `RFC 3501`_, is a IMAP4 (Rev1) command issued by the client, but executed on the server. The Cyrus IMAP server will search the mailbox for the content matching the search command issued. This may be an intensive operation if executed on large mailboxes, and may therefor delay the response to the client. +IMAP SEARCH, as described in :rfc:`3501`, is a IMAP4 (Rev1) command issued by +the client, but executed on the server. The Cyrus IMAP server will search the +mailbox for the content matching the search command issued. This may be an +intensive operation if executed on large mailboxes, and may therefor delay the +response to the client. -To significantly speed up the searching, Cyrus IMAP can create a cache of message contents and meta-data using cyrus-squatter(8). This chapter explains how to generate and maintain these caches. +To significantly speed up the searching, Cyrus IMAP can create a cache of +message contents and meta-data using cyrus-squatter(8). This chapter explains +how to generate and maintain these caches. Squatter Invocation ------------------- Consider the following implications of running cyrus-squatter; -* Squatter creates the search index from all messages in the mailbox +* Squatter creates the search index from all messages in the mailbox -.. todo:: list not complete +.. todo:: list not complete Generating the Search Indexes @@ -22,8 +28,9 @@ To generate the search index for all mailboxes, issue the following command:: $ squatter -v -While the Cyrus IMAP server now has the search index available for the mailbox contents, it does not automatically update the search index with new messages coming in. +While the Cyrus IMAP server now has the search index available for the mailbox +contents, it does not automatically update the search index with new messages +coming in. -.. todo:: So how does the search index get updated? Do you run squatter on a daily basis from the EVENTS section of /etc/cyrus.conf - -.. _RFC 3501: http://tools.ietf.org/html/rfc3501 +.. todo:: So how does the search index get updated? Do you run squatter on a + daily basis from the EVENTS section of /etc/cyrus.conf diff --git a/docsrc/imap/reference/admin/sop/userdeny.rst b/docsrc/imap/reference/admin/sop/userdeny.rst index 87adaaf0c1..5ada4ea360 100644 --- a/docsrc/imap/reference/admin/sop/userdeny.rst +++ b/docsrc/imap/reference/admin/sop/userdeny.rst @@ -3,6 +3,8 @@ Managing user_deny.db The user_deny database allows you to deny access via POP/IMAP even if the user can authenticate to the Cyrus server. For example, if the authentication data is also used for other network services. +Use :cyrusman:`cyr_deny(8)` to manage the database. + If the user_deny.db file doesn't exist in %configdirectory% (often /var/lib/imap) then you'll need to create it. In the example below, /var/lib/imap/ is used. :: @@ -11,18 +13,14 @@ If the user_deny.db file doesn't exist in %configdirectory% (often /var/lib/imap # /usr/lib/cyrus-imapd/cvt_cyrusdb /tmp/user_deny.flat flat /var/lib/imap/user_deny.db skiplist # chown cyrus:cyrus /var/lib/imap/user_deny.db -The database specification can be found at http://cyrusimap.org/docs/cyrus-imapd/2.4.17/internal/database-formats.php - -**Key:** - -**Data:** TABTAB +The database specification can be found at :ref:`imap-concepts-deployment-db-userdeny`. :: # su - cyrus - $ cyr_dbtool /var/lib/imap/user_deny.db skiplist set **username** "2 pop3 Can't use pop." + $ cyr_dbtool /var/lib/imap/user_deny.db skiplist set **username** "2pop3Can't use pop." -In order to type a tab character, you will need to escape your tabs. In bash, this is done by typing CTRL-v and then pressing Tab. +Here `pop3` is the service name as spelled in :cyrusman:`cyrus.conf(5)`. In order to type a tab character, you will need to escape your tabs. In bash, this is done by typing CTRL-v and then pressing Tab. If you got it right, when you authenticate via pop3 you should see something like the following:: diff --git a/docsrc/imap/reference/architecture.rst b/docsrc/imap/reference/architecture.rst index f585661f8e..a47fff8ebc 100644 --- a/docsrc/imap/reference/architecture.rst +++ b/docsrc/imap/reference/architecture.rst @@ -109,8 +109,8 @@ Replication Replication is not Aggregation or :ref:`Cyrus Murder`. Replication provides high availability and hot backups. It is designed to replicate the mailstore on a standalone Cyrus install, or multiple backend -servers in a :ref:`murder ` configuration. (It does -not replicate mupdate master servers (frontends have no state to replicate). +servers in a :ref:`murder ` configuration. It does +not replicate mupdate master servers: frontends have no state to replicate. .. image:: images/image3-replication.jpg :height: 385 px @@ -147,7 +147,7 @@ know the imap credentials on the replica to allow it to send details to the replica. It is the port configuration on the replica to know where to listen for change updates. -There's two standard channel configurations: +There are two standard channel configurations: 1. Single master keeping all replicas up to date. diff --git a/docsrc/imap/reference/faqs/install-linkerwarnings.rst b/docsrc/imap/reference/faqs/install-linkerwarnings.rst index cea7f30eef..76a64f2f31 100644 --- a/docsrc/imap/reference/faqs/install-linkerwarnings.rst +++ b/docsrc/imap/reference/faqs/install-linkerwarnings.rst @@ -27,15 +27,3 @@ from the linker like the following: In this case, ClamAV is still linked against OpenSSL 1.0, while Cyrus is building with OpenSSL 1.1. - -.. _libical-warnings: - -Libical v1.0.1 or v2.0 ----------------------- - -You may see warnings regarding libical v2.0 being recommended to support certain -functionality. Currently libical v1.0.1 is sufficient, unless you need/want -RSCALE (non-gregorian recurrences), VPOLL (consensus scheduling), or -VAVAILABILITY (specifying availability over time) functionality. If v2 is -required, it will need to be installed from `github -`_. diff --git a/docsrc/imap/reference/faqs/o-annotations.rst b/docsrc/imap/reference/faqs/o-annotations.rst index 2e75bd218b..42b1909de2 100644 --- a/docsrc/imap/reference/faqs/o-annotations.rst +++ b/docsrc/imap/reference/faqs/o-annotations.rst @@ -3,50 +3,87 @@ What annotations are available? ------------------------------- -Cyrus annotations are based on a draft (http://tools.ietf.org/html/draft-daboo-imap-annotatemore-08) version of :rfc:`5464`. +Cyrus annotations are based on :rfc:`5464`. -* **/admin** - Sets the administrator email address for the server. (See :cyrusman:`cyradm(8)`) +* **/admin** - Sets the administrator email address for the server. (See + :cyrusman:`cyradm(8)`) -* **/check** - Boolean value "true" or "false" that indicates whether this mailbox should be checked at regular intervals by the client. The interval in minutes is specified by the ``/checkperiod`` entry. (Draft RFC) +* **/check** - Boolean value "true" or "false" that indicates whether this + mailbox should be checked at regular intervals by the client. The interval + in minutes is specified by the ``/checkperiod`` entry. (Draft RFC) -* **/checkperiod** - Numeric value indicating a period of minutes that the client uses to determine the interval of regular 'new mail' checks for the corresponding mailbox. (Draft RFC) +* **/checkperiod** - Numeric value indicating a period of minutes that the + client uses to determine the interval of regular 'new mail' checks for the + corresponding mailbox. (Draft RFC) -* **/comment** - Sets a comment or description associated with the mailbox. (cyradm(8)) /motd - Sets a "message of the day". The message gets displayed as an ALERT after authentication. +* **/comment** - Sets a comment or description associated with the mailbox. + (cyradm(8)) -* **/sort** - Defines the default sort criteria [I-D.ietf-imapext-sort] to use when first displaying the mailbox contents to the user, or NIL if sorting is not required. (Draft RFC) +* **/motd** - Sets a "message of the day". The message gets displayed + as an ALERT after authentication. -* **/thread** - Defines the default thread criteria [I-D.ietf-imapext-sort] to use when first displaying the mailbox contents to the user, or NIL if threading is not required. If both sort and thread are not NIL, then threading should take precedence over sorting. (Draft RFC) +* **/sort** - Defines the default sort criteria [I-D.ietf-imapext-sort] to use + when first displaying the mailbox contents to the user, or NIL if sorting is + not required. (Draft RFC) -* **/vendor/cmu/cyrus-imapd/condstore** - Enables the IMAP CONDSTORE extension (modification sequences) on the mailbox. (See :cyrusman:`cyradm(8)`) +* **/thread** - Defines the default thread criteria [I-D.ietf-imapext-sort] to + use when first displaying the mailbox contents to the user, or NIL if + threading is not required. If both sort and thread are not NIL, then + threading should take precedence over sorting. (Draft RFC) -* **vendor/cmu/cyrus-imapd/duplicatedeliver** - Flag signalling that we're allowing duplicate delivery of messages to the mailbox, overriding system-wide duplicate suppression. (https://cyrusimap.org/docs/cyrus-imapd/2.5.7/internal/mailbox-format.php) +* **/vendor/cmu/cyrus-imapd/condstore** - Enables the IMAP CONDSTORE extension + (modification sequences) on the mailbox. (See :cyrusman:`cyradm(8)`) -* **/vendor/cmu/cyrus-imapd/expire** - Sets the number of days after which messages will be expired from the mailbox. (cyradm(8)) +* **vendor/cmu/cyrus-imapd/duplicatedeliver** - Flag signalling that we're + allowing duplicate delivery of messages to the mailbox, overriding + system-wide duplicate suppression. + (:ref:`imap-developer-guidance-mailbox-format`) -* **/vendor/cmu/cyrus-imapd/archive** - Sets the number of days after which messages will be archived from the mailbox. (cyradm(8)) +* **/vendor/cmu/cyrus-imapd/expire** - Sets the number of days after which + messages will be expired from the mailbox. (cyradm(8)) -* **/vendor/cmu/cyrus-imapd/delete** - Sets the number of days after which messages will be deleted from the mailbox. (cyradm(8)) +* **/vendor/cmu/cyrus-imapd/archive** - Sets the number of days after which + messages will be archived from the mailbox. (cyradm(8)) + +* **/vendor/cmu/cyrus-imapd/delete** - Sets the number of days after which + messages will be deleted from the mailbox. (cyradm(8)) * **/vendor/cmu/cyrus-imapd/freespace** - Undocumented. -* **/vendor/cmu/cyrus-imapd/lastpop** - (time_t) of the last pop3 login to this INBOX, used to enforce the "poptimeout" imapd.conf option. (https://cyrusimap.org/docs/cyrus-imapd/2.5.7/internal/mailbox-format.php) +* **/vendor/cmu/cyrus-imapd/lastpop** - (time_t) of the last pop3 login to + this INBOX, used to enforce the "poptimeout" imapd.conf option. + (:ref:`imap-developer-guidance-mailbox-format`) -* **vendor/cmu/cyrus-imapd/lastupdate** - (time_t) of the last time a message was appended (https://cyrusimap.org/docs/cyrus-imapd/2.5.7/internal/mailbox-format.php) +* **vendor/cmu/cyrus-imapd/lastupdate** - (time_t) of the last time a message + was appended + (:ref:`imap-developer-guidance-mailbox-format`) -* **/vendor/cmu/cyrus-imapd/news2mail** - Sets an email address to which messages injected into the server via NNTP will be sent. (cyradm(8)) +* **/vendor/cmu/cyrus-imapd/news2mail** - Sets an email address to which + messages injected into the server via NNTP will be sent. (cyradm(8)) * **/vendor/cmu/cyrus-imapd/partition** - Undocumented. -* **/vendor/cmu/cyrus-imapd/pop3newuidl** - Flag signalling that we're using "uidvalidity.uid" instead of just "uid" for the output of the POP3 UIDL command. (https://cyrusimap.org/docs/cyrus-imapd/2.5.7/internal/mailbox-format.php) +* **/vendor/cmu/cyrus-imapd/pop3newuidl** - Flag signalling that we're using + "uidvalidity.uid" instead of just "uid" for the output of the POP3 UIDL + command. + (:ref:`imap-developer-guidance-mailbox-format`) * **/vendor/cmu/cyrus-imapd/serve** - Undocumented. -* **/vendor/cmu/cyrus-imapd/sharedseen** - Enables the use of a shared \Seen flag on messages rather than a per-user \Seen flag. The ’s’ right in the mailbox ACL still controls whether a user can set the shared \Seen flag. (See :cyrusman:`cyradm(8)`) +* **/vendor/cmu/cyrus-imapd/sharedseen** - Enables the use of a shared \Seen + flag on messages rather than a per-user \Seen flag. The ’s’ right in the + mailbox ACL still controls whether a user can set the shared \Seen flag. + (See :cyrusman:`cyradm(8)`) -* **/vendor/cmu/cyrus-imapd/shutdown** - Sets a shutdown message. The message gets displayed as an ALERT and all users are disconnected from the server (subsequent logins are disallowed). (cyradm(8)) +* **/vendor/cmu/cyrus-imapd/shutdown** - Sets a shutdown message. The message + gets displayed as an ALERT and all users are disconnected from the server + (subsequent logins are disallowed). (cyradm(8)) -* **/vendor/cmu/cyrus-imapd/sieve** - Indicates the name of the global sieve script that should be run when a message is delivered to the shared mailbox (not used for personal mailboxes). (cyradm(8)) +* **/vendor/cmu/cyrus-imapd/sieve** - Indicates the name of the global sieve + script that should be run when a message is delivered to the shared mailbox + (not used for personal mailboxes). (cyradm(8)) * **/vendor/cmu/cyrus-imapd/size** - Undocumented. -* **/vendor/cmu/cyrus-imapd/squat** - Indicates that the mailbox should be indexed. (See :cyrusman:`squatter(8)`) +* **/vendor/cmu/cyrus-imapd/squat** - Indicates that the mailbox should be + indexed. (See :cyrusman:`squatter(8)`) diff --git a/docsrc/imap/reference/faqs/o-gdb.rst b/docsrc/imap/reference/faqs/o-gdb.rst index b39d1e53da..f87b602796 100644 --- a/docsrc/imap/reference/faqs/o-gdb.rst +++ b/docsrc/imap/reference/faqs/o-gdb.rst @@ -9,8 +9,8 @@ How to run gdb on Cyrus components imapd, lmtpd (and some others), but does *not* include the command line tools. -An easy way to debug something in a service daemon is to write a `Cassandane -`_ test that tries to reproduce the +An easy way to debug something in a service daemon is to write a +:ref:`Cassandane test ` that tries to reproduce the bug. Cassandane has a ``[gdb]`` section in cassandane.ini which allows for starting service daemons in a debugger. diff --git a/docsrc/imap/reference/manpages/configs/cyrus.conf.rst b/docsrc/imap/reference/manpages/configs/cyrus.conf.rst index 7e2b32049a..0cc85636b5 100644 --- a/docsrc/imap/reference/manpages/configs/cyrus.conf.rst +++ b/docsrc/imap/reference/manpages/configs/cyrus.conf.rst @@ -257,6 +257,48 @@ is exiting. The command (with options) to spawn as a child process. This string argument is required. +.. parsed-literal:: + + **wait=**\ 0 + +.. + + Switch: whether or not :cyrusman:`master(8)` should wait for this + daemon to successfully start before continuing to load. + + If *wait=n* (the default), the daemon will be started asynchronously + along with the service processes. The daemon process will not have + file descriptor 3 open, and does not need to indicate its readiness. + + If *wait=y*, the daemon MUST write "ok\\r\\n" to file descriptor 3 + to indicate its readiness; if it does not do this, and master has + been told to wait, master will continue to wait.... If it writes + anything else to this descriptor, or closes it before writing + "ok\\r\\n", master will exit with an error. + + Daemons with *wait=y* will be started sequentially in the order + they are listed in cyrus.conf, waiting for each to report readiness + before the next is started. + + Service processes, and *wait=n* daemons, are not started until after + the *wait=y* daemons are all started and ready. + + At shutdown, *wait=y* daemons will be terminated sequentially in the + reverse order they were started, commencing after all other services + and *wait=n* daemons have finished. + + If a daemon that was started with *wait=y* exits unexpectedly, such + that master restarts it, master will restart it asynchronously, + without waiting for it to report its readiness. In this case, file + descriptor 3 will not be open and the daemon should not try to write + to it. + + If master is told to reread its config with a SIGHUP, this signal + will be passed on to *wait=y* daemons like any other service. If the + daemon exits in response to the signal, master will restart it + asynchronously, without waiting for it to report its readiness. In + this case too, file descriptor 3 will not be open and the daemon + should not try to write to it. Examples ======== diff --git a/docsrc/imap/reference/manpages/commands.rst b/docsrc/imap/reference/manpages/index.rst similarity index 99% rename from docsrc/imap/reference/manpages/commands.rst rename to docsrc/imap/reference/manpages/index.rst index bc87ad7a26..ef89d501a6 100644 --- a/docsrc/imap/reference/manpages/commands.rst +++ b/docsrc/imap/reference/manpages/index.rst @@ -20,8 +20,6 @@ Man pages systemcommands/* - - (1) User Commands ================= diff --git a/docsrc/imap/reference/manpages/systemcommands/arbitron.rst b/docsrc/imap/reference/manpages/systemcommands/arbitron.rst index a1e388b64b..394a9c11d8 100644 --- a/docsrc/imap/reference/manpages/systemcommands/arbitron.rst +++ b/docsrc/imap/reference/manpages/systemcommands/arbitron.rst @@ -58,21 +58,7 @@ Options |cli-dash-c-text| -.. option:: -o - - Report "the old way" -- not including subscribers. - -.. option:: -l - - Enable long reporting (comma delimited table consisting of mbox, - userid, r/s, start time, end time). - -.. option:: -d days - - Count as a reader an authorization identity which has used the - mailbox within the past *days* days. - -.. option:: -D mmddyyyy[:mmddyyyy] +.. option:: -D mmddyyyy[:mmddyyyy], --date=mmddyyyy[:mmddyyyy] Count as a reader an authorization identity which has used the mailbox within the given date range. @@ -88,11 +74,31 @@ Options Please note that the date notation is American [\ *mmddyyyy*\ ] not [\ *ddmmyyyy*\ ]. -.. option:: -p months +.. option:: -d days, --days=days + + Count as a reader an authorization identity which has used the + mailbox within the past *days* days. + +.. option:: -l, --detailed + + Enable long reporting (comma delimited table consisting of mbox, + userid, r/s, start time, end time). + +.. option:: -o, --no-subscribers + + Report "the old way" -- not including subscribers. + +.. option:: -p months, --prune-seen=months Prune ``\Seen`` state for users who have not used the mailbox within the past *months* months. The default is infinity. +.. option:: -u, --include-userids + + Include userids of mailbox readers in the report. If the report + will contain mailbox subscribers (see **--no-subscribers**), also + include userids of the subscribers. + Examples ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/backupd.rst b/docsrc/imap/reference/manpages/systemcommands/backupd.rst index 3357cd70da..4c672f2986 100644 --- a/docsrc/imap/reference/manpages/systemcommands/backupd.rst +++ b/docsrc/imap/reference/manpages/systemcommands/backupd.rst @@ -19,6 +19,9 @@ Synopsis Description =========== +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. + **backupd** is the Cyrus Backups server. It accepts Cyrus replication protocol commands on its standard input and responds on its standard output. It MUST be invoked by :cyrusman:`master(8)` with those descriptors attached to a diff --git a/docsrc/imap/reference/manpages/systemcommands/chk_cyrus.rst b/docsrc/imap/reference/manpages/systemcommands/chk_cyrus.rst index 3535b70d18..0445c89788 100644 --- a/docsrc/imap/reference/manpages/systemcommands/chk_cyrus.rst +++ b/docsrc/imap/reference/manpages/systemcommands/chk_cyrus.rst @@ -38,11 +38,11 @@ Options |cli-dash-c-text| -.. option:: -P partition +.. option:: -P partition, --partition=partition Limit to partition *partition*. May not be specified with **-M**. -.. option:: -M mailbox +.. option:: -M mailbox, --mailbox=mailbox Only check mailbox *mailbox*. May not be specified with **-P**. diff --git a/docsrc/imap/reference/manpages/systemcommands/ctl_backups.rst b/docsrc/imap/reference/manpages/systemcommands/ctl_backups.rst index dcd9df6836..c90174ed6a 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ctl_backups.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ctl_backups.rst @@ -25,6 +25,9 @@ Synopsis Description =========== +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. + **ctl_backups** is a tool for performing administrative operations on Cyrus backups. @@ -111,12 +114,12 @@ Options |cli-dash-c-text| -.. option:: -F +.. option:: -F, --force Force the operation to occur, even if it is determined to be unnecessary. This is mostly useful with the **compact** sub-command. -.. option:: -S +.. option:: -S, --stop-on-error Stop-on-error. With this option, if a sub-command fails for any particular backup, **ctl_backups** will immediately exit with an error, @@ -124,7 +127,7 @@ Options The default is to log the error, and continue with the next backup. -.. option:: -V +.. option:: -V, --no-verify Don't verify backup checksums for read-only operations. @@ -135,15 +138,15 @@ Options With this option, the verification step will be skipped. -.. option:: -j +.. option:: -j, --json Produce output in JSON format. The default is plain text. -.. option:: -v +.. option:: -v, --verbose Increase the verbosity. Can be specified multiple times. -.. option:: -w +.. option:: -w, --wait-for-locks Wait for locks. With this option, if a backup named on the command line is locked, execution will block until the lock becomes available. @@ -158,7 +161,7 @@ List Options Options that apply only to the **list** sub-command. -.. option:: -t [hours] +.. option:: -t [hours], --stale[=hours] List stale backups only, that is, backups that have received no updates in *hours*. If *hours* is unspecified, it defaults to 24. @@ -170,26 +173,26 @@ Lock Options Options that apply only to the **lock** sub-command. -.. option:: -c +.. option:: -c, --create Exclusively create the named backup while obtaining the lock. Exits immediately with an error if the named backup already exists. When the lock is successfully obtained, continue as per the other options. -.. option:: -p +.. option:: -p, --pause Locks the named backup, and then waits for EOF on the standard input stream. Unlocks the backup and exits once EOF is received. This is the default mode of operation. -.. option:: -s +.. option:: -s, --sqlite3 Locks the named backup, and with the lock held, opens its index file in the :manpage:`sqlite3(1)` program. The lock is automatically released when sqlite3 exits. -.. option:: -x command +.. option:: -x command, --execute=command Locks the named backup, and with the lock held, executes *command* using **/bin/sh** (as per :manpage:`system(3)`). The lock is automatically @@ -204,35 +207,35 @@ Options that apply only to the **lock** sub-command. Modes ===== -.. option:: -A +.. option:: -A, --all Run sub-command over all known backups. Known backups are recorded in the database specified by the **backup_db** and **backup_db_path** configuration options. -.. option:: -D +.. option:: -D, --domains Backups specified on the command line are interpreted as domains. Run sub-command over known backups for users in these domains. -.. option:: -P +.. option:: -P, --prefixes Backups specified on the command line are interpreted as userid prefixes. Run sub-command over known backups for users matching these prefixes. -.. option:: -f +.. option:: -f, --filenames Backups specified on the command line are interpreted as filenames. Run sub-command over the matching backup files. The backup files do not need to be known about in the backups database. -.. option:: -m +.. option:: -m, --mailboxes Backups specified on the command line are interpreted as mailbox names. Run sub-command over known backups containing these mailboxes. -.. option:: -u +.. option:: -u, --userids Backups specified on the command line are interpreted as userids. Run sub-command over known backups for matching users. diff --git a/docsrc/imap/reference/manpages/systemcommands/ctl_conversationsdb.rst b/docsrc/imap/reference/manpages/systemcommands/ctl_conversationsdb.rst index 744bea8a10..9937a53791 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ctl_conversationsdb.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ctl_conversationsdb.rst @@ -54,36 +54,46 @@ Options |cli-dash-c-text| -.. option:: -d userid +.. option:: -d, --dump Dump the conversations database which corresponds to the user *userid* to standard output in an ASCII format. The resulting file can be used to recreate a database using the **-u** option. -.. option:: -u userid +.. option:: -u, --undump "Undumps" the conversations database corresponding to the user *userid*, i.e. replaces all the entries with data from ASCII records parsed from standard input. The output from the **-d** option can be used as input. -.. option:: -v +.. option:: -v, --verbose Be more verbose when running. -.. option:: -r +.. option:: -r, --recursive Be recursive; apply the main operation to every user. Warning: do not combine with **-u**, it will not do what you expect. -.. option:: -z +.. option:: -z, --clear Remove all conversation information from the conversations database for user *userid*, and from all the user's mailboxes. The information can all be recalculated (eventually) from message headers, using the **-b** option. -.. option:: -b +.. option:: -Z, --clearcids cid,... + + Remove all conversation information from the conversations database + for user *userid*, and from all the user's mailboxes for conversations + matching the comma separated list of cids in hex format. Can be + specified more than once. + + The information can all be recalculated (eventually) from message + headers, using the **-b** option. + +.. option:: -b, --rebuild Rebuild all conversation information in the conversations database for user *userid*, and in all the user's mailboxes, from the header @@ -97,17 +107,17 @@ Options from *cyrus.cache* files so it does not need to read every single message file. -.. option:: -R +.. option:: -R, --update-counts Recalculate counts of messages stored in existing conversations in the conversations database for user *userid*. This is a limited subset of **-b**; in particular it does not create conversations or assign messages to conversations. -.. option:: -S +.. option:: -S, --split If given with **-b**, allows splitting of conversations during the - rewrite. Only do this is changing the maximum conversation size + rewrite. Only do this if changing the maximum conversation size and you need to split those existing conversations. Examples diff --git a/docsrc/imap/reference/manpages/systemcommands/ctl_cyrusdb.rst b/docsrc/imap/reference/manpages/systemcommands/ctl_cyrusdb.rst index de2bf3a445..c7b136a64e 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ctl_cyrusdb.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ctl_cyrusdb.rst @@ -37,7 +37,7 @@ Options |cli-dash-c-text| -.. option:: -r +.. option:: -r, --recover Recover the database after an application or system failure. Also performs database cleanups like removing mailbox reservations (and @@ -47,12 +47,18 @@ Options matches the configured database type in imapd.conf. If not, the file is automatically converted using the same logic as cvt_cyrusdb. -.. option:: -x + If the ``reverseacls`` option in :cyrusman:`imapd.conf(5)` is enabled, + and the RACL entries in the database are an old version or do not + exist, they will be generated. Conversely, if RACL entries do exist + in the database, but the ``reverseacls`` option is disabled, then the + entries will be cleaned up. + +.. option:: -x, --no-cleanup Used with ``-r`` to only recover the database, and prevent any cleanup. -.. option:: -c +.. option:: -c, --checkpoint Checkpoint and archive (a copy of) the database. diff --git a/docsrc/imap/reference/manpages/systemcommands/ctl_deliver.rst b/docsrc/imap/reference/manpages/systemcommands/ctl_deliver.rst index 270e033b70..582d5c02c2 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ctl_deliver.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ctl_deliver.rst @@ -36,12 +36,12 @@ Options |cli-dash-c-text| -.. option:: -d +.. option:: -d, --dump Dump the contents of the database to standard output in a portable flat-text format. -.. option:: -f filename +.. option:: -f filename, --filename=filename Use the database specified by *filename* instead of the default (*configdirectory*/**deliver.db**). diff --git a/docsrc/imap/reference/manpages/systemcommands/ctl_mboxlist.rst b/docsrc/imap/reference/manpages/systemcommands/ctl_mboxlist.rst index 2fb4ca6a6c..bd92145419 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ctl_mboxlist.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ctl_mboxlist.rst @@ -17,7 +17,7 @@ Synopsis .. parsed-literal:: **ctl_mboxlist** [ **-C** *config-file* ] **-d** [ **-x** ] [**-y**] [ **-p** *partition* ] [ **-f** *filename* ] - **ctl_mboxlist** [ **-C** *config-file* ] **-u** [ **-f** *filename* ] + **ctl_mboxlist** [ **-C** *config-file* ] **-u** [ **-f** *filename* ] [ **-L** ] **ctl_mboxlist** [ **-C** *config-file* ] **-m** [ **-a** ] [ **-w** ] [ **-i** ] [ **-f** *filename* ] **ctl_mboxlist** [ **-C** *config-file* ] **-v** [ **-f** *filename* ] @@ -40,69 +40,75 @@ Options |cli-dash-c-text| -.. option:: -d +.. option:: -d, --dump - Dump the contents of the database to standard output in a portable - flat-text format. NOTE: In Cyrus versions 2.2.13 and earlier, the - dump format did not include the mailbox type flags, breaking remote - mailboxes (frontends, mupdate master, unified backends) when - undumped. + Dump the contents of the database to standard output in JSON format. -.. option:: -x +.. option:: -x, --remove-dumped When performing a dump, remove the mailboxes dumped from the mailbox list (mostly useful when specified with **-p**). -.. option:: -y +.. option:: -y, --include-intermediaries When performing a dump, also list intermediary mailboxes which would be hidden from IMAP. -.. option:: -p partition +.. option:: -p partition, --partition=partition When performing a dump, dump only those mailboxes that live on *partition*. -.. option:: -f filename +.. option:: -f filename, --filename=filename Use the database specified by *filename* instead of the default (*configdirectory/mailboxes.db**). -.. option:: -u +.. option:: -L, --legacy - Load the contents of the database from standard input. The input - MUST be in the format output by the **-d** option. + When performing an undump, use the legacy dump parser instead of the + JSON parser. This might be useful for importing a dump produced + by an older version of Cyrus. -.. NOTE:: - Both the old and new formats can be loaded, but the old format will - break remote mailboxes. +.. option:: -u, --undump -.. option:: -m + Load ("undump") the contents of the database from standard input. The + input MUST be a valid JSON file, unless the -L option is also supplied. + + .. IMPORTANT:: + USE THIS OPTION WITH CARE. If you have modified the dump file since it + was dumped, or if the file was not produced by **-d** in the first + place, or was produced on a different server, you can easily break your + mailboxes.db. Undump will refuse to process a syntactically-invalid + dump file, but it can't do much to protect you from a valid file + containing bad data. + +.. option:: -m, --sync-mupdate For backend servers in the Cyrus Murder, synchronize the local mailbox list file with the MUPDATE server. -.. option:: -a +.. option:: -a, --authoritative When used with **-m**, assume the local mailboxes file is authoritative, that is, only change the mupdate server, do not delete any local mailboxes. -.. IMPORTANT:: - USE THIS OPTION WITH CARE, as it allows namespace collisions into - the murder. + .. IMPORTANT:: + USE THIS OPTION WITH CARE, as it allows namespace collisions into + the murder. -.. option:: -w +.. option:: -w, --warn-only When used with **-m**, print out what would be done but do not perform the operations. -.. option:: -i +.. option:: -i, --interactive When used with **-m**, asks for verification before deleting local mailboxes. -.. option:: -v +.. option:: -v, --verify Verify the consistency of the mailbox list database and the spool partition(s). Mailboxes present in the database and not located on a @@ -119,24 +125,7 @@ Examples .. - Dump the mailboxes list in portable text format. - -.. only:: html - - :: - - tech 0 default anyone lrsp group:tech lrswipkxtecda - tech.support 0 default johnsmith lrswipkxtea group:tech lrswipkxtecda anyone lrsp - tech.support.rancid 0 default johnsmith lrswipkxtea group:tech lrswipkxtecda anyone lrsp - tech.support.commits 0 default johnsmith lrswipkxtea group:tech lrswipkxtecda anyone lrsp - tech.support.abuse 0 default johnsmith lrswipkxtea group:tech lrswipkxtecda anyone lrsp - tech.systems 0 default anyone lrsp group:tech lrswipkxtecda - tech.systems.box 0 default anyone lrsp group:tech lrswipkxtecda - tech.systems.switch 0 default anyone lrsp group:tech lrswipkxtecda - tech.systems.files 0 default anyone lrsp group:tech lrswipkxtecda - tech.systems.printer 0 default anyone lrsp group:tech lrswipkxtecda - tech.technet 0 default anyone lrsp group:tech lrswipkxtecda -.. + Dump the mailboxes list to standard output in JSON format .. parsed-literal:: @@ -145,7 +134,10 @@ Examples .. Undump (restore) the mailboxes database from *newmboxlist.dump*, - a portable text formatted file. + where *newmboxlist.dump* is a JSON file produced by **ctl_mboxlist -d** + + .. Note:: + Be very careful with this option. .. parsed-literal:: diff --git a/docsrc/imap/reference/manpages/systemcommands/ctl_zoneinfo.rst b/docsrc/imap/reference/manpages/systemcommands/ctl_zoneinfo.rst index 064d842678..b62a765755 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ctl_zoneinfo.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ctl_zoneinfo.rst @@ -17,6 +17,8 @@ Synopsis **ctl_zoneinfo** [ **-C** *config-file* ] [ **-v** ] **-r** *version-string* + **ctl_zoneinfo** [ **-C** *config-file* ] [ **-v** ] **-w** *file* + Description =========== @@ -34,19 +36,24 @@ Options |cli-dash-c-text| -.. option:: -v +.. option:: -v, --verbose Enable verbose output. -.. option:: -r version-string +.. option:: -r version-string, --rebuild=version-string Rebuild the zoneinfo database based on the directory structure of *configdirectory*/**zoneinfo**. The database to be rebuilt will be in the default location of *configdirectory*/**zoneinfo.db** unless otherwise specified by the *zoneinfo_db_path* option in :cyrusman:`imapd.conf(5)`. The *version-string* should describe the - source of the timezone data (e.g. "Olson 2013h") and will be used - by the *timezone* module of :manpage:`httpd(8)`. + source of the timezone data (e.g. "Olson:2020a") and will be used + by the *tzdist* module of :manpage:`httpd(8)`. The *version-string* + must contain a colon between the description and the version. + +.. option:: -w file, --windows-zone-xml=file + + Reads Windows Zone XML file. Examples ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/cvt_xlist_specialuse.rst b/docsrc/imap/reference/manpages/systemcommands/cvt_xlist_specialuse.rst index 9fce82ea61..eb55f969f5 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cvt_xlist_specialuse.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cvt_xlist_specialuse.rst @@ -32,7 +32,7 @@ Options |cli-dash-c-text| -.. option:: -v +.. option:: -v, --verbose Produce verbose output diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_backup.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_backup.rst index e1a7f7b1d8..a45611a638 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_backup.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_backup.rst @@ -29,6 +29,9 @@ Synopsis Description =========== +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. + **cyr_backup** is a tool for inspecting the contents of a Cyrus backup. **cyr_backup** |default-conf-text| @@ -101,7 +104,7 @@ Options |cli-dash-c-text| -.. option:: -v +.. option:: -v, --verbose Increase the verbosity. Can be specified multiple times. @@ -110,12 +113,12 @@ Options Modes ===== -.. option:: -f +.. option:: -f, --filename *backup* is interpreted as a filename. The named file does not need to be known about in the backups database. -.. option:: -m +.. option:: -m, --mailbox *backup* is interpreted as a mailbox name. There must be a known backup for the user whose mailbox this is. @@ -123,7 +126,7 @@ Modes Known backups are recorded in the database specified by the **backup_db** and **backup_db_path** configuration options. -.. option:: -u +.. option:: -u, --userid *backup* is interpreted as a userid. There must be a known backup for the specified user. diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_dbtool.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_dbtool.rst index 61487baa1a..14188d15a7 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_dbtool.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_dbtool.rst @@ -68,20 +68,15 @@ Options |cli-dash-c-text| -.. option:: -M +.. option:: -M, --improved-mboxlist-sort Uses improved MBOX list sort -.. option:: -n +.. option:: -n, --create Create the database file if it doesn't already exist. -.. option:: -o - - Store all the output in memory and only print it once the transaction - is completed. - -.. option:: -T +.. option:: -T, --use-transaction Use a transaction to do the action (most especially for 'show') - the default used to be transactions. @@ -97,7 +92,8 @@ is typically a Cyrus "flat" format database. database version number (currently 2), a list of "wildmat" patterns specifying Cyrus services to be denied, and a text message to be displayed to the user upon denial. The service names to be matched are -those as used in :cyrusman:`cyrus.conf(5)`. +those as used in :cyrusman:`cyrus.conf(5)`. :cyrusman:`cyr_deny(8)` +provides more convenient way to manage *user_deny.db*. .. Note:: diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_deny.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_deny.rst index 9e61405f9c..3ab63b61d3 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_deny.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_deny.rst @@ -26,7 +26,8 @@ Description The first synopsis denies user *user* access to Cyrus services, the second synopsis allows access again. **cyr_deny** works by adding an entry to the Cyrus ``user_deny.db`` database; the third synopsis lists -the entries in the database. +the entries in the database. The service names to be matched are those +as used in :cyrusman:`cyrus.conf(5)`. **cyr_deny** |default-conf-text| @@ -39,24 +40,24 @@ Options |cli-dash-c-text| -.. option:: -a user +.. option:: -a, --allow Allow access to all services for user *user* (remove any entry from the deny database). -.. option:: -s services +.. option:: -s services, --services=services Deny access only to the given *services*, which is a comma-separated list of wildcard patterns. The default is "*" which denies access to all services. -.. option:: -m message +.. option:: -m message, --message=message Provide a message which is sent to the user to explain why access is being denied. A default message is used if none is specified. -.. option:: -l +.. option:: -l, --list List the entries in the deny database. diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_df.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_df.rst index 09136a0668..3f84584835 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_df.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_df.rst @@ -35,7 +35,7 @@ Options |cli-dash-c-text| -.. option:: -m +.. option:: -m, --metadata Report on metadata partitions rather than message file partitions. diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_expire.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_expire.rst index 4c4cc02218..5e87e392f3 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_expire.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_expire.rst @@ -42,9 +42,19 @@ There are various annotations that **cyr_expire** respects: messages - ``/vendor/cmu/cyrus-imapd/delete`` which controls the deletion of messages +- ``/vendor/cmu/cyrus-imapd/noexpire_until`` which disables the expire + and delete operations per user -These mailbox annotations specify the age(in days) of messages in the -given mailbox that should be expired/archived/deleted. +The first three mailbox annotations specify the age of messages in the +given mailbox that should be expired/archived/deleted. The +age is specified as a duration, the default unit are days. +The duration format is defined in :cyrusman:`imapd.conf(5)`. + +The last mailbox annotation specifies the UNIX epoch time in seconds +until which expiring messages or removing deleted mailboxes is blocked. +The zero epoch time represents infinity. This annotation has precedence +over any of the other annotations or command line flags. It must only +be set on the user inbox and applies to all mailboxes of that user. The value of the ``/vendor/cmu/cyrus-imapd/expire`` annotation is inherited by all children of the mailbox on which it is set, so an @@ -84,7 +94,7 @@ Options |cli-dash-c-text| -.. option:: -A archive-duration +.. option:: -A archive-duration, --archive-duration=archive-duration Archive non-flagged messages older than *archive-duration* to the archive partition, allowing mailbox messages to be split between fast @@ -92,67 +102,69 @@ Options ``archivepartition-*`` has been set in your config. This value is only used for entries which do not have a corresponding ``/vendonr/cmu/cyrus-imapd/archive`` mailbox annotation. + The duration format is defined in :cyrusman:`imapd.conf(5)`. The default + unit are days. |v3-new-feature| -.. option:: -D delete-duration +.. option:: -D delete-duration, --delete-duration=delete-duration Remove previously deleted mailboxes older than *delete-duration* (when using the "delayed" delete mode). - The value can be a floating point number, and may have a suffix to - specify the unit of time. If no suffix, the value is number of days. - Valid suffixes are **d** (days), **h** (hours), **m** (minutes) and - **s** (seconds). This value is only used for entries which do not have a corresponding ``/verdor/cmu/cyrus-imapd/delete`` mailbox annotation. + The duration format is defined in :cyrusman:`imapd.conf(5)`. The default + unit are days. -.. option:: -E expire-duration +.. option:: -E expire-duration, --expire-duration=expire-duration Prune the duplicate database of entries older than *expire-duration*. This value is only used for entries which do not have a corresponding ``/vendor/cmu/cyrus-imapd/expire`` mailbox annotation. - Format is the same as delete-duration. + The duration format is defined in :cyrusman:`imapd.conf(5)`. The default + unit are days. -.. option:: -X expunge-duration +.. option:: -X expunge-duration, --expunge-duration=expunge-duration Expunge previously deleted messages older than *expunge-duration* (when using the "delayed" expunge mode). - Format is the same as delete-duration. + The duration format is defined in :cyrusman:`imapd.conf(5)`. The default + unit are days. -.. option:: -c +.. option:: -c, --no-conversations Do not expire conversation database entries, even if the conversations feature is enabled. |v3-new-feature| -.. option:: -x +.. option:: -x, --no-expunge Do not expunge messages even if using delayed expunge mode. This reduces IO traffic considerably, allowing ``cyr_expire`` to be run frequently to clean up the duplicate database without overloading the machine. -.. option:: -p mailbox-prefix +.. option:: -p mailbox-prefix, --prefix=mailbox-prefix Only find mailboxes starting with this prefix, e.g. "user.justgotspammedlots". -.. option:: -u userid +.. option:: -u userid, --userid=userid Only find mailboxes belonging to this user, e.g. "justgotspammedlots@example.com". -.. option:: -t +.. option:: -t, --prune-userflags Remove any user flags which are not used by remaining (not expunged) messages. -.. option:: -v +.. option:: -v, --verbose Enable verbose output. -.. option:: -a +.. option:: -a, --ignore-annotations Skip the annotation lookup, so all ``/vendor/cmu/cyrus-imapd/expire`` annotations are ignored entirely. It behaves as if they were not diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_info.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_info.rst index 5f2dd8e3d9..04bc46ff1d 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_info.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_info.rst @@ -35,17 +35,27 @@ configuring Cyrus easier. .. option:: conf - Print only the configuration options which are not the same as - default (regardless of whether you have specified them or not). + Print the configuration options which have been set to a value + other than their default, and their value. -.. option:: conf-default - - Print all default configuration options, ignoring those set locally. + With **-s version**, configuration options whose behaviour has + changed since *version* will be highlighted. .. option:: conf-all - Print ALL configuration options - including default options. This - command shows which options will be in effect at runtime. + Print ALL configuration options and their configured values (including + those using their default value). This command shows which options + will be in effect at runtime. + + With **-s version**, configuration options which have been added or + whose behaviour has changed since *version* will be highlighted. + +.. option:: conf-default + + Print the default values for all available configuration options. + + With **-s version**, configuration options which have been added or + whose behaviour has changed since *version* will be highlighted. .. option:: conf-lint @@ -56,7 +66,7 @@ configuring Cyrus easier. .. option:: proc - Print all currently connected processes in the proc directory + Print active processes that :cyrusman:`master(8)` is managing. Options ======= @@ -71,10 +81,19 @@ Options Read service specifications from *config-file* (cyrus.conf format). -.. option:: -n servicename +.. option:: -n name, --service=name Read the configuration as if for the service named *name*. +.. option:: -s version, --since=version + + Highlight configuration options that have been added or whose behaviour + has been modified since *version*. Use this option after a server upgrade, + specifying your previous version, to find which options you need to review + and maybe change before starting up the upgraded server. + + For use with the **conf**, **conf-all**, and **conf-default** sub-commands. + Examples ======== @@ -84,7 +103,7 @@ Examples .. - List all the proc files and who they're logged in as. + List the active processes that master is managing .. only:: html @@ -126,4 +145,4 @@ Files See Also ======== -:cyrusman:`imapd.conf(5)`, :cyrusman:`cyrus.conf(5)` +:cyrusman:`imapd.conf(5)`, :cyrusman:`cyrus.conf(5)`, :cyrusman:`master(8)` diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_ls.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_ls.rst new file mode 100644 index 0000000000..627deaec77 --- /dev/null +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_ls.rst @@ -0,0 +1,82 @@ +.. cyrusman:: cyr_ls(8) + +.. author: Ken Murchison (Fastmail) + +.. _imap-reference-manpages-systemcommands-cyr_ls: + +========== +**cyr_ls** +========== + +List Cyrus mailbox directory contents + +Synopsis +======== + +.. parsed-literal:: + + **cyr_ls** [ **-C** *config-file* ] [ **-l** ] [ **-m** ] [ **-R** ] [ **-1** ] [ *mailbox-name* ] + +Description +=========== + +List information about the directory corresponding to the given +mailbox name (the current directory by default) + +**cyr_ls** |default-conf-text| It uses /mailboxes.db +to locate the mailboxes on disk. + +Options +======= + +.. program:: cyr_ls + +.. option:: -C config-file + + |cli-dash-c-text| + +.. option:: -l, --long + + Use a long listing format. + +.. option:: -m, --metadata + + Output the path to the metadata files (if different from the + message files). + +.. option:: -R, --recursive + + List submailboxes recursively. + +.. option:: -1, --one-per-line + + List one file per line. + +Examples +======== + +.. parsed-literal:: + + **cyr_ls** *user/jsmith* + +.. + + Display the directory contents for mailbox *user/jsmith*. + +.. only:: html + + :: + + 1. cyrus.cache cyrus.index cyrus.header + Sent Trash + +Files +===== + +/etc/imapd.conf, +/mailboxes.db + +See Also +======== + +:cyrusman:`imapd.conf(5)` diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_sequence.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_sequence.rst deleted file mode 100644 index e17bd08d16..0000000000 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_sequence.rst +++ /dev/null @@ -1,115 +0,0 @@ -.. cyrusman:: cyr_sequence(8) - -.. _imap-reference-manpages-systemcommands-cyr_sequence: - -================ -**cyr_sequence** -================ - -Debug tool for seqset. Also useful for resolving sequences. - -Synopsis -======== - -.. parsed-literal:: - - **cyr_sequence** [ **-C** *altconfig* ] [ **-m** *maxval* ] \ *sequence* - - The *command* is one of: - - * parsed - * compress - * members - * ismember - * create - - The *sequence* is a list of sequences. Discrete numbers are separated with commas, ranges are separated by colons. - -Description -=========== - -**cyr_sequence** shows what happens when various operations are performed over a sequence. - - -Options -======= - -.. program:: cyr_sequence - -.. option:: parsed *sequence* - - Dumps a parsed view of the list structure, broken into contiguous sections. - -.. option:: compress *sequence* - - Given a list, compress ranges with colons. - -.. option:: members *sequence* - - Displays the full list of members within the sequence, in order, expanding out the ranges. - -.. option:: ismember *[num...]* - - For each number in the list, check if it's in the sequence. - -.. option:: create *[-s] [-o origlist] [items]* - - Generate a new list from the items, prefix numbers with ``~`` to remove them from the list. - If an original list is given, this is joined into this new list. - - The *-s* flag generates a sparse list. - -.. option:: join *sequence1* *sequence2* - - Join two sequences together and return the output in compressed format. - -.. option:: -C *altconfig* - - Specify an alternate config file. - -.. option:: -m *maxval* - - Limit the maximum value to accept. - -Examples -======== - -.. parsed-literal:: - - **cyr_sequence parsed 1,3,4,5** - -.. only:: html - - :: - - Sections: 2 - [1, 1] - [3, 5] - -.. parsed-literal:: - - **cyr_sequence compress 1,3,4,5** - -.. only:: html - - :: - - 1,3:5 - -.. parsed-literal:: - - **cyr_sequence members 1,23:25,28,30:32** - -.. only:: html - - :: - - 1 - 23 - 24 - 25 - 28 - 30 - 31 - 32 - diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_synclog.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_synclog.rst index f257ae8f75..6724cfb803 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_synclog.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_synclog.rst @@ -38,27 +38,27 @@ Options |cli-dash-c-text| -.. option:: -u user +.. option:: -u, --user user -.. option:: -U unuser +.. option:: -U, --unuser unuser -.. option:: -v sieve +.. option:: -v, --sieve sieve -.. option:: -m mailbox +.. option:: -m, --mailbox mailbox -.. option:: -M unmailbox +.. option:: -M, --unmailbox unmailbox -.. option:: -a append +.. option:: -a, --append append -.. option:: -c acl +.. option:: -c, --acl acl -.. option:: -q quota +.. option:: -q, --quota quota -.. option:: -n annotation +.. option:: -n, --annotation annotation -.. option:: -s seen +.. option:: -s, --seen seen -.. option:: -b subscription +.. option:: -b, --subscription subscription Examples ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_userseen.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_userseen.rst index 72c8332f81..b8e26c540b 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_userseen.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_userseen.rst @@ -42,7 +42,7 @@ Options |cli-dash-c-text| -.. option:: -d +.. option:: -d, --delete Actually delete all user seen state information. diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_virusscan.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_virusscan.rst index bfbef30975..44392fad60 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_virusscan.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_virusscan.rst @@ -1,5 +1,7 @@ .. cyrusman:: cyr_virusscan(8) +.. highlight:: none + .. author: Nic Bernstein (Onlight) .. _imap-reference-manpages-systemcommands-cyr_virusscan: @@ -35,9 +37,10 @@ A table of infected messages will be output. To remove infected messages, use the **-r** flag. Infected messages will be expunged from the user's mailbox. -With the notify flag, **-n**, notifications will be appended to the inbox of the mailbox owner, -containing message digest information for the affected mail. This -flag is only works in combination with **-r**. +With the notify flag, **-n**, notifications will be appended to the inbox of +the mailbox owner, containing message digest information for the affected mail. +This flag only works in combination with **-r**. The notification message +can by customised by template, for details see `Notifications`_ below. **cyr_virusscan** can be configured to run periodically by cron(8) via crontab(5) or your preferred method (i.e. /etc/cron.hourly), or by @@ -59,24 +62,62 @@ Options |cli-dash-c-text| -.. option:: -n +.. option:: -n, --notify Notify mailbox owner of deleted messages via email. This flag is only operable in combination with **-r**. -.. option:: -r +.. option:: -r, --remove-infected Remove infected messages. -.. option:: -s imap-search-string +.. option:: -s imap-search-string, --search=imap-search-string Rather than scanning for viruses, messages matching the search criteria will be treated as infected. -.. option:: -v +.. option:: -v, --verbose Produce more verbose output +Notifications +============= + +When the **-n** flag is provided, notifications are sent to mailbox owners +when infected messages are removed. One notification is sent per owner, +containing a digest of each message that was deleted from any of their +mailboxes. + +The default notification subject is "Automatically deleted mail", which +can be overridden by setting ``virusscan_notification_subject`` in +:cyrusman:`imapd.conf(5)` to a UTF-8 value. + +Each infected message will be described according to the following template:: + + The following message was deleted from mailbox '%MAILBOX%' + because it was infected with virus '%VIRUS%' + + Message-ID: %MSG_ID% + Date: %MSG_DATE% + From: %MSG_FROM% + Subject: %MSG_SUBJECT% + IMAP UID: %MSG_UID% + +To use a custom template, create a UTF-8 file containing your desired text +and using the same %-delimited substitutions as above, and set the +``virusscan_notification_template`` option in :cyrusman:`imapd.conf(5)` to +its path. + +The notification message will be properly MIME-encoded at delivery. Do not +pre-encode the template file or the subject! + +When **cyr_virusscan** starts up, if notifications have been requested (with +the **-n** flag), a basic sanity check of the template will be performed +prior to initialising the antivirus engine. If it appears that the +resultant notifications would be undeliverable for some reason, +**cyr_virusscan** will exit immediately with an error, rather than risk +deleting messages without notifying. + Examples ======== @@ -114,7 +155,7 @@ Examples .. Scan mailbox *user/bovik*, removing infected messages and append - notifications to bovik's inbox. + notifications to Bovik's inbox. .. only:: html @@ -130,12 +171,12 @@ Examples :: - The following message was deleted from mailbox 'Inbox.bovik' + The following message was deleted from mailbox 'INBOX' because it was infected with virus 'Email.Trojan.Trojan-1051' Message-ID: <201308131519.r7DFJM9K083763@tselina.kiev.ua> Date: Tue, 13 Aug 2013 18:19:22 +0300 (EEST) - From: ("FEDEX Thomas Cooper" NIL "thomas_cooper94" "themovieposterpage.com") + From: "FEDEX Thomas Cooper" Subject: Problem with the delivery of parcel IMAP UID: 17426 diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_withlock_run.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_withlock_run.rst new file mode 100644 index 0000000000..c7c5c35703 --- /dev/null +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_withlock_run.rst @@ -0,0 +1,85 @@ +.. cyrusman:: cyr_withlock_run(8) + +.. author: Bron Gondwana + +.. _imap-reference-manpages-systemcommands-cyr_withlock_run: + +==================== +**cyr_withlock_run** +==================== + +is used to run system command with a lock held + +.. warning:: + + This command runs as the Cyrus user, so if you need to call something + as root, the cyrus user may need sudo privileges. + +Synopsis +======== + +.. parsed-literal:: + + **cyr_withlock_run** [ **-C** *config-file* ] [ **-u** *userid* ] cmd args... + +Description +=========== + +**cyr_withlock_run** will run the command and arguments provided on the +command line, either locking the entire server, or a particular user, so +no changes can be made to it while this command is running. + +WARNING: since this takes an exclusive lock, it will deadlock if the command tries +to connect to Cyrus via IMAP/JMAP/etc and run commands that take exclusive locks. +You can run cyrus commandline tools so long as the environment is passed through. + +It is most useful for running an external filesystem snapshot command, which can +be run safely, knowing that files aren't in a partial state. + + +**cyr_withlock_run** |default-conf-text| + +Options +======= + +.. program:: cyr_withlock_run + +.. option:: -C config-file + + |cli-dash-c-text| + +.. option:: -u, --user + + Lock just the specified userid rather than the global lock. + + NOTE: if you run for the global lock, then your server must have the + `global_lock` config option set, or this command will fail with an + error as it can't get a guaranteed lock. + +Examples +======== + +.. parsed-literal:: + + **cyr_withlock_run** sleep 300 + +.. + + Pause all writes on the server for 300 seconds (stops any writes from getting locks) + +Files +===== + +/etc/imapd.conf + + +Environment +=========== + +Sets the `CYRUS_HAVELOCK_GLOBAL` or `CYRUS_HAVELOCK_USER` environment variables, +to tell any called cyrus command that this lock is already held. + +See Also +======== + +:cyrusman:`imapd.conf(5)` diff --git a/docsrc/imap/reference/manpages/systemcommands/cyrdump.rst b/docsrc/imap/reference/manpages/systemcommands/cyrdump.rst index 2e6ee0e570..53e62b07c7 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyrdump.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyrdump.rst @@ -30,7 +30,7 @@ Options Alternate config file. -.. option:: -v +.. option:: -v, --verbose Produce verbose output on errors. diff --git a/docsrc/imap/reference/manpages/systemcommands/deliver.rst b/docsrc/imap/reference/manpages/systemcommands/deliver.rst index faf307b1f0..7df5d01ad8 100644 --- a/docsrc/imap/reference/manpages/systemcommands/deliver.rst +++ b/docsrc/imap/reference/manpages/systemcommands/deliver.rst @@ -41,17 +41,17 @@ Options .. option:: -d - Ignored for compatability with **/bin/mail**. + Ignored for compatibility with **/bin/mail**. -.. option:: -r address +.. option:: -r address, --return-path=address Insert a **Return-Path:** header containing *address*. -.. option:: -f address +.. option:: -f address Insert a **Return-Path:** header containing *address*. -.. option:: -m mailbox +.. option:: -m mailbox, --mailbox=mailbox Deliver to **mailbox**. If any *userid*\ s are specified, attempts to deliver to ``user.``\ *userid*\ ``.mailbox`` for each *userid*\ . @@ -63,15 +63,15 @@ Options . If the ACL on *mailbox* does not grant the sender the "p" right, the delivery fails. -.. option:: -a auth-id +.. option:: -a auth-id, --auth-id=auth-id Specify the authorization id of the sender. Defaults to "anonymous". -.. option:: -q user-id +.. option:: -q, --ignore-quota Deliver message even when receiving mailbox is over quota. -.. option:: -l +.. option:: -l, --lmtp Accept messages using the LMTP protocol. diff --git a/docsrc/imap/reference/manpages/systemcommands/fetchnews.rst b/docsrc/imap/reference/manpages/systemcommands/fetchnews.rst index bec4efe8c8..1d7a847c7b 100644 --- a/docsrc/imap/reference/manpages/systemcommands/fetchnews.rst +++ b/docsrc/imap/reference/manpages/systemcommands/fetchnews.rst @@ -38,39 +38,39 @@ Options |cli-dash-c-text| -.. option:: -s servername +.. option:: -s servername, --server=servername Hostname of the Cyrus server (with optional port) to which articles should be fed. Defaults to "localhost:nntp". -.. option:: -n +.. option:: -n, --no-newnews Don't use the NEWNEWS command. **fetchnews** will keep track of the high and low water marks for each group and use them to fetch new articles. -.. option:: -y +.. option:: -y, --yyyy Use 4 instead of 2 digits for year. 2-digits are :rfc:`977` - but not y2k-compliant. -.. option:: -w wildmat +.. option:: -w wildmat, --groups=wildmat Wildmat pattern specifying which newsgroups to search for new articles. Defaults to "*". -.. option:: -f tstampfile +.. option:: -f tstampfile, --newsstamp-file=tstampfile File in which to read/write the timestamp of when articles were last retrieved. Defaults to ``/newsstamp`` as specified by the configuration options. -.. option:: -a authname +.. option:: -a authname, --auth-id=authname Userid to use for authentication. -.. option:: -p password +.. option:: -p password, --password=password Password to use for authentication. diff --git a/docsrc/imap/reference/manpages/systemcommands/fud.rst b/docsrc/imap/reference/manpages/systemcommands/fud.rst index f9258b7c5e..76b6239629 100644 --- a/docsrc/imap/reference/manpages/systemcommands/fud.rst +++ b/docsrc/imap/reference/manpages/systemcommands/fud.rst @@ -118,7 +118,7 @@ Also not really a bug, **fud** requires that the anonymous user has the 0 is not a standard IMAP ACL bit. **fud** is an experimental interface meant to provide information to -build a finger-like service around. Eventually it should be superceded +build a finger-like service around. Eventually it should be superseded by a more standards-based protocol. diff --git a/docsrc/imap/reference/manpages/systemcommands/httpd.rst b/docsrc/imap/reference/manpages/systemcommands/httpd.rst index 6440b0f001..51fa2703a8 100644 --- a/docsrc/imap/reference/manpages/systemcommands/httpd.rst +++ b/docsrc/imap/reference/manpages/systemcommands/httpd.rst @@ -16,7 +16,7 @@ Synopsis .. parsed-literal:: **httpd** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] - [ **-s** ] [ **-p** *ssf* ] [ **-q** ] + [ **-H** ] [ **-s** ] [ **-p** *ssf* ] [ **-q** ] Description =========== @@ -57,6 +57,10 @@ Options Run external debugger specified in debug_command. +.. option:: -H + + Tell **httpd** to expect a HAProxy protocol header from the sender. + .. option:: -s Serve HTTP over SSL (https). All data to and from **httpd** diff --git a/docsrc/imap/reference/manpages/systemcommands/ipurge.rst b/docsrc/imap/reference/manpages/systemcommands/ipurge.rst index 9260a79a74..3537a32a29 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ipurge.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ipurge.rst @@ -47,52 +47,52 @@ Options |cli-dash-c-text| -.. option:: -f +.. option:: -f, --include-user-mailboxes Force ipurge to examine mailboxes below INBOX.* and user.*. -.. option:: -d days +.. option:: -d days, --days=days Age of message in *days*. -.. option:: -b bytes +.. option:: -b bytes, --bytes=bytes Size of message in *bytes*. -.. option:: -k Kbytes +.. option:: -k Kbytes, --kbytes=Kbytes Size of message in *Kbytes* (2^10 bytes). -.. option:: -m Mbytes +.. option:: -m Mbytes, --mbytes=Mbytes Size of message in *Mbytes* (2^20 bytes). -.. option:: -x +.. option:: -x, --exact-match Perform an exact match on age or size (instead of older or larger). -.. option:: -X +.. option:: -X, --delivery-time Use delivery time instead of Date: header for date matches. -.. option:: -i +.. option:: -i, --invert-match Invert match logic: -x means not equal, date is for newer, size is for smaller. -.. option:: -s +.. option:: -s, --skip-flagged Skip over messages that have the \\Flagged flag set. -.. option:: -o +.. option:: -o, --only-deleted Only purge messages that have the \\Deleted flag set. -.. option:: -n +.. option:: -n, --dry-run Only print messages that would be deleted (dry run). -.. option:: -v +.. option:: -v, --verbose Enable verbose output/logging. diff --git a/docsrc/imap/reference/manpages/systemcommands/masssievec.rst b/docsrc/imap/reference/manpages/systemcommands/masssievec.rst index e4f6de0c70..968484f75a 100644 --- a/docsrc/imap/reference/manpages/systemcommands/masssievec.rst +++ b/docsrc/imap/reference/manpages/systemcommands/masssievec.rst @@ -30,7 +30,7 @@ Options .. option:: imapd.conf - Provide an alternate impad.conf. If not specified, uses ``/etc/imapd.conf``. + Provide an alternate imapd.conf. If not specified, uses ``/etc/imapd.conf``. See Also ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/master.rst b/docsrc/imap/reference/manpages/systemcommands/master.rst index 49c363cd92..e4ec447e66 100644 --- a/docsrc/imap/reference/manpages/systemcommands/master.rst +++ b/docsrc/imap/reference/manpages/systemcommands/master.rst @@ -16,16 +16,15 @@ Synopsis .. parsed-literal:: **master** [ **-C** *config-file* ] [ **-M** *alternate cyrus.conf* ] - [ **-l** *listen queue* ] [ **-p** *pidfile* ] [ **-P** *snmp agentx ping interval* ] + [ **-l** *listen queue* ] [ **-p** *pidfile* ] [ **-r** *ready_file* ] [ **-j** *janitor period* ] [ **-d** | **-D** ] [ **-L** *logfile* ] - [ **-x** *snmp agentx socket* ] Description =========== **master** is the process that controls all of the Cyrus processes. This process is responsible for creating all imapd, pop3d, -lmtpd and sieved child processes. This process also performs scheduled +lmtpd and timsieved child processes. This process also performs scheduled cleanup/maintenance. If this process dies, then no new sessions will be started. @@ -56,7 +55,7 @@ Options .. option:: -j janitor full-sweeps per second - Sets the amount of times per second the janitor should sweep the + Sets the number of times per second the janitor should sweep the entire child table. Leave it at the default of 1 unless you have a really high fork rate (and you have not increased the child hash table size when you compiled Cyrus from its default of 10000 @@ -65,13 +64,14 @@ Options .. option:: -p pidfile Use *pidfile* as the pidfile. If not specified, defaults to - ``/var/run/master.pid`` + ``master_pid_file`` from :cyrusman:`imapd.conf(5)`, which + defaults to ``{configdirectory}/master.pid`` -.. option:: -P snmp agentx ping interval +.. option:: -r ready_file - Sets the amount on time in seconds the subagent will try and - reconnect to the master agent (snmpd) if it ever becomes (or - starts) disconnected. Requires net-snmp 5.0 or higher. + Use *ready_file* as the ready file. If not specified, uses + ``master_ready_file`` from :cyrusman:`imapd.conf(5)`, which + defaults to ``{configdirectory}/master.ready`` .. option:: -d @@ -88,11 +88,6 @@ Options Redirect stdout and stderr to the given *logfile*. -.. option:: -x snmp agentx socket - - Address the master agent (most likely snmpd) listens on. - Requires net-snmp 5.0 or higher. - Configuration ============= @@ -125,6 +120,9 @@ The environment variable **CYRUS_VERBOSE** can be set to log additional debugging information. Setting the value to 1 results in base level logging. Setting it higher results in more log messages being generated. +The :cyrusman:`cyr_info(8)` utility's ``proc`` subcommand can be used to +list the active processes that **master** is managing. + Files ===== @@ -137,4 +135,4 @@ See Also :cyrusman:`cyrus.conf(5)`, :cyrusman:`imapd.conf(5)`, :cyrusman:`imapd(8)`, :cyrusman:`pop3d(8)`, :cyrusman:`lmtpd(8)`, :cyrusman:`timsieved(8)`, -:cyrusman:`idled(8)` +:cyrusman:`idled(8)`, :cyrusman:`cyr_info(8)` diff --git a/docsrc/imap/reference/manpages/systemcommands/mbexamine.rst b/docsrc/imap/reference/manpages/systemcommands/mbexamine.rst index 2ea2b0f940..7dc13ebf8a 100644 --- a/docsrc/imap/reference/manpages/systemcommands/mbexamine.rst +++ b/docsrc/imap/reference/manpages/systemcommands/mbexamine.rst @@ -40,21 +40,21 @@ Options |cli-dash-c-text| -.. option:: -u uid +.. option:: -u uid, --uid=uid Dump information for the given uid only. -.. option:: -s seqnum +.. option:: -s seqnum, --seq=seqnum Dump information for the given sequence number only. -.. option:: -q +.. option:: -q, --check-quota Compare the quota usage in cyrus.index to the actual message file sizes and report any differences. If there are differences, the mailbox SHOULD be reconstructed. -.. option:: -c +.. option:: -c, --check-message-files Compare the records in cyrus.index to the actual message files report any differences. This can help detect issues if messages diff --git a/docsrc/imap/reference/manpages/systemcommands/mbpath.rst b/docsrc/imap/reference/manpages/systemcommands/mbpath.rst index 38ec13314a..9bebcd2f1c 100644 --- a/docsrc/imap/reference/manpages/systemcommands/mbpath.rst +++ b/docsrc/imap/reference/manpages/systemcommands/mbpath.rst @@ -15,17 +15,18 @@ Synopsis .. parsed-literal:: - **mbpath** [ **-C** *config-file* ] [ **-q** ] [ **-s** ] [ **-m** ] [ *mailbox-names*... ] + **mbpath** [ **-C** *config-file* ] [ **-l** ] [ **-m** ] [ **-q** ] [ **-s** ] [ **-u** | **-p** ] [ **-a** | **-A** | **-M** | **-S** | **-U** ] [ *mailbox-names*... ] Description =========== Given a mailbox name or a space separated list of mailbox names, -**mbpath** outputs the filesystem path to the mailbox. - +**mbpath** outputs the filesystem path(s) of the mailbox. By default, +the mailboxes' data partition paths are shown (same as **-D**). +See `Selectors`_ for selecting which filesystem path(s) to output. **mbpath** |default-conf-text| It uses /mailboxes.db -to locate the mailbox on disk. +to locate the mailboxes on disk. Options ======= @@ -36,18 +37,57 @@ Options |cli-dash-c-text| -.. option:: -q +.. option:: -l, --local-only + + Local mailboxes only (exits with error for remote or nonexistent mailboxes) + +.. option:: -m + + Output the path to the metadata files (if different from the + message files). Legacy, use **-M**. + +.. option:: -q, --quiet Suppress any error output. -.. option:: -s +.. option:: -s, --stop If any error occurs, stop processing the list of mailboxes and exit. -.. option:: -m +.. option:: -u, --userids - Output the path to the metadata files (if different from the - message files). + The specified *mailbox-names* are userids, not mailboxes. + +.. option:: -p, --paths + + The specified *mailbox-names* are UNIX mailbox paths, not mailboxes. + +Selectors +========= + +.. option:: -A, --archive + + Show the mailbox archive path + +.. option:: -D, --data + + Show the mailbox data path (*default*) + +.. option:: -M, --metadata + + Show the mailbox metadata path (same as **-m**) + +.. option:: -S, --sieve + + Show the user sieve scripts path + +.. option:: -U, --user-files + + Show the user files path (seen, sub, etc) + +.. option:: -a, --all + + Show all paths, as if all selectors were specified Examples ======== @@ -58,7 +98,7 @@ Examples .. - Display the path for mailbox *user.jsmith*. + Display the data path for mailbox *user.jsmith*. .. only:: html @@ -68,7 +108,7 @@ Examples .. parsed-literal:: - **mbpath -m** *user.jsmith* + **mbpath -M** *user.jsmith* .. @@ -80,6 +120,20 @@ Examples /var/spool/meta/imap/user/jsmith +.. parsed-literal:: + + **mbpath -u -S** *jsmith* + +.. + + Display the sieve scripts path for user *jsmith*. + +.. only:: html + + :: + + /var/spool/sieve/j/jsmith + Files ===== diff --git a/docsrc/imap/reference/manpages/systemcommands/mbtool.rst b/docsrc/imap/reference/manpages/systemcommands/mbtool.rst index 0cfb8d6879..02d6bd0e95 100644 --- a/docsrc/imap/reference/manpages/systemcommands/mbtool.rst +++ b/docsrc/imap/reference/manpages/systemcommands/mbtool.rst @@ -41,16 +41,17 @@ Options |cli-dash-c-text| -.. option:: -t +.. option:: -t, --normalize-internaldate Normalize ``internaldate`` on all index records of all listed *mailbox*\ es to match the *Date:* header if they're off by more than a day, which can be used to fix up a mailbox which has been restored from backup and lost its internaldate information. -.. option:: -r +.. option:: -r, --new-uniqueid - Create a new unique ID for all listed *mailbox*\ es. + Create a new unique ID for all listed *mailbox*\ es. Only works + for legacy mailboxes. Examples ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/mkimap.rst b/docsrc/imap/reference/manpages/systemcommands/mkimap.rst index ccb6031ebb..a2ffea76b9 100644 --- a/docsrc/imap/reference/manpages/systemcommands/mkimap.rst +++ b/docsrc/imap/reference/manpages/systemcommands/mkimap.rst @@ -66,9 +66,9 @@ Examples :: reading configure file /etc/imapd.conf... - i will configure directory /var/lib/imap. - i saw partition /var/spool/cyrus/mail. - i saw partition /var/spool/cyrus/news. + I will configure directory /var/lib/imap. + I saw partition /var/spool/cyrus/mail. + I saw partition /var/spool/cyrus/news. done configuring /var/lib/imap... creating /var/spool/cyrus/mail... diff --git a/docsrc/imap/reference/manpages/systemcommands/nntpd.rst b/docsrc/imap/reference/manpages/systemcommands/nntpd.rst index aa35af696d..d05bfb8ff9 100644 --- a/docsrc/imap/reference/manpages/systemcommands/nntpd.rst +++ b/docsrc/imap/reference/manpages/systemcommands/nntpd.rst @@ -16,7 +16,7 @@ Synopsis .. parsed-literal:: **nntpd** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] - [ **-s** ] [ **-r** ] [ **-f** ] [ **-p** *ssf* ] + [ **-H** ] [ **-s** ] [ **-r** ] [ **-f** ] [ **-p** *ssf* ] Description =========== @@ -68,6 +68,10 @@ Options Run external debugger specified in debug_command. +.. option:: -H + + Tell **nntpd** to expect a HAProxy protocol header from the sender. + .. option:: -s Serve NNTP over SSL (https). All data to and from **nntpd** diff --git a/docsrc/imap/reference/manpages/systemcommands/pop3d.rst b/docsrc/imap/reference/manpages/systemcommands/pop3d.rst index b6490771cb..528438bcaa 100644 --- a/docsrc/imap/reference/manpages/systemcommands/pop3d.rst +++ b/docsrc/imap/reference/manpages/systemcommands/pop3d.rst @@ -10,95 +10,4 @@ POP3 server process -Synopsis -======== - -.. parsed-literal:: - - **pop3d** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] - [ **-s** ] [ **-k** ] [ **-p** *ssf* ] - -Description -=========== - -**pop3d** is an POP3 server. It accepts commands on its standard -input and responds on its standard output. It MUST be invoked by -:cyrusman:`master(8)` with those descriptors attached to a remote client -connection. - -**pop3d** |default-conf-text| - -If the directory ``log``\/*user* exists under the directory specified in -the ``configdirectory`` configuration option, then **pop3d** will create -protocol telemetry logs for sessions authenticating as *user*. - -The telemetry logs will be stored in the ``log``/\ *user* directory with -a filename of the **pop3d** process-id. - -Options -======= - -.. program:: pop3d - -.. option:: -C config-file - - |cli-dash-c-text| - -.. option:: -U uses - - The maximum number of times that the process should be used for new - connections before shutting down. The default is 250. - -.. option:: -T timeout - - The number of seconds that the process will wait for a new - connection before shutting down. Note that a value of 0 (zero) - will disable the timeout. The default is 60. - -.. option:: -D - - Run external debugger specified in debug_command. - -.. option:: -s - - Serve POP3 over SSL (pop3s). All data to and from **pop3d** is - encrypted using the Secure Sockets Layer. - -.. option:: -k - - Serve MIT's KPOP (Kerberized POP) protocol instead. - -.. option:: -p ssf - - Tell **pop3d** that an external layer exists. An *SSF* (security - strength factor) of 1 means an integrity protection layer exists. - Any higher SSF implies some form of privacy protection. - -Examples -======== - -**pop3d** is commonly included in the SERVICES section of -:cyrusman:`cyrus.conf(5)` like so: - -.. parsed-literal:: - SERVICES { - imap cmd="imapd -U 30" listen="imap" prefork=0 - imaps cmd="imapd -s -U 30" listen="imaps" prefork=0 maxchild=100 - **pop3 cmd="pop3d -U 30" listen="pop3" prefork=0** - **pop3s cmd="pop3d -s -U 30" listen="pop3s" prefork=0 maxchild=100** - lmtpunix cmd="lmtpd" listen="/var/run/cyrus/socket/lmtp" prefork=0 maxchild=20 - sieve cmd="timsieved" listen="sieve" prefork=0 - notify cmd="notifyd" listen="/var/run/cyrus/socket/notify" proto="udp" prefork=1 - httpd cmd="httpd" listen=8080 prefork=1 maxchild=20 - } - -Files -===== - -/etc/imapd.conf - -See Also -======== - -:cyrusman:`imapd.conf(5)`, -:cyrusman:`master(8)` +.. include:: /assets/man-pop3d.rst diff --git a/docsrc/imap/reference/manpages/systemcommands/pop3proxyd.rst b/docsrc/imap/reference/manpages/systemcommands/pop3proxyd.rst index 39e3c07e71..8723957e7d 100644 --- a/docsrc/imap/reference/manpages/systemcommands/pop3proxyd.rst +++ b/docsrc/imap/reference/manpages/systemcommands/pop3proxyd.rst @@ -6,7 +6,7 @@ **pop3proxyd** ============== +This is a hard linked copy of :cyrusman:`pop3d(8)`, retained for backwards +compatibility from when a murder frontend used its own pop3d binaries. -This is a hard linked copy of :cyrusman:`pop3d(8)`, retained for backwards compatibility from when a murder frontend used its own pop3d binaries. - -.. include:: /assets/man-lmtpd.rst +.. include:: /assets/man-pop3d.rst diff --git a/docsrc/imap/reference/manpages/systemcommands/promstatsd.rst b/docsrc/imap/reference/manpages/systemcommands/promstatsd.rst new file mode 100644 index 0000000000..1fcd0c5e16 --- /dev/null +++ b/docsrc/imap/reference/manpages/systemcommands/promstatsd.rst @@ -0,0 +1,129 @@ +.. cyrusman:: promstatsd(8) + +.. _imap-reference-manpages-systemcommands-promstatsd: + +============== +**promstatsd** +============== + +Cyrus Prometheus statistics collating daemon + +Synopsis +======== + +.. parsed-literal:: + + **promstatsd** [ **-C** *config-file* ] [ **-v** ] [ **-f** *service-frequency* ] [ **-d** ] + + **promstatsd** [ **-C** *config-file* ] [ **-v** ] **-c** + + **promstatsd** [ **-C** *config-file* ] [ **-v** ] **-1** + +Description +=========== + +**promstatsd** is the Cyrus Prometheus statistics collating daemon. + +When the **prometheus_enabled** :cyrusman:`imapd.conf(5)` setting is true, +various Cyrus service processes will count statistics as they run. +**promstatsd** collates these statistics into a text-based report that +Prometheus can ingest. + +The report produced by **promstatsd** is served by :cyrusman:`httpd(8)` at +the "/metrics" URL, if "prometheus" has been set in **httpmodules** in +:cyrusman:`imapd.conf(5)`. + +**promstatsd** |default-conf-text| + +In the first synopsis, **promstatsd** will run as a daemon, updating the +service and (optionally) usage reports at the frequencies set by the +**prometheus_service_update_freq** and **prometheus_usage_update_freq** +:cyrusman:`imapd.conf(5)` options, which default to 10s and disabled, +respectively. The optional **-f** *service-frequency* argument can be used to +override **prometheus_service_update_freq**. This invocation should be run +from the DAEMON section of :cyrusman:`cyrus.conf(5)` (see +:ref:`promstatsd-examples` below). + +In the second synopsis, **promstatsd** will clean up all statistics files and +exit. The statistics Cyrus maintains are only valid while Cyrus is running, +so this invocation must be run from the START section of +:cyrusman:`cyrus.conf(5)` (see :ref:`promstatsd-examples` below) to clean up +after the previous run, before new service processes are started. + +In the third synopsis, **promstatsd** will immediately update the report(s) +once, and then exit. This can be safely used while another **promstatsd** +process runs in daemon form. It is useful if you need to update the report +*now* for some reason, rather than waiting for the daemon's next update. + +Options +======= + +.. program:: promstatsd + +.. option:: -C config-file + + |cli-dash-c-text| + +.. option:: -D + + Run the external debugger specified in the **debug_command** + :cyrusman:`imapd.conf(5)` option. + +.. option:: -1 + + Update the report(s) once and exit. + +.. option:: -c + + Clean up the stats directory and exit. + +.. option:: -d + + Debug mode -- **promstatsd** will not background itself, for aid in + debugging. + +.. option:: -f service-frequency + + Update the service report every *service-frequency* seconds. If not + specified, the **prometheus_service_update_freq** from + :cyrusman:`imapd.conf(5)` will be used, which defaults to 10 seconds. + +.. option:: -v + + Increase verbosity. Can be specified multiple times. + +.. _promstatsd-examples: + +Examples +======== + +To regularly produce a report that Prometheus can consume, **promstatsd** must +be run from the DAEMON section of :cyrusman:`cyrus.conf(5)` as per the first +synopsis, like so: + +.. parsed-literal:: + DAEMON { + **promstatsd cmd="promstatsd"** + } + +To ensure a clean statistical state at startup, **promstatsd** must be run +from the START section of :cyrusman:`cyrus.conf(5)` as per the second synopsis, +like so: + +.. parsed-literal:: + START { + **statscleanup cmd="promstatsd -c"** + } + +History +======= + +Files +===== + +See Also +======== + +:cyrusman:`imapd.conf(5)`, +:cyrusman:`cyrus.conf(5)`, +:cyrusman:`httpd(8)`, diff --git a/docsrc/imap/reference/manpages/systemcommands/ptdump.rst b/docsrc/imap/reference/manpages/systemcommands/ptdump.rst index 0c759d80f7..dda12b0de3 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ptdump.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ptdump.rst @@ -8,7 +8,7 @@ **ptdump** ========== -Program to to dump the current PTS (protection database authorization) +Program to dump the current PTS (protection database authorization) cache. Synopsis diff --git a/docsrc/imap/reference/manpages/systemcommands/ptexpire.rst b/docsrc/imap/reference/manpages/systemcommands/ptexpire.rst index c25e05d33c..b088e6f1c2 100644 --- a/docsrc/imap/reference/manpages/systemcommands/ptexpire.rst +++ b/docsrc/imap/reference/manpages/systemcommands/ptexpire.rst @@ -15,14 +15,17 @@ Synopsis .. parsed-literal:: - **ptexpire** [**-C** *filename*] [**-E** *time*] + **ptexpire** [**-C** *filename*] [**-E** *seconds*] [ *username* ...] Description =========== -The **ptexpire** program sweeps the ``ptscache_db`` database, expiring -entries older than the time specified on the command line (default 3 -hours). +The **ptexpire** program sweeps the ``ptscache_db`` database, deleting +entries older than the expiry duration, which defaults to 5400 seconds +(3 hours). The expiry duration can be changed with the **-E** option. + +Alternatively, if it's passed a list of usernames it deletes just those +usernames, immediately. **ptexpire** |default-conf-text| @@ -35,10 +38,9 @@ Options |cli-dash-c-text| -.. option:: -E time +.. option:: -E seconds, --expire-duration=seconds - Expire entries older than this time. - Default: 3 hours + Set the expiry duration to *seconds*. Files ===== diff --git a/docsrc/imap/reference/manpages/systemcommands/quota.rst b/docsrc/imap/reference/manpages/systemcommands/quota.rst index df5c64c2df..dbc5084780 100644 --- a/docsrc/imap/reference/manpages/systemcommands/quota.rst +++ b/docsrc/imap/reference/manpages/systemcommands/quota.rst @@ -55,25 +55,30 @@ Options |cli-dash-c-text| -.. option:: -d domain +.. option:: -d domain, --domain domain List and/or fix quota only in *domain*. -.. option:: -f +.. option:: -f, --fix - Fix any inconsistencies in the quota subsystem before generating a - report. + Detect and fix any inconsistencies in the quota subsystem before generating + a report. -.. option:: -q +.. option:: -n, --report-only + + Check for any inconsistencies in the quota subsystem but don't actually + fix them. Use with **-f** and **-q** to only see what's incorrect. + +.. option:: -q, --quiet Operate quietly. If **-f** is specified, then don't print the quota values, only print messages when things are changed. -.. option:: -J +.. option:: -J, --json Output the quota values as JSON for automated tooling support -.. option:: -u +.. option:: -u, --userids Interpret *mailbox-spec* arguments as userids. The default is to interpret them as mailbox prefixes diff --git a/docsrc/imap/reference/manpages/systemcommands/reconstruct.rst b/docsrc/imap/reference/manpages/systemcommands/reconstruct.rst index 6f52d51c31..11c74213df 100644 --- a/docsrc/imap/reference/manpages/systemcommands/reconstruct.rst +++ b/docsrc/imap/reference/manpages/systemcommands/reconstruct.rst @@ -17,23 +17,24 @@ Synopsis **reconstruct** [ **-C** *config-file* ] [ **-p** *partition* ] [ **-x** ] [ **-r** ] [ **-f** ] [ **-U** ] [ **-s** ] [ **-q** ] [ **-G** ] [ **-R** ] [ **-o** ] - [ **-O** ] [ **-M** ] [ **-V** *version* ] *mailbox*... + [ **-O** ] [ **-M** ] *mailbox*... **reconstruct** [ **-C** *config-file* ] [ **-p** *partition* ] [ **-x** ] [ **-r** ] [ **-f** ] [ **-U** ] [ **-s** ] [ **-q** ] [ **-G** ] [ **-R** ] [ **-o** ] - [ **-O** ] [ **-M** ] [ **-u** ] *users*... + [ **-O** ] [ **-M** ] **-u** *user*... - **reconstruct** [ **-C** *config-file* ] [ **-p** *partition* ] [ **-x** ] [ **-r** ] - [ **-f** ] [ **-U** ] [ **-s** ] [ **-q** ] [ **-G** ] [ **-R** ] [ **-o** ] - [ **-O** ] [ **-M** ] **-V** ** [ **-u** *users* ] + **reconstruct** [ **-C** *config-file* ] [ **-p** *partition* ] [ **-r** ] + [ **-q** ] **-V** *version* *mailbox*... - **reconstruct** [ **-C** *config-file* ] **-m** + **reconstruct** [ **-C** *config-file* ] [ **-p** *partition* ] [ **-r** ] + [ **-q** ] **-V** *version* **-u** *user*... + + **reconstruct** [ **-C** *config-file* ] **-P** *cyrus-header-paths*... Description =========== -**reconstruct** rebuilds one or more IMAP mailboxes. When invoked with -the **-m** switch, it rebuilds the master mailboxes file. It can be +**reconstruct** rebuilds one or more IMAP mailboxes. It can be used to recover from almost any sort of data corruption. If **reconstruct** can find existing header and index files, it @@ -54,24 +55,27 @@ root files. When upgrading versions of Cyrus software, it may be necessary to run **reconstruct** with the **-V** option, to rebuild indexes to a -given version, (or *max* for the most recent). +given version (or *max* for the most recent). Note that the **-V** +option cannot be combined with most other reconstruct options. If +a mailbox needs reconstructing you should do that first, and then +upgrade it with **-V** once it's good. Options ======= .. program:: reconstruct -.. option:: -C config-file +.. option:: -C config-file |cli-dash-c-text| -.. option:: -p partition +.. option:: -p partition, --partition=partition Search for the listed (non-existant) mailboxes on the indicated *partition*. Create the mailboxes in the database in addition to reconstructing them. (not compatible with the use of wildcards) -.. option:: -x +.. option:: -x, --ignore-disk-metadata When processing a mailbox which is not in the mailbox list (e.g. via the **-p** or **-f** options), do not import the metadata from @@ -79,53 +83,52 @@ Options least the mailbox's seen state unique identifier, user flags, and ACL). -.. option:: -r +.. option:: -r, --recursive Recursively reconstruct all sub-mailboxes of the mailboxes or mailbox prefixes given as arguments. -.. option:: -f +.. option:: -f, --scan-filesystem Examine the filesystem underneath mailbox, adding all directories with a ``cyrus.header`` found there as new mailboxes. Useful for restoring mailboxes from backups. -.. option:: -s +.. option:: -s, --no-stat Don't stat underlying files. This makes reconstruct run faster, at the expense of not noticing some issues (like zero byte files or size mismatches). "**reconstruct -s**" should be quite fast. -.. option:: -q +.. option:: -q, --quiet Emit less verbose information to syslog. -.. option:: -n +.. option:: -n, --dry-run - Don't make any changes. This gives equivalent behaviour to - :cyrusman:`chk_cyrus(8)` where problems are reported, but not fixed. + Don't make any changes. Problems are reported, but not fixed. -.. option:: -G +.. option:: -G, --force-reparse Force re-parsing of the underlying message (checks GUID correctness). Reconstruct with -G should fix all possible individual message issues, including corrupted data files. -.. option:: -I +.. option:: -I, --update-uniqueids If two mailboxes exist with the same UNIQUEID and reconstruct visits both of them, -I will cause the second mailbox to have a new UNIQUEID created for it. If you don't specify -I, you will just get a syslog entry telling you of the clash. -.. option:: -R +.. option:: -R, --guid-mismatch-keep Perform a UID upgrade operation on GUID mismatch files. Use this option if you think your index is corrupted rather than your message files, or if all backup attempts have failed and you're happy to be served the missing files. -.. option:: -U +.. option:: -U, --guid-mismatch-discard Use this option if you have corrupt message files in your spool and have been unable to restore them from backup. This will make the @@ -135,40 +138,40 @@ Options this deletes corrupt message files for ever - so make sure you've exhausted other options first! -.. option:: -o +.. option:: -o, --ignore-odd-files - Ignore odd files in your mailbox disk directories. Probably useful - if you are using some tool which adds additional tracking files. + Ignore odd files in your mailbox disk directories, instead of + complaining about them. Probably useful if you are using some + tool which adds additional tracking files. -.. option:: -O +.. option:: -O, --delete-odd-files Delete odd files. This is the opposite of **-o**. -.. option:: -M +.. option:: -M, --prefer-mboxlist Prefer mailboxes.db over cyrus.header - will rewrite ACL or uniqueid from the mailboxes.db into the header file rather than the other way around. |v3-new-feature| -.. option:: -V version +.. option:: -V version, --set-version=version Change the ``cyrus.index`` minor version to a specific *version*. This can be useful for upgrades or downgrades. Use a magical version of *max* to upgrade to the latest available database format version. -.. option:: -u - - Instead of mailbox prefixes, give usernames on the command line +.. option:: -u, --userids -.. option:: -m + Instead of mailbox prefixes, give userids on the command line - NOTE: - CURRENTLY UNAVAILABLE +.. option:: -P, --header-paths - Rebuild the *mailboxes* file. Use whatever data in the existing - *mailboxes* file it can scavenge, then scans all partitions listed - in the :cyrusman:`imapd.conf(5)` file for additional mailboxes. + Instead of mailbox prefixes, give paths to cyrus.header files on + the command line. The paths can be mailbox directories, or + explicit cyrus.header filenames. + This will ONLY create/repair mailboxes.db records using data in + cyrus.header and cyrus.index. Examples ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/rehash.rst b/docsrc/imap/reference/manpages/systemcommands/rehash.rst index d46a8a01ed..fa1e506690 100644 --- a/docsrc/imap/reference/manpages/systemcommands/rehash.rst +++ b/docsrc/imap/reference/manpages/systemcommands/rehash.rst @@ -17,7 +17,7 @@ Synopsis .. parsed-literal:: - **rehash** [**-v**] [**-n**] [**-f**|**-F**] [**-i**|**-I**] imapd.conf + **rehash** [**-v**] [**-n**] [**-f**\|\ **-F**] [**-i**\|\ **-I**] imapd.conf Description =========== @@ -50,12 +50,12 @@ Options In -n "no change" mode, it will just print the changes. Note, verbose is always turned on in no-change mode. -.. option:: -f | -F +.. option:: -f \| -F | -f forces fulldirhash: no | -F forces fulldirhash: yes -.. option:: -i +.. option:: -i \| -I | -i forces hashimapspool: no | -I forces hashimapspool: yes diff --git a/docsrc/imap/reference/manpages/systemcommands/relocate_by_id.rst b/docsrc/imap/reference/manpages/systemcommands/relocate_by_id.rst new file mode 100644 index 0000000000..325aaa22e5 --- /dev/null +++ b/docsrc/imap/reference/manpages/systemcommands/relocate_by_id.rst @@ -0,0 +1,72 @@ +.. cyrusman:: relocate_by_id(8) + +.. author: Ken Murchison (Fastmail) + +.. _imap-reference-manpages-systemcommands-relocate_by_id: + +================== +**relocate_by_id** +================== + +Relocate mailbox trees by their mailbox ids + +Synopsis +======== + +.. parsed-literal:: + + **relocate_by_id** [ **-C** *config-file* ] [ **-n** ] [ **-q** ] [ **-u** ] [ *mailbox-names*... ] + +Description +=========== + +Given a mailbox name or a space separated list of mailbox names, +**relocate_by_id** relocates the mailbox and its submailboxes to +directory trees hashed by mailbox id rather than hashed by mailbox name. + +**relocate_by_id** |default-conf-text| It uses /mailboxes.db +to locate the mailboxes on disk. + +Options +======= + +.. program:: relocate_by_id + +.. option:: -C config-file + + |cli-dash-c-text| + +.. option:: -n, --dry-run + + Do NOT make any changes. Just list which directories will be relocated. + +.. option:: -q, --quiet + + Run quietly. Suppress any error output. + +.. option:: -u, --userids + + The specified *mailbox-names* are users, not mailboxes. + User metadata directories will also be relocated. + +Examples +======== + +.. parsed-literal:: + + **relocate_by_id -u** *jsmith* + +.. + + Relocate all mailbox and metadata directories for user *jsmith*. + +Files +===== + +/etc/imapd.conf, +/mailboxes.db + +See Also +======== + +:cyrusman:`imapd.conf(5)` diff --git a/docsrc/imap/reference/manpages/systemcommands/restore.rst b/docsrc/imap/reference/manpages/systemcommands/restore.rst index 47d2340ecc..f0eb839283 100644 --- a/docsrc/imap/reference/manpages/systemcommands/restore.rst +++ b/docsrc/imap/reference/manpages/systemcommands/restore.rst @@ -20,6 +20,9 @@ Synopsis Description =========== +.. note:: + Cyrus Backups are experimental, incomplete, and deprecated as of 3.10. + **restore** is a tool for restoring messages and mailboxes from a Cyrus backup to a Cyrus IMAP server. It must be run from the server containing the backup storage. @@ -67,7 +70,7 @@ restoring into mailboxes that already exists) will not have their ACLs altered. Options ======= -.. option:: -A [acl] +.. option:: -A [acl], --override-acl[=acl] Apply specified *acl* to restored mailboxes, rather than their ACLs as stored in the backup. @@ -81,7 +84,7 @@ Options |cli-dash-c-text| -.. option:: -D +.. option:: -D, --keep-deletedprefix Don't trim **deletedprefix** from mailbox names prior to restoring. This is mainly useful for rebuilding failed servers, where deleted mailboxes @@ -96,7 +99,7 @@ Options See :cyrusman:`imapd.conf(5)` for information about the **deletedprefix** and **delete_mode** configuration options. -.. option:: -F input-file +.. option:: -F input-file, --input-file=input-file Get the list of mailboxes or messages from *input-file* instead of from the command line arguments. @@ -105,7 +108,7 @@ Options a *uniqueid*, or a *guid*) per line. Empty lines, and lines beginning with a '#' character, are ignored. -.. option:: -L +.. option:: -L, --local-only Local operations only. Actions required to restore the requested mailboxes and messages will be performed on the destination server only. @@ -116,7 +119,7 @@ Options This option has no effect if the destination server is not part of a murder. -.. option:: -M mboxname +.. option:: -M mboxname, --dest-mailbox=mboxname Messages are restored to the mailbox with the specified *mboxname*. If no mailbox of this name exists, one will be created. @@ -132,11 +135,11 @@ Options The default when restoring individual messages is to restore them into their original mailboxes. -.. option:: -P partition +.. option:: -P partition, --dest-partition=partition Restore mailboxes to the specified *partition* -.. option:: -U +.. option:: -U, --keep-uidvalidity Try to preserve uidvalidity and other related fields, such that the restored mailboxes and messages appear like they never left, and IMAP @@ -150,17 +153,17 @@ Options appended as if newly delivered, regardless of whether the **-U** option was specified. -.. option:: -X +.. option:: -X, --skip-expunged Do not restore messages that are marked as expunged in the *backup*. See also **-x**. -.. option:: -a +.. option:: -a, --all-mailboxes Try to restore all mailboxes in the specified *backup*. -.. option:: -n +.. option:: -n, --dry-run Do nothing. The work required to perform the restoration will be calculated (and reported depending on verbosity level), but no @@ -169,23 +172,23 @@ Options Note that the *server* argument is still mandatory with this option. -.. option:: -r +.. option:: -r, --recursive Recurse into submailboxes. When restoring mailboxes, also restore any mailboxes contained within them. The default is to restore only explicitly-specified mailboxes. -.. option:: -v +.. option:: -v, --verbose Increase the verbosity level. This option can be specified multiple times for additional verbosity. -.. option:: -w seconds +.. option:: -w seconds, --delayed-startup=seconds Wait *seconds* before starting. This is useful for attaching a debugger. -.. option:: -x +.. option:: -x, --only-expunged Only restore messages that are marked as expunged in the *backup*. @@ -195,7 +198,7 @@ Options See also **-X**. -.. option:: -z +.. option:: -z, --require-compression Require compression for server connection. The restore will abort if compression is unavailable. @@ -205,12 +208,12 @@ Options Modes ===== -.. option:: -f +.. option:: -f backup, --file=backup *backup* is interpreted as a filename. The named file does not need to be known about in the backups database. -.. option:: -m +.. option:: -m backup, --mailbox=backup *backup* is interpreted as a mailbox name. There must be a known backup for the user whose mailbox this is. @@ -218,7 +221,7 @@ Modes Known backups are recorded in the database specified by the **backup_db** and **backup_db_path** configuration options. -.. option:: -u +.. option:: -u backup, --userid=backup *backup* is interpreted as a userid. There must be a known backup for the specified user. diff --git a/docsrc/imap/reference/manpages/systemcommands/rmnews.rst b/docsrc/imap/reference/manpages/systemcommands/rmnews.rst deleted file mode 100644 index 9a3f39976b..0000000000 --- a/docsrc/imap/reference/manpages/systemcommands/rmnews.rst +++ /dev/null @@ -1,60 +0,0 @@ -.. cyrusman:: rmnews(8) - -.. author: Nic Bernstein (Onlight) - -.. _imap-reference-manpages-systemcommands-rmnews: - -========== -**rmnews** -========== - -Expunge and remove news articles - -Synopsis -======== - -.. parsed-literal:: - - **rmnews** [ **-C** *config-file* ] - -Description -=========== - -**rmnews** reads article data from the standard input. It then expunges -and removes the listed articles. **rmnews** is designed to be used by -InterNetNews to remove canceled, superseded, and expired news articles. - -The input is processed as an INN :manpage:`expirerm(8)` file listing or -an INN cancel stream written as a \`\`WC'' entry in the -:manpage:`newsfeeds(5)` file. This data consists of lines of text, each -containing a list of relative article pathnames, with a single space -between entries. If a listed file is contained in an IMAP news -mailbox, it is expunged out of that mailbox. In any case, each listed -file is unlinked. - -**rmnews** |default-conf-text| The optional ``newsprefix`` option -specifies a prefix to be prepended to newsgroup names to make the -corresponding IMAP mailbox names. The required ``partition-news`` -option specifies the pathname prefix to the IMAP news mailboxes. The -value of ``partition-news`` concatenated with the -dots-to-slashes-converted value of ``newsprefix`` must be the pathname -of the news spool directory. - -Options -======= - -.. program:: rmnews - -.. option:: -C config-file - - |cli-dash-c-text| - -Files -===== - -/etc/imapd.conf - -See Also -======== - -:cyrusman:`imapd.conf(5)` diff --git a/docsrc/imap/reference/manpages/systemcommands/sieved.rst b/docsrc/imap/reference/manpages/systemcommands/sieved.rst index 3c87e31761..4c630df204 100644 --- a/docsrc/imap/reference/manpages/systemcommands/sieved.rst +++ b/docsrc/imap/reference/manpages/systemcommands/sieved.rst @@ -6,26 +6,34 @@ **sieved** ========== -Script to decompile a sieve script back from bytecode. +Tool to decompile a sieve script back from bytecode. Synopsis ======== .. parsed-literal:: - **sieved** *scriptfile* + **sieved** [OPTIONS] *bytcodefile* Description =========== -**sieved** decompiles the given script at *scriptfile* from bytecode, writing output to stdout. +**sieved** decompiles the given *bytecodefile*, writing output to stdout. +By default, the output is a descriptive version of the bytecode. With the +**-s** option, an equivalent sieve script is produced instead. Options ======= .. program:: sieved +.. option:: -s, --as-sieve + + Produce a sieve script rather than describing the bytecode. + Note that if the bytecode contains deprecated features, + the resulting script will not be compilable without changes. + See Also ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/smmapd.rst b/docsrc/imap/reference/manpages/systemcommands/smmapd.rst index b03df77f25..20b432b194 100644 --- a/docsrc/imap/reference/manpages/systemcommands/smmapd.rst +++ b/docsrc/imap/reference/manpages/systemcommands/smmapd.rst @@ -8,23 +8,38 @@ **smmapd** ========== -Sendmail socket map daemon +Sendmail and Postfix socket map daemon Synopsis ======== .. parsed-literal:: - **smmapd** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] + **smmapd** [ **-C** *config-file* ] [ **-U** *uses* ] [ **-T** *timeout* ] [ **-D** ] [**-p**] Description =========== -**smmapd** is a Sendmail socket map daemon which is used to verify that -a Cyrus mailbox exists, that it is postable and it is under quota. It +**smmapd** is a Sendmail and Postfix socket map daemon which is used to verify +that a Cyrus mailbox exists, that it is postable, it is not blocked for the +smmapd service in the userdeny database, and it is under quota. It accepts commands on its standard input and responds on its standard output. It MUST be invoked by :cyrusman:`master(8)` with those -descriptors attached to a remote client connection. +descriptors attached to a remote client connection. The received queries +contain map name followed by mailbox, **smmapd** ignores the map name. +Queries with plus addressing, when *-p* is not passed, return *OK* when +the user has a mailbox with the name after plus, otherwise the result +is *NOTFOUND*. Match for the mailbox after plus is performed +case-sensitive, for the address before the plus - depends on +`lmtp_downcase_rcpt`. + +The use case is to verify in Sendmail or Postfix if the destination exists, +before accepting an email. Then, if `autocreate_sieve_folders` is set, but +the folder does not exist yet, **smmapd** will return *NOTFOUND*, unless *-p* +is passed. Another use case is to do something in a Sieve script with emails, +based on plus addressing, without delivering them in the correspondent sub-folder. +To accept such emails, when the folder with the same name does not exist, *-p* must +be passed. **smmapd** |default-conf-text| @@ -52,6 +67,11 @@ Options Run external debugger specified in debug_command. +.. option:: -p + + Skip plus addressing: everything from `+` until `@`. When looking up the userdeny + database, plus addressing is always skipped, irrespective of this option. + Examples ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/squatter.rst b/docsrc/imap/reference/manpages/systemcommands/squatter.rst index ace2119f9f..fdd1ac4dc5 100644 --- a/docsrc/imap/reference/manpages/systemcommands/squatter.rst +++ b/docsrc/imap/reference/manpages/systemcommands/squatter.rst @@ -8,7 +8,7 @@ **squatter** ============ -Create SQUAT indexes for mailboxes +Create SQUAT and Xapian indexes for mailboxes Synopsis ======== @@ -19,12 +19,12 @@ Synopsis **squatter** [ **-C** *config-file* ] [**mode**] [**options**] [**source**] i.e.: - **squatter** [ **-C** *config-file* ] [**-v**] [**-S** *seconds*] [ **-Z** ] - **squatter** [ **-C** *config-file* ] [ **-a** ] [ **-i** ] [**-N** *name*] [**-S** *seconds*] [ **-r** ] [ **-Z** ] *mailbox*... - **squatter** [ **-C** *config-file* ] [ **-a** ] [ **-i** ] [**-N** *name*] [**-S** *seconds*] [ **-r** ] [ **-Z** ] **-u** *user*... - **squatter** [ **-C** *config-file* ] **-R** [ **-n** *channel* ] [ **-d** ] [**-S** *seconds*] [ **-Z** ] - **squatter** [ **-C** *config-file* ] **-f** *synclogfile* [**-S** *seconds*] [ **-Z** ] - **squatter** [ **-C** *config-file* ] **-t** *srctier*... **-z** *desttier* [ **-F** ] [ **-U** ] [ **-T** *dir* ] [ **-X** ] [ **-o** ] [ **-u** *user*... ] [**-S** *seconds*] + **squatter** [ **-C** *config-file* ] [ **-v** ] [ **-a** ] [ **-S** *seconds* ] [ **-Z** ] + **squatter** [ **-C** *config-file* ] [ **-v** ] [ **-a** ] [ **-i** ] [ **-N** *name* ] [ **-S** *seconds* ] [ **-r** ] [ **-Z** ] *mailbox*... + **squatter** [ **-C** *config-file* ] [ **-v** ] [ **-a** ] [ **-i** ] [ **-N** *name* ] [ **-S** *seconds* ] [ **-r** ] [ **-Z** ] **-u** *user*... + **squatter** [ **-C** *config-file* ] [ **-v** ] [ **-a** ] **-R** [ **-n** *channel* ] [ **-d** ] [ **-S** *seconds* ] [ **-Z** ] + **squatter** [ **-C** *config-file* ] [ **-v** ] [ **-a** ] **-f** *synclogfile* [ **-S** *seconds* ] [ **-Z** ] + **squatter** [ **-C** *config-file* ] [ **-v** ] **-t** *srctier(s)*... **-z** *desttier* [ **-B** ] [ **-F** ] [ **-U** ] [ **-T** *reindextiers* ] [ **-X** ] [ **-o** ] [ **-S** *seconds* ] [ **-u** *user*... ] @@ -46,6 +46,8 @@ The index is a unified index of all of the header and body text of each message in a given mailbox. This index is used to significantly reduce IMAP SEARCH times on a mailbox. +**mode** is one of indexer, search, rolling, synclog, compact or audit. + By default, **squatter** creates an index of ALL messages in the mailbox, not just those since the last time that it was run. The **-i** option is used to select incremental updates. Any messages @@ -57,11 +59,12 @@ section of :cyrusman:`cyrus.conf(5)` or use *rolling* mode (**-R**). In the first synopsis, **squatter** indexes all mailboxes. In the second synopsis, **squatter** indexes the specified mailbox(es). +The mailboxes are space-separated. In the third synopsis, **squatter** indexes the specified user(s) mailbox(es). -For any of those three source modes (default=all, mailbox, user) one +For the latter two index modes (mailbox, user) one may optionally specify **-r** to recurse from the specified start, or **-a** to limit action only to mailboxes which have the shared */vendor/cmu/cyrus-imapd/squat* annotation set to "true". @@ -78,19 +81,19 @@ In the fifth synopsis, **squatter** reads a single sync log file and performs incremental indexing on the mailbox(es) listed therein. This is sometimes useful for cleaning up after problems with rolling mode. -In the sixth synopsis, **squatter** reads *file* containing *mailbox* -*uid* tuples and performs indexing on the specified messages. - -In the seventh synopsis, **squatter** will compact indices from +In the sixth synopsis, **squatter** will compact indices from *srctier(s)* to *desttier*, optionally reindexing (**-X**) or filtering -expunged records (**-F**) in the process. The optional **-T** flag may -be used to specify a directory to use for temporary files. The **-o** -flag may be used to direct that a single index be copied, rather than -compacted, from *srctier* to *desttier*. The **-U** flag may be used -to only compact if re-indexing. The **--u** flag may be used to -restrict operation to the specified user. - -For all modes, the **-S** option may be specified, causing squatter to +expunged records (**-F**) in the process. The optional **-T** flag may be +used to specify members of srctiers which must be reindexed. These files are +eventually copied with `rsync -a` and then removed by `rm`. +`rsync` can increase the load average of the system, especially when the +temporary directory is on `tmpfs`. To throttle `rsync` it is possible to +modify the call in `imap/search_xapian.c` and pass `-\\-bwlimit=` as further +parameter. The **-o** flag may be used to direct that a single index be +copied, rather than compacted, from *srctier* to *desttier*. The **-u** flag +may be used to restrict operation to the specified user(s). + +For all modes, the **-S** option may be specified, causing **squatter** to pause *seconds* seconds after each mailbox, to smooth loads. When using the Xapian engine the **-Z** option may be specified, for @@ -119,7 +122,7 @@ Options |cli-dash-c-text| -.. option:: -a +.. option:: -a, --squat-annot Only create indexes for mailboxes which have the shared */vendor/cmu/cyrus-imapd/squat* annotation set to "true". @@ -133,39 +136,51 @@ Options inherit one), then the mailbox is not indexed. In other words, the implicit value of */vendor/cmu/cyrus-imapd/squat* is "false". -.. option:: -d +.. option:: -A, --audit + + Audits the specified mailboxes (or all), reports any unindexed messages. + |master-new-feature| + +.. option:: -d, --nodaemon In rolling mode, don't background and do emit log messages on standard error. Useful for debugging. |v3-new-feature| -.. option:: -F +.. option:: -B, --skip-locked + + In compact mode, use non-blocking lock to start and skip any + users who have their xapianactive file locked at the time (i.e + another reindex task) + |master-new-feature| + +.. option:: -F, --filter In compact mode, filter the resulting database to only include messages which are not expunged in mailboxes with existing name/uidvalidity. |v3-new-feature| -.. option:: -f synclogfile +.. option:: -f synclogfile, --synclog=synclogfile Read the *synclogfile* and incrementally index all the mailboxes listed therein, then exit. |v3-new-feature| -.. option:: -h +.. option:: -h, --help Display this usage information. -.. option:: -i +.. option:: -i, --incremental Incremental updates where indexes already exist. -.. option:: -N name +.. option:: -N name, --name=name Only index mailboxes beginning with *name* while iterating through the mailbox list derived from other options. -.. option:: -n channel +.. option:: -n channel, --channel=channel In rolling mode, specify the name of the sync log *channel* that **squatter** will listen to. The default is "squatter". This @@ -173,78 +188,123 @@ Options being used. |v3-new-feature| -.. option:: -o +.. option:: -o, --copydb In compact mode, if only one source database is selected, just copy it to the destination rather than compacting. |v3-new-feature| -.. option:: -R +.. option:: -p, --allow-partials + + When indexing, allow messages to be partially indexed. This may + occur if attachment indexing is enabled but indexing failed for + one or more attachment body parts. If this flag is set, the + message is partially indexed and squatter continues. Otherwise + squatter aborts with an error. Also see **-P**. + Xapian only. + |master-new-feature| + + .. option:: -P, --reindex-partials + + When reindexing, then attempt to reindex any partially indexed + messages (see **-p**). Setting this flag implies **-Z**. + Xapian only. + |master-new-feature| + + .. option:: -L, --reindex-minlevel=level + + When reindexing, index all messages that have an index level + less than level. Currently, Cyrus only supports two index levels: + A message for which attachment indexing was never attempted has + index level 1. A message that has indexed attachments, or does not + contain attachments, has index level 3. Consequently, running + squatter with minlevel set to 3 will cause it to attempt reindexing + all messages, for which attachment indexing never was attempted. + Future Cyrus versions may introduce additional levels. Setting + this flag implies **-Z**. + Xapian only. + |master-new-feature| + +.. option:: -R, --rolling Run in rolling mode; **squatter** runs as a daemon listening to a sync log channel and continuously incrementally indexing mailboxes. See also **-d** and **-n**. |v3-new-feature| -.. option:: -r +.. option:: -r, --recursive Recursively create indexes for all sub-mailboxes of the user, mailboxes or mailbox prefixes given as arguments. -.. option:: -S seconds +.. option:: -s delta, --squat-skip=delta + + Skip mailboxes that have not been modified since last index. This is + achieved by comparing the last modification time of a mailbox to + the last time the squat index of this mailbox got updated. If the + mailbox modification time plus delta is less than the squat + index modification time, then the mailbox is skipped. The argument + value delta is defined in seconds and must be greater than or equal + to zero. The historical default delta was 60, and this remains a + good general choice, but for technical reasons it must now be + specified explicitly. + Squat only. + +.. option:: -S seconds, --sleep=seconds After processing each mailbox, sleep for "seconds" before continuing. Can be used to provide some load balancing. Accepts fractional amounts. |v3-new-feature| -.. option:: -T directory +.. option:: -T reindextiers, --reindex-tier=reindextiers - When indexing, work on a temporary copy of the search engine - databases in *directory*. That directory would typically be on - some very fast filesystem, like an SSD or tmpfs. This option may - not work with all search engines, but it's only effect is to speed - up initial indexing. - Xapian only. + In compact mode, a comma-separated subset of the source tiers + (see **-t**) to be reindexed. Similar to **-X** but allows + limiting the tiers that will be reindexed. |v3-new-feature| -.. option:: -t srctier... +.. option:: -t srctiers, --srctier=srctiers - In compact mode, the source tier(s) for the compacted indices. - At least one source tier must be specified in compact mode. + In compact mode, the comma-separated source tier(s) for the compacted + indices. At least one source tier must be specified in compact mode. Xapian only. |v3-new-feature| -.. option:: -u +.. option:: -u name, --user=name Extra options refer to usernames (e.g. foo@bar.com) rather than - mailbox names. + mailbox names. Usernames are space-separated. |v3-new-feature| -.. option:: -U +.. option:: -U, --only-upgrade In compact mode, only compact if re-indexing. Xapian only. |master-new-feature| -.. option:: -v +.. option:: -v, --verbose - Increase the verbosity of progress/status messages. + Increase the verbosity of progress/status messages. Sometimes additional messages + are emitted on the terminal with this option and the messages are unconditionally sent + to syslog. Sometimes messages are sent to syslog, only if -v is provided. In rolling and + synclog modes, -vv sends even more messages to syslog. -.. option:: -X +.. option:: -X, --reindex Reindex all the messages before compacting. This mode reads all the lists of messages indexed by the listed tiers, and re-indexes them into a temporary database before compacting that into place. Xapian only. + |v3-new-feature| -.. option:: -z desttier +.. option:: -z desttier, --compact=desttier In compact mode, the destination tier for the compacted indices. This must be specified in compact mode. Xapian only. |v3-new-feature| -.. option:: -Z +.. option:: -Z, --internalindex When indexing messages, use the Xapian internal cyrusid rather than referencing the ranges of already indexed messages to know if a @@ -330,7 +390,7 @@ The following command-line switches were added in version 3.0: .. parsed-literal:: - **-R -u -d -O -F -A** + **-F -R -X -d -f -o -u** The following command-line settings were added in version 3.0: diff --git a/docsrc/imap/reference/manpages/systemcommands/sync_client.rst b/docsrc/imap/reference/manpages/systemcommands/sync_client.rst index aced1e2504..7542b93017 100644 --- a/docsrc/imap/reference/manpages/systemcommands/sync_client.rst +++ b/docsrc/imap/reference/manpages/systemcommands/sync_client.rst @@ -20,7 +20,7 @@ Synopsis **sync_client** [ **-v** ] [ **-l** ] [ **-L** ] [ **-z** ] [ **-C** *config-file* ] [ **-S** *server-name* ] [ **-f** *input-file* ] [ **-F** *shutdown_file* ] [ **-w** *wait_interval* ] [ **-t** *timeout* ] [ **-d** *delay* ] [ **-r** ] [ **-n** *channel* ] [ **-u** ] [ **-m** ] - [ **-p** *partition* ] [ **-A** ] [ **-s** ] [ **-O** ] *objects*... + [ **-p** *partition* ] [ **-A** ] [ **-N** ] [ **-s** ] [ **-O** ] *objects*... Description =========== @@ -41,14 +41,14 @@ Options |cli-dash-c-text| -.. option:: -A +.. option:: -A, --all-users All users mode. Sync every user on the server to the replica (doesn't do non-user mailboxes at all... this could be considered a bug and maybe it should do those mailboxes independently) -.. option:: -d delay +.. option:: -d delay, --delay=delay Minimum delay between replication runs in rolling replication mode. Larger values provide better efficiency as transactions can be @@ -56,14 +56,14 @@ Options date and that you don't end up with large blocks of replication transactions as a single group. Default: 3 seconds. -.. option:: -f input-file +.. option:: -f input-file, --input-file=input-file In mailbox or user replication mode: provides list of users or mailboxes to replicate. In rolling replication mode, specifies an alternate log file (**sync_client** will exit after processing the log file). -.. option:: -F shutdown-file +.. option:: -F shutdown-file, --shutdown-file=shutdown-file Rolling replication checks for this file at the end of each replication cycle and shuts down if it is present. Used to request @@ -71,94 +71,125 @@ Options removed on shutdown. Overrides ``sync_shutdown_file`` option in :cyrusman:`imapd.conf(5)`. -.. option:: -l +.. option:: -l, --verbose-logging Verbose logging mode. -.. option:: -L +.. option:: -L, --local-only Perform only local mailbox operations (do not do mupdate operations). |v3-new-feature| -.. option:: -m +.. option:: -m, --mailboxes Mailbox mode. Remaining arguments are list of mailboxes which should be replicated. -.. option:: -n channel +.. option:: -n channel, --channel=channel Use the named channel for rolling replication mode. If multiple channels are specified in ``sync_log_channels`` then use one of them. This option is probably best combined with **-S** to connect to a different server with each channel. -.. option:: -o +.. option:: -N, --skip-locked - Only attempt to connect to the backend server once rather than - waiting up to 1000 seconds before giving up. + Use non-blocking sync_lock (combination of IP address and username) + to skip over any users who are currently syncing. -.. option:: -O +.. option:: -o, --connect-once + + Only attempt to connect to the backend server once. + + Without this option, **sync_client** will retry failed connections + indefinitely, waiting up to ``sync_reconnect_maxwait`` (default: 20m) + between attempts. + +.. option:: -O, --no-copyback No copyback mode. Replication will stop if the replica reports a CRC error, rather than doing a full mailbox sync. Useful if moving users to a new server, where you don't want any errors to cause the source servers to change the account. -.. option:: -p partition +.. option:: -p partition, --dest-partition=partition In mailbox or user replication mode: provides the name of the partition on the replica to which the mailboxes/users should be replicated. -.. option:: -r +.. option:: -r, --rolling Rolling (repeat) replication mode. Pick up a list of actions recorded by the :cyrusman:`lmtpd(8)`, :cyrusman:`imapd(8)`, :cyrusman:`pop3d(8)` and :cyrusman:`nntpd(8)` daemons from the file specified in ``sync_log_file``. Repeat until ``sync_shutdown_file`` - appears. + appears. Alternative log and shutdown files can be specified with + **-f** and **-F**. + + In this invocation, sync_client will background itself to run as a + daemon. + +.. option:: -R, --foreground-rolling + + As for **-r**, but without backgrounding. + +.. option:: -1, --rolling-once + + As for **-R**, but only process a single log file before exiting. -.. option:: -s +.. option:: -s, --sieve-mode Sieve mode. Remaining arguments are list of users whose Sieve files should be replicated. Principally used for debugging purposes: not exposed to :cyrusman:`sync_client(8)`. -.. option:: -S servername +.. option:: -S servername, --server=servername Tells **sync_client** with which server to communicate. Overrides the ``sync_host`` configuration option. -.. option:: -t timeout +.. option:: -t timeout, --timeout=timeout Timeout for single replication run in rolling replication. **sync_client** will negotiate a restart after this many seconds. Default: 600 seconds -.. option:: -u +.. option:: -u, --userids User mode. Remaining arguments are list of users who should be replicated. -.. option:: -v +.. option:: -v, --verbose Verbose mode. Use twice (**-v -v**) to log all protocol traffic to stderr. -.. option:: -w interval +.. option:: -w interval, --delayed-startup=interval Wait this long before starting. This option is typically used so that we can attach a debugger to one end of the replication system or the other. -.. option:: -z +.. option:: -z, --require-compression Require compression. The replication protocol will always try to enable deflate compression if both ends support it. Set this flag when you want to abort if compression is not available. +.. option:: -a, --stage-to-archive + + Request the stage-to-archive feature. If the remote end has the + ``archive_enabled`` option set, then it will stage incoming replication on + the archive partition instead of the spool partition. If the remote end + does not support it, replication will proceed as though **-a** was not + provided. This option is useful when standing up a new replica of an + existing server, as most of the stored mail is likely older than the + archive threshold and so is destined for the archive partition anyway. By + staging on that partition, Cyrus can avoid a cross-partition copy for every + message. Examples ======== diff --git a/docsrc/imap/reference/manpages/systemcommands/sync_reset.rst b/docsrc/imap/reference/manpages/systemcommands/sync_reset.rst index 83d5c387e3..8f948f1556 100644 --- a/docsrc/imap/reference/manpages/systemcommands/sync_reset.rst +++ b/docsrc/imap/reference/manpages/systemcommands/sync_reset.rst @@ -37,11 +37,11 @@ Options |cli-dash-c-text| -.. option:: -v +.. option:: -v, --verbose Verbose mode. -.. option:: -f +.. option:: -f, --force Force operation. Without this flag **sync_reset** just bails out with an error. Principally here to try and prevent accidents with command diff --git a/docsrc/imap/reference/manpages/systemcommands/sync_server.rst b/docsrc/imap/reference/manpages/systemcommands/sync_server.rst index 52196e9db0..fee07a86bf 100644 --- a/docsrc/imap/reference/manpages/systemcommands/sync_server.rst +++ b/docsrc/imap/reference/manpages/systemcommands/sync_server.rst @@ -23,7 +23,7 @@ Synopsis Description =========== -**sync_server** is the server side of the the replication system. It +**sync_server** is the server side of the replication system. It runs on the target (replica) system and listens for connections from :cyrusman:`sync_client(8)` which provides instructions for synchronizing the replica system with the master system. diff --git a/docsrc/imap/reference/manpages/systemcommands/timsieved.rst b/docsrc/imap/reference/manpages/systemcommands/timsieved.rst index 462e104e6e..94d433179f 100644 --- a/docsrc/imap/reference/manpages/systemcommands/timsieved.rst +++ b/docsrc/imap/reference/manpages/systemcommands/timsieved.rst @@ -17,7 +17,7 @@ Synopsis .. parsed-literal:: - **timsieved** [ **-C** *config-file* ] + **timsieved** [ **-C** *config-file* ] [ **-H** ] Description =========== @@ -40,6 +40,10 @@ Options .. option:: -C config-file +.. option:: -H + + Tell **timsievedd** to expect a HAProxy protocol header from the sender. + |cli-dash-c-text| Examples diff --git a/docsrc/imap/reference/manpages/systemcommands/unexpunge.rst b/docsrc/imap/reference/manpages/systemcommands/unexpunge.rst index 953d167824..4267819afe 100644 --- a/docsrc/imap/reference/manpages/systemcommands/unexpunge.rst +++ b/docsrc/imap/reference/manpages/systemcommands/unexpunge.rst @@ -16,7 +16,7 @@ Synopsis .. parsed-literal:: - **unexpunge** [ **-C** *config-file* ] **-l** *mailbox* + **unexpunge** [ **-C** *config-file* ] **-l** *mailbox* [ *uid*... ] **unexpunge** [ **-C** *config-file* ] **-t** *time-interval* [ **-d** ] [ **-v** ] [ **-f** *flagname* ] **mailbox** **unexpunge** [ **-C** *config-file* ] **-a** [ **-d** ] [ **-v** ] [ **-f** *flagname* ] *mailbox* **unexpunge** [ **-C** *config-file* ] **-u** [ **-d** ] [ **-v** ] [ **-f** *flagname* ] *mailbox* *uid*... @@ -42,38 +42,40 @@ Options |cli-dash-c-text| -.. option:: -l +.. option:: -l, --list List the expunged messages in the specified mailbox which are available for restoration. + Optionally, only list the messages in the mailbox matching the + UIDs in the space\-separated list at the end of the command invocation. -.. option:: -t time-interval +.. option:: -t time-interval, --within-time-interval=time-interval - Unexpunge messages which where expunged within the last + Unexpunge messages which were expunged within the last ``time-interval`` seconds. Use one of the trailing modifiers -- ``m`` (minutes), ``h`` (hours), ``d`` (days) or ``w`` (weeks) -- to specify a different time unit. -.. option:: -a +.. option:: -a, --all Restore **all** of the expunged messages in the specified mailbox. -.. option:: -u +.. option:: -u, --uids Restore only messages matching the UIDs, in a space-separated list at the end of the command invocation, in the specified mailbox. -.. option:: -d +.. option:: -d, --unset-deleted Unset the *\\Deleted* flag on any restored messages. -.. option:: -f flagname +.. option:: -f flagname, --set-flag=flagname Set the user flag *\\flagname* on the messages restored, making it easier for the user(s) to find the restored messages and operate on them (in a batch). -.. option:: -v +.. option:: -v, --verbose Enable verbose output/logging. diff --git a/docsrc/imap/reference/manpages/usercommands/dav_reconstruct.rst b/docsrc/imap/reference/manpages/usercommands/dav_reconstruct.rst index 4a8acb139c..c89bb8f3de 100644 --- a/docsrc/imap/reference/manpages/usercommands/dav_reconstruct.rst +++ b/docsrc/imap/reference/manpages/usercommands/dav_reconstruct.rst @@ -31,13 +31,14 @@ Options Alternative config file with cyrus settings. -.. option:: -a +.. option:: -a, --all Process all users on this store. -.. option:: -A \ +.. option:: -A audit-tool, --audit-tool=audit-tool - Name of a program to take two sqlite databases and compare them. This option currently does not work. + Name of a program to take two sqlite databases and compare them. This option + currently does not work. .. option:: userid_list diff --git a/docsrc/imap/reference/manpages/usercommands/installsieve.rst b/docsrc/imap/reference/manpages/usercommands/installsieve.rst index 1a63acf480..c7a5494950 100644 --- a/docsrc/imap/reference/manpages/usercommands/installsieve.rst +++ b/docsrc/imap/reference/manpages/usercommands/installsieve.rst @@ -40,7 +40,7 @@ Options .. option:: -l List all of the scripts currently on the server. If one of the - scripts is active a arrow is printed indicating that it is the + scripts is active an arrow is printed indicating that it is the active script. .. option:: -p port diff --git a/docsrc/imap/rfc-support.rst b/docsrc/imap/rfc-support.rst index fc789cb3d7..98c5ce9195 100644 --- a/docsrc/imap/rfc-support.rst +++ b/docsrc/imap/rfc-support.rst @@ -95,11 +95,11 @@ The following is an inventory of RFCs supported by Cyrus IMAP. :rfc:`2087` - IMAP4 QUOTA extension + IMAP4 QUOTA extension, obsoleted by :rfc:`9208`. :rfc:`2088` - IMAP4 non-synchronizing literals + IMAP4 non-synchronizing literals, obsoleted by :rfc:`7888`. :rfc:`2177` @@ -135,6 +135,14 @@ The following is an inventory of RFCs supported by Cyrus IMAP. IMAP4 UIDPLUS extension, obsoleted by :rfc:`4315` +:rfc:`2425` + + A MIME Content-Type for Directory Information + +:rfc:`2426` + + vCard MIME Directory Profile + :rfc:`2444` The One-Time-Password SASL Mechanism @@ -151,19 +159,10 @@ The following is an inventory of RFCs supported by Cyrus IMAP. Using TLS with IMAP, POP3 and ACAP -:rfc:`2617` - - HTTP Authentication: Basic and Digest Access Authentication, - updated by :rfc:`7615`, :rfc:`7616`, :rfc:`7617`. - :rfc:`2817` HTTP Upgrading to TLS Within HTTP/1.1 -:rfc:`2818` - - HTTP Over TLS - :rfc:`2821` Simple Mail Transfer Protocol @@ -172,10 +171,6 @@ The following is an inventory of RFCs supported by Cyrus IMAP. Internet Message Format -:rfc:`2831` - - Using Digest Authentication as a SASL Mechanism - :rfc:`2920` SMTP Service Extension for Command Pipelining @@ -223,7 +218,8 @@ The following is an inventory of RFCs supported by Cyrus IMAP. :rfc:`3501` - Internet Message Access Protocol - version 4rev1 + Internet Message Access Protocol - version 4rev1, obsoleted by + :rfc:`9051`. :rfc:`3502` @@ -317,7 +313,7 @@ The following is an inventory of RFCs supported by Cyrus IMAP. :rfc:`4551` IMAP Extension for Conditional STORE Operation or Quick Flag Changes - Resynchronization + Resynchronization, obsoleted by :rfc:`7162`. :rfc:`4559` @@ -381,18 +377,32 @@ The following is an inventory of RFCs supported by Cyrus IMAP. IMAP URL Scheme, updated by :rfc:`5593`. +:rfc:`5051` + + i;unicode-casemap - Simple Unicode Collation Algorithm + + .. NOTE:: + + This collation is ONLY supported by Sieve. Support in IMAP + is documented in :rfc:`5255`, which is currently NOT implemented. + :rfc:`5161` The IMAP ENABLE Extension :rfc:`5162` - IMAP4 Extensions for Quick Mailbox Resynchronization + IMAP4 Extensions for Quick Mailbox Resynchronization, obsoleted by + :rfc:`7162`. :rfc:`5173` Sieve Email Filtering: Body Extension +:rfc:`5182` + + IMAP Extension for Referencing the Last SEARCH Result + :rfc:`5183` Sieve Email Filtering: Environment Extension @@ -441,6 +451,15 @@ The following is an inventory of RFCs supported by Cyrus IMAP. .. versionadded:: 2.5.0 +:rfc:`5267` + + Contexts for IMAP4 + + .. NOTE:: + + The ESORT capability is implemented. The CONTEXT=SEARCH and + CONTEXT=SORT capabilities are not implemented. + :rfc:`5293` Sieve Email Filtering: Editheader Extension @@ -453,6 +472,10 @@ The following is an inventory of RFCs supported by Cyrus IMAP. Internet Message Format + .. NOTE:: + + The JMAP mapping is incomplete. + :rfc:`5397` WebDAV Current Principal Extension @@ -511,6 +534,16 @@ The following is an inventory of RFCs supported by Cyrus IMAP. iCalendar Transport-Independent Interoperability Protocol (iTIP) +:rfc:`5550` + + The Internet Email to Support Diverse Service Environments (Lemonade) Profile + + .. NOTE:: + + The URL-PARTIAL capability is implemented. The CONTEXT=SEARCH, + CONTEXT=SORT, CONVERT, I18NLEVEL=1, and NOTIFY capabilities + are not implemented. + :rfc:`5593` Internet Message Access Protocol (IMAP) - URL Access Identifier @@ -537,6 +570,16 @@ The following is an inventory of RFCs supported by Cyrus IMAP. Using POST to Add Members to Web Distributed Authoring and Versioning (WebDAV) Collections +:rfc:`6009` + + Sieve Email Filtering: Delivery Status Notifications and + Deliver-By Extensions + + .. NOTE:: + + envelope-dsn and envelope-deliverby are implemented. redirect-dsn + and redirect-deliverby are not implemented. + :rfc:`6047` iCalendar Message-Based Interoperability Protocol (iMIP) @@ -547,7 +590,7 @@ The following is an inventory of RFCs supported by Cyrus IMAP. .. NOTE:: - SSLv3 is considered inscure as it is vulnerable to POODLE. + SSLv3 is considered insecure as it is vulnerable to POODLE. Support for SSLv3 is being deprecated and removed. @@ -555,10 +598,23 @@ The following is an inventory of RFCs supported by Cyrus IMAP. Sieve Vacation Extension: "Seconds" Parameter +:rfc:`6134` + + Sieve Extension: Externally Stored Lists + :rfc:`6154` IMAP LIST Extension for Special-Use Mailboxes + .. NOTE:: + + The unextended LIST and LSUB commands return the special-use flags, unless + the ``specialusealways`` configuration variable is explicitly turned off. + +:rfc:`6203` + + IMAP4 Extension for Fuzzy Search + :rfc:`6321` xCal: The XML Format for iCalendar @@ -576,6 +632,10 @@ The following is an inventory of RFCs supported by Cyrus IMAP. DomainKeys Identified Mail (DKIM) Signatures +:rfc:`6455` + + The WebSocket Protocol + :rfc:`6578` Collection Synchronization for Web Distributed Authoring and @@ -608,33 +668,23 @@ The following is an inventory of RFCs supported by Cyrus IMAP. .. versionadded:: 2.5.0 -:rfc:`7230` - - Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing - -:rfc:`7231` - - Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content - -:rfc:`7232` - - Hypertext Transfer Protocol (HTTP/1.1): Conditional Requests - -:rfc:`7233` +:rfc:`6855` - Hypertext Transfer Protocol (HTTP/1.1): Range Requests + IMAP Support for UTF-8 -:rfc:`7234` + .. NOTE:: - Hypertext Transfer Protocol (HTTP/1.1): Caching + This extension will only be advertised and supported + if both 'reject8bit' and 'munge8bit' are disabled. -:rfc:`7235` +:rfc:`6901` - Hypertext Transfer Protocol (HTTP/1.1): Authentication + JavaScript Object Notation (JSON) Pointer -:rfc:`7238` +:rfc:`7162` - The Hypertext Transfer Protocol Status Code 308 (Permanent Redirect) + IMAP Extensions: Quick Flag Changes Resynchronization (CONDSTORE) + and Quick Mailbox Resynchronization (QRESYNC) :rfc:`7239` @@ -648,31 +698,40 @@ The following is an inventory of RFCs supported by Cyrus IMAP. jCal: The JSON Format for iCalendar +:rfc:`7352` + + Sieve Email Filtering: Detecting Duplicate Deliveries + +:rfc:`7377` + + IMAP4 Multimailbox SEARCH Extension + :rfc:`7529` Non-Gregorian Recurrence Rules in the Internet Calendaring and Scheduling Core Object Specification (iCalendar) -:rfc:`7540` - - Hypertext Transfer Protocol Version 2 (HTTP/2) - :rfc:`7615` HTTP Authentication-Info and Proxy-Authentication-Info Response - Header Fields - -:rfc:`7616` - - HTTP Digest Access Authentication + Header Fields, obsoleted by :rfc:`9110` :rfc:`7617` The 'Basic' HTTP Authentication Scheme +:rfc:`7692` + + Compression Extensions for WebSocket + :rfc:`7694` - Hypertext Transfer Protocol (HTTP) Client-Initiated Content-Encoding + Hypertext Transfer Protocol (HTTP) Client-Initiated Content-Encoding, + obsoleted by :rfc:`9110` + +:rfc:`7725` + + An HTTP Status Code to Report Legal Obstacles :rfc:`7804` @@ -686,6 +745,14 @@ The following is an inventory of RFCs supported by Cyrus IMAP. CalDAV: Time Zones by Reference +:rfc:`7888` + + IMAP4 Non-synchronizing Literals + +:rfc:`7889` + + The IMAP APPENDLIMIT Extension + :rfc:`7932` Brotli Compressed Data Format @@ -694,29 +761,168 @@ The following is an inventory of RFCs supported by Cyrus IMAP. Calendar Availability +:rfc:`7986` + + New Properties for iCalendar + + .. NOTE:: + + Support here means, that when the iCalendar stream is retrieved with HTTP GET, + Cyrus IMAP inserts the color, description and name from the WebDAV properties. + IMAGE, SOURCE, multi-lingual calendar DESCRIPTIONs, URL, LAST-MODIFIED, CATEGORIES, + and REFRESH-INTERVAL are not exported on iCalendar streams retrieved with GET. + + Individual iCalendar objects (VEVENT, VTODO, VJOURNAL) can be uploaded and + downloaded with the New Properties for iCalendar. + :rfc:`8144` Use of the Prefer Header Field in Web Distributed Authoring and Versioning (WebDAV) +:rfc:`8246` + + HTTP Immutable Responses + +:rfc:`8288` + + Web Linking + +:rfc:`8297` + + An HTTP Status Code for Indicating Hints + +:rfc:`8437` + + IMAP UNAUTHENTICATE Extension for Connection Reuse + +:rfc:`8438` + + IMAP Extension for STATUS=SIZE + +:rfc:`8440` + + IMAP4 Extension for Returning MYRIGHTS Information in Extended LIST + +:rfc:`8441` + + Bootstrapping WebSockets with HTTP/2 + +:rfc:`8474` + + IMAP Extension for Object Identifiers + +:rfc:`8508` + + IMAP REPLACE Extension + +:rfc:`8514` + + Internet Message Access Protocol (IMAP) - SAVEDATE Extension + +:rfc:`8579` + + Sieve Email Filtering: Delivering to Special-Use Mailboxes + +:rfc:`8580` + + Sieve Extension: File Carbon Copy (FCC) + +:rfc:`8607` + + Calendaring Extensions to WebDAV (CalDAV): Managed Attachments + +:rfc:`8620` + + The JSON Meta Application Protocol (JMAP) + +:rfc:`8621` + + The JSON Meta Application Protocol (JMAP) for Mail + +:rfc:`8878` + + Zstandard Compression and the application/zstd Media Type + +:rfc:`8887` + + A JSON Meta Application Protocol (JMAP) Subprotocol for WebSocket + +:rfc:`8970` + + IMAP4 Extension: Message Preview Generation + +:rfc:`9042` + + Sieve Email Filtering: Delivery by MAILBOXID + +:rfc:`9051` + + Internet Message Access Protocol (IMAP) - version 4rev2 + +:rfc:`9110` + + HTTP Semantics + +:rfc:`9111` + + HTTP Caching + +:rfc:`9112` + + HTTP/1.1 + +:rfc:`9113` + + HTTP/2 + +:rfc:`9208` + + IMAP QUOTA Extension + +:rfc:`9394` + + IMAP PARTIAL Extension for Paged SEARCH and FETCH IETF RFC Drafts =============== +draft-ietf-extra-imap-list-metadata + + IMAP4 Extension for Returning Mailbox METADATA in Extended LIST + +draft-ietf-extra-imap-inprogress + + IMAP4 Response Code for Command Progress Notifications + +draft-ietf-extra-jmapaccess + + The JMAPACCESS Extension for IMAP + +draft-ietf-extra-sieve-snooze + + Sieve Email Filtering: Snooze Extension + +draft-ietf-extra-imap-uidonly + + IMAP Extension for only using and returning UIDs + +draft-ietf-jmap-calendars + + JMAP for Calendars + +draft-ietf-jmap-sieve + + JMAP for Sieve Scripts + draft-murchison-lmtp-ignorequota LMTP Service Extension for Ignoring Recipient Quotas -[MS-NTHT] NTLM Over HTTP Protocol Specification - draft-ietf-sieve-regex Sieve Email Filtering -- Regular Expression Extension -draft-martin-sieve-notify - - Sieve -- An extension for providing instant notifications - draft-york-vpoll VPOLL: Consensus Scheduling Component for iCalendar @@ -737,20 +943,53 @@ draft-thomson-hybi-http-timeout RFC Wishlist ============ +:rfc:`2221` + + IMAP4 Login Referrals + +:rfc:`2295` + + Transparent Content Negotiation in HTTP + +:rfc:`2369` + + The Use of URLs as Meta-Syntax for Core Mail List Commands + and their Transport through Message Header Fields + +:rfc:`3229` + + Delta encoding in HTTP + :rfc:`5235` Sieve Email Filtering: Spamtest and Virustest Extensions +:rfc:`5255` + + Internet Message Access Protocol Internationalization + +:rfc:`5259` + + Internet Message Access Protocol - CONVERT Extension + :rfc:`5437` Sieve Notification Mechanism: Extensible Messaging and Presence Protocol (XMPP) +:rfc:`5466` + + IMAP4 Extension for Named Searches (Filters) + :rfc:`5703` Sieve Email Filtering: MIME Part Tests, Iteration, Extraction, Replacement, and Enclosure +:rfc:`5842` + + Binding Extensions to Web Distributed Authoring and Versioning (WebDAV) + :rfc:`6468` Sieve Notification Mechanism: SIP MESSAGE @@ -762,3 +1001,7 @@ RFC Wishlist :rfc:`6785` Support for Internet Message Access Protocol (IMAP) Events in Sieve + +:rfc:`8470` + + Using Early Data in HTTP diff --git a/docsrc/imap/support/feedback-irc.rst b/docsrc/imap/support/feedback-irc.rst deleted file mode 100644 index 18de4fafc4..0000000000 --- a/docsrc/imap/support/feedback-irc.rst +++ /dev/null @@ -1,21 +0,0 @@ -.. _feedback-irc: - -======== -IRC Chat -======== - -We found chatting on IRC has just that little more bandwidth available -for a conversation as opposed to mailing lists and/or bug reports, so we -would like to invite you to join us on IRC if you're interested. - -There are often Cyrus developers and experienced administrators hanging -around to talk to in a friendly, helpful environment. - -Network: **FreeNode (irc.freenode.net)** - -Channel: **#cyrus** - -If you have an IRC client installed, you may be able to use this link: irc://irc.freenode.net/cyrus - -If you don't have an IRC client, you can use the `FreeNode web client `__ - diff --git a/docsrc/imap/support/feedback-mailing-lists.rst b/docsrc/imap/support/feedback-mailing-lists.rst index 6578c0ef68..2dbce479c0 100644 --- a/docsrc/imap/support/feedback-mailing-lists.rst +++ b/docsrc/imap/support/feedback-mailing-lists.rst @@ -4,80 +4,29 @@ Mailing Lists ============= +Cyrus is discussed at a few official mailing lists hosted by +`Topicbox `_ — which is, itself, powered by Cyrus! -* cyrus-announce@lists.andrew.cmu.edu +At the links below you can browse the archives and subscribe to the lists. You +can request a daily summary if you don't want to receive every message in real +time. + +* `Cyrus Announcements `_ This is a low-traffic mailing list used only for announcements related to Cyrus releases. -* info-cyrus@lists.andrew.cmu.edu +* `Cyrus Info `_ This is the general mailing list regarding all aspects of Cyrus software. The bulk of the discussion is about the IMAP server. -* cyrus-sasl@lists.andrew.cmu.edu +* `Cyrus SASL `_ This is the mailing list specifically about the Cyrus SASL library. -* cyrus-devel@lists.andrew.cmu.edu +* `Cyrus Development `_ This is a mailing list for the use of Cyrus developers and package maintainers, for all the Cyrus software. -Archives --------- - -.. TODO:: - - Which is the best url for the http list archive? Pipermail or the - other one? Does the anonymous IMAP version still work? - -The list archives can be accessed as follows: - -* **cyrus-announce** - `Anonymous IMAP `__ : `Web/HTTP `__ or `Pipermail `__ -* **info-cyrus** - `Anonymous IMAP `__ : `Web/HTTP `__ or `Pipermail `__ -* **cyrus-sasl** - `Anonymous IMAP `__: `Web/HTTP `__ or `Pipermail `__ -* **cyrus-devel** - `Anonymous IMAP `__ : `Web/HTTP `__ or `Pipermail `__ - -Subscribe/Unsubscribe ---------------------- - -The mailing lists are managed by GNU Mailman. You can manage your list subscription options by visiting -https://lists.andrew.cmu.edu/ - -or send mail to the following addresses to subscribe: - - To subscribe to cyrus-announce send email to - cyrus-announce-subscribe@lists.andrew.cmu.edu. - - To subscribe to info-cyrus send email to - info-cyrus-subscribe@lists.andrew.cmu.edu. - - To subscribe to cyrus-sasl send email to - cyrus-sasl-subscribe@lists.andrew.cmu.edu. - - To subscribe to cyrus-devel send email to - cyrus-devel-subscribe@lists.andrew.cmu.edu. - -Or to these addresses to unsubscribe: - - To unsubscribe from cyrus-announce email - cyrus-announce-unsubscribe@lists.andrew.cmu.edu - - To unsubscribe from info-cyrus email - info-cyrus-unsubscribe@lists.andrew.cmu.edu - - To unsubscribe from cyrus-sasl email - cyrus-sasl-unsubscribe@lists.andrew.cmu.edu - - To unsubscribe from cyrus-devel email - cyrus-devel-unsubscribe@lists.andrew.cmu.edu - -Digest Lists ------------- - -All of the lists have digest versions. To get the list as a digest, subscribe normally, and then set your list options by visiting the Mailman list info page at https://lists.andrew.cmu.edu/. diff --git a/docsrc/imap/support/feedback-meetings.rst b/docsrc/imap/support/feedback-meetings.rst index ba7984f657..50c6e8bcdd 100644 --- a/docsrc/imap/support/feedback-meetings.rst +++ b/docsrc/imap/support/feedback-meetings.rst @@ -6,6 +6,8 @@ Online Meetings Join us online! -Regular contributors catch up online weekly, both as a status checkpoint and to make sure we're all alive! +Regular contributors catch up online weekly, both as a status checkpoint and +to make sure we're all alive! -Meetings are currently held at **Monday 21:00 UTC** via `Google Hangouts `_. It's worth checking on :ref:`IRC ` to confirm the time and URL if nobody else seems to be online. +Meetings are currently held at **Monday 11:00 UTC** via +`Zoom `_. diff --git a/docsrc/index.rst b/docsrc/index.rst index 031e169061..c7be0abac6 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -1,5 +1,6 @@ .. _imap-index: +=================== What is Cyrus IMAP? =================== @@ -31,7 +32,7 @@ Features Read more in our full :ref:`list of features `. -Cyrus has been under active development since the year 2000. It's used in production +Cyrus has been under active development since the year 1993 when the project was launched at Carnegie Mellon University. It's used in production systems around the world, at universities and in private enterprise. Need help? We have :ref:`active mailing lists `. @@ -52,6 +53,7 @@ Need help? We have :ref:`active mailing lists `. -------- +=================== What is Cyrus SASL? =================== Simple Authentication and Security Layer (SASL_) is a specification that describes how authentication mechanisms can be plugged into an application protocol on the wire. Cyrus SASL is an implementation of SASL that makes it easy for application developers to integrate authentication mechanisms into their application in a generic way. diff --git a/docsrc/make.bat b/docsrc/make.bat index 97c41e8ebd..0287128ba7 100644 --- a/docsrc/make.bat +++ b/docsrc/make.bat @@ -176,7 +176,7 @@ if "%1" == "text" ( ) if "%1" == "man" ( - %SPHINXBUILD% -b cyrman %ALLSPHINXOPTS% %BUILDDIR%/man + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. diff --git a/docsrc/operations.rst b/docsrc/operations.rst index c47f178b6d..faee86ecb6 100644 --- a/docsrc/operations.rst +++ b/docsrc/operations.rst @@ -6,6 +6,6 @@ Operations .. toctree:: :maxdepth: 2 - imap/reference/manpages/commands + imap/reference/manpages/index imap/reference/admin imap/reference/faq diff --git a/docsrc/overview.rst b/docsrc/overview.rst index 02bbefd444..8db5b459d0 100644 --- a/docsrc/overview.rst +++ b/docsrc/overview.rst @@ -5,7 +5,6 @@ Overview .. toctree:: :maxdepth: 2 - :glob: imap/concepts/features imap/concepts/overview_and_concepts diff --git a/docsrc/overview/cyrus_history.rst b/docsrc/overview/cyrus_history.rst index 646fb7225b..f8e1e3a78e 100644 --- a/docsrc/overview/cyrus_history.rst +++ b/docsrc/overview/cyrus_history.rst @@ -20,4 +20,4 @@ Cyrus the Great (c. 585-529 BC) founded the ancient Persian Empire, and then nee "Nothing mortal travels so fast as these Persian messengers. The entire plan is a Persian invention; and this is the method of it. Along the whole line of road there are men stationed with horses, in number equal to the number of days which the journey takes, allowing a man and horse to each day; and these men will not be hindered from accomplishing at their best speed the distance which they have to go, either by snow, or rain, or heat, or by the darkness of night. The first rider delivers his despatch to the second and the second passes it to the third; and so it is borne from hand to hand along the whole line..." .. _Cyrus Project Goals: https://asg.andrew.cmu.edu/cyrus/1994-goals.html -.. _Cyrus Technology Overview: https://asg.andrew.cmu.edu/cyrus/1994-techoverview.html \ No newline at end of file +.. _Cyrus Technology Overview: https://asg.andrew.cmu.edu/cyrus/1994-techoverview.html diff --git a/docsrc/overview/cyrus_roadmap.rst b/docsrc/overview/cyrus_roadmap.rst index b0af565cc8..7645f7dd5c 100644 --- a/docsrc/overview/cyrus_roadmap.rst +++ b/docsrc/overview/cyrus_roadmap.rst @@ -4,9 +4,15 @@ Cyrus Roadmap ============= -This is a very general, high-level view of where the Cyrus project is heading in the future, and the amount of code support you may expect to receive if you're running an older version of Cyrus. +This is a very general, high-level view of where the Cyrus project is heading +in the future, and the amount of code support you may expect to receive if +you're running an older version of Cyrus. -This is under your control! If there's a feature you'd like to see added, or testing/documentation you'd like to see improved, we'd love to have your involvement to help make it happen. We're here to support you. :ref:`Contact us ` and take a look at the :ref:`Contributor guides `. +This is under your control! If there's a feature you'd like to see added, or +testing/documentation you'd like to see improved, we'd love to have your +involvement to help make it happen. We're here to support you. +:ref:`Contact us ` and take a look at the +:ref:`Contributor guides `. High Level Roadmap ================== @@ -32,24 +38,18 @@ Future 2.5.x ----- -* Support ANNOTATE (`RFC 5257`_) -* Support some of ESORT/ESEARCH (`RFC 5256`_) -* Support LIST-EXT STATUS (`RFC 5819`_) -* Support SORT=DISPLAY (`RFC 5957`_) -* Support SpecialUse (`RFC 6154`_) +* Support ANNOTATE (:rfc:`5257`) +* Support some of ESORT/ESEARCH (:rfc:`5256`) +* Support LIST-EXT STATUS (:rfc:`5819`) +* Support SORT=DISPLAY (:rfc:`5957`) +* Support SpecialUse (:rfc:`6154`) * Autocreate/autosieve (needs to be -enabled in config) * Complete compliance with all tests from ImapTest_ (integrated with Cassandane) * cyr_info utility - configuration 'lint' and dumping tool. -* MESSAGE quota support (`RFC 2087`_) +* MESSAGE quota support (:rfc:`2087`) * Some CalDAV calendaring support. * Some CardDAV contact support. -.. _RFC 5257: http://tools.ietf.org/html/rfc5257 -.. _RFC 5256: http://tools.ietf.org/html/rfc5256 -.. _RFC 5819: http://tools.ietf.org/html/rfc5819 -.. _RFC 5957: http://tools.ietf.org/html/rfc5959 -.. _RFC 6154: http://tools.ietf.org/html/rfc6154 -.. _RFC 2087: http://tools.ietf.org/html/rfc2087 .. _ImapTest: http://www.imapwiki.org/ImapTest 2.4.x diff --git a/docsrc/overview/what_is_cyrus.rst b/docsrc/overview/what_is_cyrus.rst index 6d9e83e490..5465c7659b 100644 --- a/docsrc/overview/what_is_cyrus.rst +++ b/docsrc/overview/what_is_cyrus.rst @@ -12,7 +12,7 @@ What is IMAP? ------------- The Cyrus IMAP (Internet Message Access Protocol) server provides access to personal mail and system-wide bulletin boards through the IMAP protocol. The Cyrus IMAP server is a scalable enterprise mail system designed for use from small to large enterprise environments using standards-based technologies. -A full Cyrus IMAP implementation allows a seamless mail and bulletin board environment to be set up across multiple servers. It differs from other IMAP server implementations in that it is run on "sealed" servers, where users are not normally permitted to log in. The mailbox database is stored in parts of the filesystem that are private to the Cyrus IMAP system. All user access to mail is through software using the IMAP, POP3, or KPOP protocols. +A full Cyrus IMAP implementation allows a seamless mail and bulletin board environment to be set up across multiple servers. It differs from other IMAP server implementations in that it is run on "sealed" servers, where users are not normally permitted to log in. The mailbox database is stored in parts of the filesystem that are private to the Cyrus IMAP system. All user access to mail is through software using the IMAP, POP3, or JMAP protocols. The private mailbox database design gives the server large advantages in efficiency, scalability, and administrability. Multiple concurrent read/write connections to the same mailbox are permitted. The server supports access control lists on mailboxes and storage quotas on mailbox hierarchies. diff --git a/docsrc/overview/who_is_cyrus.rst b/docsrc/overview/who_is_cyrus.rst index 82eca24734..e2ecfd2ed2 100644 --- a/docsrc/overview/who_is_cyrus.rst +++ b/docsrc/overview/who_is_cyrus.rst @@ -1,40 +1,32 @@ Who Is Cyrus ============ -The Cyrus project was originally started at `Carnegie Mellon University`_ but it has since attracted a very dedicated and diverse group of core contributors. Carnegie Mellon University remains active in development of Cyrus and also provides the infrastructure on which cyrusimap.org runs today, but CMU is now just one of many core contributors to Project Cyrus. +The Cyrus project was originally started at `Carnegie Mellon University`_ but it has since attracted a very dedicated and diverse group of core contributors. Core Contributors ----------------- These are the core organizations who are providing dedicated resources to the Cyrus Project. -**FastMail** +**Fastmail** -FastMail_ is a leading e-mail hosting provider that runs on Cyrus software. +Fastmail_ is a leading e-mail hosting provider that runs on Cyrus software. -* Bron Gondwana -* Ellie Timoney -* Conrad Kleinespel -* Chris Davies -* Nicola Nye +* Bron Gondwana +* Ellie Timoney +* Ken Murchison +* Robert Stepanek **Kolab Systems** `Kolab Systems AG`_ is the developer of the Kolab Groupware system, of which Cyrus IMAP is a core component. -* Jeroen van Meeuwen +* Jeroen van Meeuwen **Isode** `Isode Ltd`_ is a renowned developer of open-standard e-mail, instant messaging, and directory software. Its internet servers use Cyrus SASL for authentication. -* Alexey Melnikov - -**Carnegie Mellon University** - -`Carnegie Mellon University`_ is a global research university with more than 11,000 students, 86,500 alumni, and 4,000 faculty and staff. - -* Ken Murchison -* Dave McMurtrie +* Alexey Melnikov Individual contributors and past contributors @@ -42,13 +34,13 @@ Individual contributors and past contributors There are also individuals who have dedicated their time to Project Cyrus in various ways including answering questions on the mailing lists and IRC channel, writing code, testing new releases, etc. This isn't going to be a complete list by any stretch. If I left anyone off this list it's because I have a bad memory and not because your contribution wasn't appreciated. -* Raymond Poitras -* Jean-Francois Smigielski -* Nic Bernstein -* Wes Craig -* Benn Oshrin -* Matt Selsky -* Jeffrey T. Eaton +* Raymond Poitras +* Jean-Francois Smigielski +* Nic Bernstein +* Wes Craig +* Benn Oshrin +* Matt Selsky +* Jeffrey T. Eaton * Yoni Afek * Leena Heino * Dan White @@ -76,9 +68,12 @@ There are also individuals who have dedicated their time to Project Cyrus in var * Chris Newman * Laurie D. Mann * Simon Matter -* Greg Banks +* Greg Banks +* Conrad Kleinespel +* Chris Davies +* Nicola Nye .. _Carnegie Mellon University: http://www.cmu.edu/ -.. _FastMail: http://www.fastmail.com/ +.. _Fastmail: http://www.fastmail.com/ .. _Kolab Systems AG: http://kolabsys.com/ .. _Isode Ltd: http://isode.com/ diff --git a/docsrc/quickstart.rst b/docsrc/quickstart.rst index 867cf7c2a2..c9d4271104 100644 --- a/docsrc/quickstart.rst +++ b/docsrc/quickstart.rst @@ -16,78 +16,46 @@ Quick install A quick guide to getting a basic installation of Cyrus up and running in 5 minutes. The first place to start with a new installation of Cyrus IMAP is with -your OS distribution of choice and their packaging, where available. If -there is no Cyrus IMAP 3.0 package available yet from your distro, -download the `latest stable package`_ : version |imap_current_stable_version|. +your OS distribution of choice and their packaging, where available. -.. _latest stable package: ftp://ftp.cyrusimap.org/cyrus-imapd/debs/ +If there is no Cyrus IMAP |imap_current_stable_version| package available yet +from your distro, download the official source tarball from GitHub_. The +:ref:`compiling` guide will help you get it built and installed. -We only provide limited options for reference packages, so use a -supported distribution whenever possible. At this time the only -official Cyrus packages are for Debian Jessie (with backports enabled). +.. _GitHub: https://github.com/cyrusimap/cyrus-imapd/releases -Please download both the packages (\*.deb) and the signature files. -Installation is a two step process: -1. Install Cyrus reference packages +1. Install Cyrus package(s) ----------------------------------- -First, invoke the ``dpkg -i`` command with the list of all packages:: +Install the Cyrus IMAP package(s), either from your distribution's package +manager, or from a release tarball. - $ sudo dpkg -i \ - cyrus-common_3.0.1-jessie_amd64.deb \ - cyrus-doc_3.0.1-jessie_all.deb \ - cyrus-imapd_3.0.1-jessie_amd64.deb \ - cyrus-pop3d_3.0.1-jessie_amd64.deb \ - cyrus-admin_3.0.1-jessie_all.deb \ - cyrus-murder_3.0.1-jessie_amd64.deb \ - cyrus-replication_3.0.1-jessie_amd64.deb \ - cyrus-nntpd_3.0.1-jessie_amd64.deb \ - cyrus-caldav_3.0.1-jessie_amd64.deb \ - cyrus-clients_3.0.1-jessie_amd64.deb \ - cyrus-dev_3.0.1-jessie_amd64.deb \ - libcyrus-imap-perl_3.0.1-jessie_amd64.deb +Your distribution might have split Cyrus IMAP into several packages. Check +their documentation if you're not sure what you need. -That step will produce an error, as there will doubtless be unmet -dependencies. Not to worry, there's a fix for that... - -2. Use "apt-get install -f" to complete installation ----------------------------------------------------- - -Now invoke ``apt-get install -f`` to pull in the dependencies and -complete the installation:: - - $ sudo apt-get install -f - -The packaging should pull along all necessary support libraries, etc.. - -3. Setup the cyrus:mail user and group +2. Setup the cyrus:mail user and group -------------------------------------- .. include:: /assets/cyrus-user-group.rst -4. Setting up authentication with SASL +3. Setting up authentication with SASL -------------------------------------- .. include:: /assets/setup-sasl-sasldb.rst -5. Setup mail delivery from your MTA +4. Setup mail delivery from your MTA ------------------------------------ Your Cyrus IMAP server will want to receive the emails accepted by your -SMTP server (ie Sendmail, Postfix, etc). In Cyrus, this happens via a -protocol called LMTP, which is usually supported by your SMTP server. - -.. include:: /assets/setup-sendmail.rst +SMTP server (ie Sendmail, Postfix, Exim). See :ref:`Mail delivery from your MTA `. -.. include:: /assets/setup-postfix.rst - -6. Protocol ports +5. Protocol ports ----------------- .. include:: /assets/services.rst -7. Configuring Cyrus +6. Configuring Cyrus -------------------- (Nearly there) @@ -160,68 +128,14 @@ For example:: vi /etc/cyrus.conf ... -8. Launch Cyrus +7. Launch Cyrus --------------- -If using our packages on Debian Jessie, you will have a SystemV -compatible init script installed, with systemd support. Start Cyrus -with the following command:: - - systemctl start cyrus-imapd - -Tada! You should now have a working Cyrus IMAP server. - -Feature overview ----------------- - -The features (compile-time configuration options) supported in our -reference packages are: - -Cyrus Server configured components - * event notification : yes - * gssapi : /usr - * autocreate : yes - * idled : yes - * httpd : yes - * kerberos V4 : no - * murder : yes - * nntpd : yes - * replication : yes - * sieve : yes - * calalarmd : no - * jmap : yes - * objectstore : no - * backup : no - -External dependencies - * ldap : yes - * openssl : yes - * zlib : yes - * pcre : yes - * clamav : yes - * snmp : yes - * caringo : no - * openio : no - * nghttp2 : no - * brotli : no - * xml2 : yes - * ical : yes - * icu4c : no - * shapelib : no - -Database support - * mysql : no - * postgresql : no - * sqlite : yes - -Search engine - * squat : yes - * xapian : yes - * xapian_flavor : vanilla - -Hardware support - * SSE4.2 : yes - -Installation directories - * prefix : /usr - * sysconfdir : /etc +If using a distribution package, you probably now have an init script +installed, that you can invoke with your system's usual service control +mechanism. + +If you built from source, you will need to write your own init script. +The simplest one will simply start/stop the :cyrusman:`master(8)` binary, +with suitable options, as root (master will drop root privileges itself +as soon as it possibly can). diff --git a/docsrc/support.rst b/docsrc/support.rst index 0178505415..13e5360c52 100644 --- a/docsrc/support.rst +++ b/docsrc/support.rst @@ -10,6 +10,5 @@ Support/Community Found a bug? Mailing lists - IRC Weekly meetings About diff --git a/imap/annotate.c b/imap/annotate.c index 85d2fddf3a..528b1f756e 100644 --- a/imap/annotate.c +++ b/imap/annotate.c @@ -79,6 +79,14 @@ #include "xstrlcat.h" #include "tok.h" #include "quota.h" +#include "xunlink.h" + +#ifdef WITH_DAV +#include "caldav_alarm.h" +#include "dav_util.h" + +#define CAL_TZ_ANNOT DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-timezone" +#endif /* generated headers are not necessarily in current directory */ #include "imap/imap_err.h" @@ -88,13 +96,6 @@ #define DEBUG 0 -#define ANNOTATION_SCOPE_UNKNOWN (-1) -enum { - ANNOTATION_SCOPE_SERVER = 1, - ANNOTATION_SCOPE_MAILBOX = 2, - ANNOTATION_SCOPE_MESSAGE = 3 -}; - typedef struct annotate_entrydesc annotate_entrydesc_t; struct annotate_entry_list @@ -190,7 +191,8 @@ enum { ATTRIB_TYPE_STRING, ATTRIB_TYPE_BOOLEAN, ATTRIB_TYPE_UINT, - ATTRIB_TYPE_INT + ATTRIB_TYPE_INT, + ATTRIB_TYPE_DURATION }; #define ATTRIB_NO_FETCH_ACL_CHECK (1<<30) @@ -206,7 +208,9 @@ struct annotate_entrydesc struct annotate_entry_list *entry); /* function to set the entry */ int (*set)(annotate_state_t *state, - struct annotate_entry_list *entry); + struct annotate_entry_list *entry, + int maywrite); + char *freeme; /* entry and name needs to be freed on cleanup */ void *rock; /* rock passed to get() function */ }; @@ -214,7 +218,7 @@ struct annotate_db { annotate_db_t *next; int refcount; - char *mboxname; + char *mboxid; char *filename; struct db *db; struct txn *txn; @@ -242,15 +246,23 @@ static int annotate_state_set_scope(annotate_state_t *state, unsigned int uid); static void init_annotation_definitions(void); static int annotation_set_tofile(annotate_state_t *state, - struct annotate_entry_list *entry); + struct annotate_entry_list *entry, + int maywrite); static int annotation_set_todb(annotate_state_t *state, - struct annotate_entry_list *entry); + struct annotate_entry_list *entry, + int maywrite); static int annotation_set_mailboxopt(annotate_state_t *state, - struct annotate_entry_list *entry); + struct annotate_entry_list *entry, + int maywrite); static int annotation_set_pop3showafter(annotate_state_t *state, - struct annotate_entry_list *entry); + struct annotate_entry_list *entry, + int maywrite); +static int annotation_set_fuzzyalways(annotate_state_t *state, + struct annotate_entry_list *entry, + int maywrite); static int annotation_set_specialuse(annotate_state_t *state, - struct annotate_entry_list *entry); + struct annotate_entry_list *entry, + int maywrite); static int _annotate_rewrite(struct mailbox *oldmailbox, uint32_t olduid, const char *olduserid, @@ -282,23 +294,6 @@ EXPORTED void appendstrlist(struct strlist **l, char *s) *tail = (struct strlist *)xmalloc(sizeof(struct strlist)); (*tail)->s = xstrdup(s); - (*tail)->p = 0; - (*tail)->next = 0; -} - -/* - * Append 's' to the strlist 'l', compiling it as a pattern. - * Caller must pass in memory that is freed when the strlist is freed. - */ -EXPORTED void appendstrlistpat(struct strlist **l, char *s) -{ - struct strlist **tail = l; - - while (*tail) tail = &(*tail)->next; - - *tail = (struct strlist *)xmalloc(sizeof(struct strlist)); - (*tail)->s = s; - (*tail)->p = charset_compilepat(s); (*tail)->next = 0; } @@ -312,7 +307,6 @@ EXPORTED void freestrlist(struct strlist *l) while (l) { n = l->next; free(l->s); - if (l->p) charset_freepat(l->p); free((char *)l); l = n; } @@ -406,6 +400,29 @@ EXPORTED void setentryatt(struct entryattlist **l, const char *entry, } } +EXPORTED char *dumpentryatt(const struct entryattlist *l) +{ + struct buf buf = BUF_INITIALIZER; + + const struct entryattlist *ee; + buf_printf(&buf, "("); + const char *sp = ""; + const struct attvaluelist *av; + for (ee = l ; ee ; ee = ee->next) { + buf_printf(&buf, "%s%s (", sp, ee->entry); + const char *insp = ""; + for (av = ee->attvalues ; av ; av = av->next) { + buf_printf(&buf, "%s%s %s", insp, av->attrib, buf_cstring(&av->value)); + insp = " "; + } + buf_printf(&buf, ")"); + sp = " "; + } + buf_printf(&buf, ")"); + + return buf_release(&buf); +} + EXPORTED void clearentryatt(struct entryattlist **l, const char *entry, const char *attrib) { @@ -574,7 +591,7 @@ static int annotate_dbname_mbentry(const mbentry_t *mbentry, return 0; } -static int annotate_dbname_mailbox(struct mailbox *mailbox, char **fnamep) +static int annotate_dbname_mailbox(const struct mailbox *mailbox, char **fnamep) { const char *conf_fname; @@ -588,13 +605,13 @@ static int annotate_dbname_mailbox(struct mailbox *mailbox, char **fnamep) } -static int annotate_dbname(const char *mboxname, char **fnamep) +static int annotate_dbname(const char *mboxid, char **fnamep) { int r = 0; mbentry_t *mbentry = NULL; - if (mboxname) { - r = mboxlist_lookup(mboxname, &mbentry, NULL); + if (mboxid) { + r = mboxlist_lookup_by_uniqueid(mboxid, &mbentry, NULL); if (r) goto out; } @@ -605,7 +622,8 @@ static int annotate_dbname(const char *mboxname, char **fnamep) return r; } -static int _annotate_getdb(const char *mboxname, +static int _annotate_getdb(const char *mboxid, + const struct mailbox *mailbox, unsigned int uid, int dbflags, annotate_db_t **dbp) @@ -618,18 +636,18 @@ static int _annotate_getdb(const char *mboxname, *dbp = NULL; /* - * The incoming (mboxname,uid) tuple tells us which scope we + * The incoming (mboxid,uid) tuple tells us which scope we * need a database for. Translate into the mboxname used to * key annotate_db_t's, which is slightly different: message * scope goes into a per-mailbox db, others in the global db. */ - if (!strcmpsafe(mboxname, NULL) /*server scope*/ || + if (!strcmpsafe(mboxid, NULL) /*server scope*/ || !uid /* mailbox scope*/) - mboxname = NULL; + mboxid = NULL; /* try to find an existing db for the mbox */ for (d = all_dbs_head ; d ; prev = d, d = d->next) { - if (!strcmpsafe(mboxname, d->mboxname)) { + if (!strcmpsafe(mboxid, d->mboxid)) { /* found it, bump the refcount */ d->refcount++; *dbp = d; @@ -648,11 +666,15 @@ static int _annotate_getdb(const char *mboxname, } /* not found, open/create a new one */ - r = annotate_dbname(mboxname, &fname); + if (mailbox) + r = annotate_dbname_mailbox(mailbox, &fname); + else + r = annotate_dbname(mboxid, &fname); + if (r) goto error; #if DEBUG - syslog(LOG_ERR, "Opening annotations db %s\n", fname); + syslog(LOG_ERR, "Opening annotations db %s", fname); #endif r = cyrusdb_open(DB, fname, dbflags | CYRUSDB_CONVERT, &db); @@ -667,7 +689,7 @@ static int _annotate_getdb(const char *mboxname, /* record all the above */ d = xzmalloc(sizeof(*d)); d->refcount = 1; - d->mboxname = xstrdupnull(mboxname); + d->mboxid = xstrdupnull(mboxid); d->filename = fname; d->db = db; @@ -682,14 +704,15 @@ static int _annotate_getdb(const char *mboxname, return r; } -HIDDEN int annotate_getdb(const char *mboxname, annotate_db_t **dbp) +HIDDEN int annotate_getdb(const struct mailbox *mailbox, annotate_db_t **dbp) { - if (!mboxname || !*mboxname) { - syslog(LOG_ERR, "IOERROR: annotate_getdb called with no mboxname"); + if (!mailbox) { + syslog(LOG_ERR, "IOERROR: annotate_getdb called with no mailbox"); return IMAP_INTERNAL; /* we don't return the global db */ } - /* synthetic UID '1' forces per-mailbox mode */ - return _annotate_getdb(mboxname, 1, CYRUSDB_CREATE, dbp); + /* ANNOTATE_ANY_UID forces UID mode */ + return _annotate_getdb(mailbox_uniqueid(mailbox), mailbox, ANNOTATE_ANY_UID, + CYRUSDB_CREATE, dbp); } static void annotate_closedb(annotate_db_t *d) @@ -705,7 +728,7 @@ static void annotate_closedb(annotate_db_t *d) detach_db(prev, d); #if DEBUG - syslog(LOG_ERR, "Closing annotations db %s\n", d->filename); + syslog(LOG_ERR, "Closing annotations db %s", d->filename); #endif r = cyrusdb_close(d->db); @@ -714,7 +737,7 @@ static void annotate_closedb(annotate_db_t *d) d->filename, cyrusdb_strerror(r)); free(d->filename); - free(d->mboxname); + free(d->mboxid); memset(d, 0, sizeof(*d)); /* JIC */ free(d); } @@ -746,7 +769,7 @@ EXPORTED void annotatemore_open(void) annotate_db_t *d = NULL; /* force opening the global annotations db */ - r = _annotate_getdb(NULL, 0, CYRUSDB_CREATE, &d); + r = _annotate_getdb(NULL, NULL, 0, CYRUSDB_CREATE, &d); if (r) fatal("can't open global annotations database", EX_TEMPFAIL); @@ -777,7 +800,7 @@ static void annotate_abort(annotate_db_t *d) if (d->txn) { #if DEBUG - syslog(LOG_ERR, "Aborting annotations db %s\n", d->filename); + syslog(LOG_ERR, "Aborting annotations db %s", d->filename); #endif cyrusdb_abort(d->db, d->txn); } @@ -794,7 +817,7 @@ static int annotate_commit(annotate_db_t *d) if (d->txn) { #if DEBUG - syslog(LOG_ERR, "Committing annotations db %s\n", d->filename); + syslog(LOG_ERR, "Committing annotations db %s", d->filename); #endif r = cyrusdb_commit(d->db, d->txn); if (r) @@ -808,37 +831,73 @@ static int annotate_commit(annotate_db_t *d) EXPORTED void annotate_done(void) { + int i; + /* DB->done() handled by cyrus_done() */ if (annotatemore_dbopen) { annotatemore_close(); } + + for (i = 0; i < ptrarray_size(&message_entries); i++) { + annotate_entrydesc_t *ae = ptrarray_nth(&message_entries, i); + if (ae->freeme) { + free(ae->freeme); + free(ae); + } + } + ptrarray_fini(&message_entries); + + for (i = 0; i < ptrarray_size(&mailbox_entries); i++) { + annotate_entrydesc_t *ae = ptrarray_nth(&mailbox_entries, i); + if (ae->freeme) { + free(ae->freeme); + free(ae); + } + } + ptrarray_fini(&mailbox_entries); + + for (i = 0; i < ptrarray_size(&server_entries); i++) { + annotate_entrydesc_t *ae = ptrarray_nth(&server_entries, i); + if (ae->freeme) { + free(ae->freeme); + free(ae); + } + } + ptrarray_fini(&server_entries); + annotate_initialized = 0; } +#define OWNER_USERID_TOKEN "[.OwNeR.]" + static int make_key(const char *mboxname, + const char *mboxid, unsigned int uid, const char *entry, const char *userid, char *key, size_t keysize) { - int keylen = 0; + int keylen; if (!uid) { - strlcpy(key+keylen, mboxname, keysize-keylen); - keylen += strlen(mboxname) + 1; + strlcpy(key, mboxid, keysize); } else if (uid == ANNOTATE_ANY_UID) { - strlcpy(key+keylen, "*", keysize-keylen); - keylen += strlen(key+keylen) + 1; + strlcpy(key, "*", keysize); } else { - snprintf(key+keylen, keysize-keylen, "%u", uid); - keylen += strlen(key+keylen) + 1; + snprintf(key, keysize, "%u", uid); } + keylen = strlen(key) + 1; strlcpy(key+keylen, entry, keysize-keylen); keylen += strlen(entry); /* if we don't have a userid, we're doing a foreach() */ if (userid) { + if (userid[0] && mboxname && + mboxname_userownsmailbox(userid, mboxname)) { + /* replace userid of owner with a fixed token */ + userid = OWNER_USERID_TOKEN; + } keylen++; strlcpy(key+keylen, userid, keysize-keylen); keylen += strlen(userid) + 1; @@ -849,7 +908,7 @@ static int make_key(const char *mboxname, static int split_key(const annotate_db_t *d, const char *key, int keysize, - const char **mboxnamep, + const char **mboxidp, unsigned int *uidp, const char **entryp, const char **useridp) @@ -870,17 +929,17 @@ static int split_key(const annotate_db_t *d, * not handle embedded NULs. */ - if (d->mboxname) { - *mboxnamep = d->mboxname; + if (d->mboxid) { + *mboxidp = d->mboxid; *uidp = 0; while (*p && p < end) *uidp = (10 * (*uidp)) + (*p++ - '0'); if (p < end) p++; else return IMAP_ANNOTATION_BADENTRY; } else { - /* global db for mailnbox & server scope annotations */ + /* global db for mailbox & server scope annotations */ *uidp = 0; - *mboxnamep = p; + *mboxidp = p; while (*p && p < end) p++; if (p < end) p++; else return IMAP_ANNOTATION_BADENTRY; @@ -899,18 +958,18 @@ static int split_key(const annotate_db_t *d, static const char *key_as_string(const annotate_db_t *d, const char *key, int keylen) { - const char *mboxname, *entry, *userid; + const char *mboxid, *entry, *userid; unsigned int uid; int r; static struct buf buf = BUF_INITIALIZER; buf_reset(&buf); - r = split_key(d, key, keylen, &mboxname, &uid, &entry, &userid); + r = split_key(d, key, keylen, &mboxid, &uid, &entry, &userid); if (r) buf_appendcstr(&buf, "invalid"); else - buf_printf(&buf, "{ mboxname=\"%s\" uid=%u entry=\"%s\" userid=\"%s\" }", - mboxname, uid, entry, userid); + buf_printf(&buf, "{ mboxid=\"%s\" uid=%u entry=\"%s\" userid=\"%s\" }", + mboxid, uid, entry, userid); return buf_cstring(&buf); } #endif @@ -952,8 +1011,11 @@ static int split_attribs(const char *data, int datalen, } if (tmps < end) { - mdata->modseq = ntohll(*((unsigned long long *)tmps)); - tmps += sizeof(unsigned long long); + /* make sure ntohll's input is correctly aligned */ + modseq_t modseq; + memcpy(&modseq, tmps, sizeof(modseq)); + mdata->modseq = ntohll(modseq); + tmps += sizeof(modseq_t); } if (tmps < end) { @@ -970,7 +1032,10 @@ static int split_attribs(const char *data, int datalen, } struct find_rock { - struct glob *mglob; + const char *pattern; + const struct mailbox *mailbox; + const mbentry_t *mbentry; + const char *entry; struct glob *eglob; unsigned int uid; modseq_t since_modseq; @@ -985,21 +1050,22 @@ static int find_p(void *rock, const char *key, size_t keylen, size_t datalen __attribute__((unused))) { struct find_rock *frock = (struct find_rock *) rock; - const char *mboxname, *entry, *userid; + const char *mboxid, *entry, *userid; unsigned int uid; int r; - r = split_key(frock->d, key, keylen, &mboxname, + r = split_key(frock->d, key, keylen, &mboxid, &uid, &entry, &userid); if (r < 0) return 0; + if (!userid) + return 0; + if (frock->uid && frock->uid != ANNOTATE_ANY_UID && frock->uid != uid) return 0; - if (!GLOB_MATCH(frock->mglob, mboxname)) - return 0; if (!GLOB_MATCH(frock->eglob, entry)) return 0; return 1; @@ -1009,7 +1075,7 @@ static int find_cb(void *rock, const char *key, size_t keylen, const char *data, size_t datalen) { struct find_rock *frock = (struct find_rock *) rock; - const char *mboxname, *entry, *userid; + const char *mboxid, *entry, *userid; unsigned int uid; char newkey[MAX_MAILBOX_PATH+1]; size_t newkeylen; @@ -1019,16 +1085,17 @@ static int find_cb(void *rock, const char *key, size_t keylen, assert(keylen < MAX_MAILBOX_PATH); - r = split_key(frock->d, key, keylen, &mboxname, + r = split_key(frock->d, key, keylen, &mboxid, &uid, &entry, &userid); if (r) { syslog(LOG_ERR, "find_cb: can't split bogus key %*.s", (int)keylen, key); return r; } - newkeylen = make_key(mboxname, uid, entry, userid, newkey, sizeof(newkey)); + newkeylen = make_key(NULL, mboxid, uid, entry, userid, newkey, sizeof(newkey)); if (keylen != newkeylen || strncmp(newkey, key, keylen)) { - syslog(LOG_ERR, "find_cb: bogus key %s %d %s %s (%d %d)", mboxname, uid, entry, userid, (int)keylen, (int)newkeylen); + syslog(LOG_ERR, "find_cb: bogus key %s %d %s %s (%d %d)", + mboxid, uid, entry, userid, (int)keylen, (int)newkeylen); } r = split_attribs(data, datalen, &value, &mdata); @@ -1060,13 +1127,56 @@ static int find_cb(void *rock, const char *key, size_t keylen, return 0; } - if (!r) r = frock->proc(mboxname, uid, entry, userid, &value, &mdata, - frock->rock); + if (!r) { + const char *mboxname = frock->mbentry ? frock->mbentry->name : ""; + char *owner = NULL; + + if (!strcmp(userid, OWNER_USERID_TOKEN)) { + /* construct actual userid from mboxname */ + userid = owner = mboxname_to_userid(mboxname); + } + + r = frock->proc(mboxname, uid, entry, userid, &value, &mdata, + frock->rock); + free(owner); + } + buf_free(&value); return r; } -EXPORTED int annotatemore_findall(const char *mboxname, /* internal */ +static int _findall(struct findall_data *data, void *rock) +{ + struct find_rock *frock = (struct find_rock *) rock; + const char *mboxname = "", *mboxid = ""; + char key[MAX_MAILBOX_PATH+1], *p; + size_t keylen; + + if (!data || !data->is_exactmatch) return 0; + + if (data->mbentry) { + mboxname = data->mbentry->name; + mboxid = data->mbentry->uniqueid; + } + + /* Find fixed-string pattern prefix */ + keylen = make_key(mboxname, mboxid, frock->uid, + frock->entry, NULL, key, sizeof(key)); + + for (p = key; keylen; p++, keylen--) { + if (*p == '*' || *p == '%') break; + } + keylen = p - key; + + frock->mbentry = data->mbentry; + + return cyrusdb_foreach(frock->d->db, key, keylen, + &find_p, &find_cb, frock, tid(frock->d)); +} + +static int annotatemore_findall_full(const char *pattern, /* internal */ + const struct mailbox *mailbox, + const mbentry_t *mbentry, unsigned int uid, const char *entry, modseq_t since_modseq, @@ -1074,48 +1184,104 @@ EXPORTED int annotatemore_findall(const char *mboxname, /* internal */ void *rock, int flags) { - char key[MAX_MAILBOX_PATH+1], *p; - size_t keylen; int r; struct find_rock frock; init_internal(); - assert(mboxname); assert(entry); - frock.mglob = glob_init(mboxname, '.'); + + frock.pattern = pattern; + frock.mailbox = mailbox; + frock.entry = entry; frock.eglob = glob_init(entry, '/'); + frock.d = NULL; frock.uid = uid; frock.proc = proc; frock.rock = rock; frock.since_modseq = since_modseq; frock.flags = flags; - r = _annotate_getdb(mboxname, uid, 0, &frock.d); + + r = _annotate_getdb(mbentry ? mbentry->uniqueid : NULL, mailbox, uid, 0, &frock.d); if (r) { if (r == CYRUSDB_NOTFOUND) r = 0; goto out; } - /* Find fixed-string pattern prefix */ - keylen = make_key(mboxname, uid, - entry, NULL, key, sizeof(key)); - - for (p = key; keylen; p++, keylen--) { - if (*p == '*' || *p == '%') break; + if (mbentry) { + struct findall_data data = { .mbentry = mbentry, .is_exactmatch = 1 }; + r = _findall(&data, &frock); + } + else if (!pattern || !*pattern) { + /* Server entries */ + struct findall_data data = { .mbentry = NULL, .is_exactmatch = 1 }; + r = _findall(&data, &frock); + } + else { + /* Mailbox pattern */ + r = mboxlist_findall(NULL, pattern, 1, NULL, NULL, &_findall, &frock); } - keylen = p - key; - - r = cyrusdb_foreach(frock.d->db, key, keylen, &find_p, &find_cb, - &frock, tid(frock.d)); out: - glob_free(&frock.mglob); glob_free(&frock.eglob); annotate_putdb(&frock.d); return r; } + +EXPORTED int annotatemore_findall_mailbox(const struct mailbox *mailbox, + unsigned int uid, + const char *entry, + modseq_t since_modseq, + annotatemore_find_proc_t proc, + void *rock, + int flags) +{ + const mbentry_t *mbentry = mailbox_mbentry(mailbox); + + return annotatemore_findall_full(NULL, mailbox, mbentry, + uid, entry, since_modseq, proc, rock, flags); +} + +EXPORTED int annotatemore_findall_pattern(const char *pattern, + unsigned int uid, + const char *entry, + modseq_t since_modseq, + annotatemore_find_proc_t proc, + void *rock, + int flags) +{ + return annotatemore_findall_full(pattern, NULL, NULL, + uid, entry, since_modseq, proc, rock, flags); +} + + +EXPORTED int annotatemore_findall_mboxname(const char *mboxname, + unsigned int uid, + const char *entry, + modseq_t since_modseq, + annotatemore_find_proc_t proc, + void *rock, + int flags) +{ + mbentry_t *mbentry = NULL; + int r; + + /* special case where mboxname is "" means server annotations + * which will be signaled by a NULL mbentry */ + if (*mboxname) { + r = mboxlist_lookup_allow_all(mboxname, &mbentry, NULL); + if (r) return r; + } + + r = annotatemore_findall_full(NULL, NULL, mbentry, + uid, entry, since_modseq, proc, rock, flags); + mboxlist_entry_free(&mbentry); + + return r; +} + /*************************** Annotate State Management ***************************/ EXPORTED annotate_state_t *annotate_state_new(void) @@ -1190,6 +1356,10 @@ EXPORTED int annotate_state_commit(annotate_state_t **statep) return r; } +EXPORTED int annotate_state_scope(annotate_state_t *state) +{ + return state ? state->which : ANNOTATION_SCOPE_UNKNOWN; +} static struct annotate_entry_list * _annotate_state_add_entry(annotate_state_t *state, @@ -1312,7 +1482,7 @@ static int annotate_state_set_scope(annotate_state_t *state, state->mailbox = mailbox; state->uid = uid; - r = _annotate_getdb(mailbox ? mailbox->name : NULL, uid, + r = _annotate_getdb(mailbox ? mailbox_uniqueid(mailbox) : NULL, mailbox, uid, CYRUSDB_CREATE, &state->d); out: @@ -1325,10 +1495,10 @@ static int annotate_state_need_mbentry(annotate_state_t *state) int r = 0; if (!state->mbentry && state->mailbox) { - r = mboxlist_lookup(state->mailbox->name, &state->ourmbentry, NULL); + r = mboxlist_lookup(mailbox_name(state->mailbox), &state->ourmbentry, NULL); if (r) { syslog(LOG_ERR, "Failed to lookup mbentry for %s: %s", - state->mailbox->name, error_message(r)); + mailbox_name(state->mailbox), error_message(r)); goto out; } state->mbentry = state->ourmbentry; @@ -1368,8 +1538,6 @@ static void output_entryatt(annotate_state_t *state, const char *entry, char key[MAX_MAILBOX_PATH+1]; /* XXX MAX_MAILBOX_NAME + entry + userid */ struct buf buf = BUF_INITIALIZER; - if (!userid) userid = ""; - /* We don't put any funny interpretations on NULL values for * some of these anymore, now that the dirty hacks are gone. */ assert(state); @@ -1378,7 +1546,7 @@ static void output_entryatt(annotate_state_t *state, const char *entry, assert(value); if (state->mailbox) - mboxname = state->mailbox->name; + mboxname = mailbox_name(state->mailbox); else if (state->mbentry) mboxname = state->mbentry->name; else @@ -1456,7 +1624,7 @@ static int _annotate_may_fetch(annotate_state_t *state, return 1; if (state->which == ANNOTATION_SCOPE_SERVER) { - /* RFC5464 doesn't mention access control for server + /* RFC 5464 doesn't mention access control for server * annotations, but this seems a sensible practice and is * consistent with past Cyrus behaviour */ return 1; @@ -1464,21 +1632,21 @@ static int _annotate_may_fetch(annotate_state_t *state, else if (state->which == ANNOTATION_SCOPE_MAILBOX) { assert(state->mailbox || state->mbentry); - /* Make sure its a local mailbox annotation */ + /* Make sure it is a local mailbox annotation */ if (state->mbentry && state->mbentry->server) return 0; - if (state->mailbox) acl = state->mailbox->acl; + if (state->mailbox) acl = mailbox_acl(state->mailbox); else if (state->mbentry) acl = state->mbentry->acl; - /* RFC5464 is a trifle vague about access control for mailbox + /* RFC 5464 is a trifle vague about access control for mailbox * annotations but this seems to be compliant */ needed = ACL_LOOKUP|ACL_READ; /* fall through to ACL check */ } else if (state->which == ANNOTATION_SCOPE_MESSAGE) { assert(state->mailbox); - acl = state->mailbox->acl; - /* RFC5257: reading from a private annotation needs 'r'. + acl = mailbox_acl(state->mailbox); + /* RFC 5257: reading from a private annotation needs 'r'. * Reading from a shared annotation needs 'r' */ needed = ACL_READ; /* fall through to ACL check */ @@ -1561,7 +1729,7 @@ static void annotation_get_server(annotate_state_t *state, r = annotate_state_need_mbentry(state); assert(r == 0); - /* Make sure its a remote mailbox */ + /* Make sure it is a remote mailbox */ if (!state->mbentry->server) goto out; /* Check ACL */ @@ -1591,7 +1759,7 @@ static void annotation_get_partition(annotate_state_t *state, r = annotate_state_need_mbentry(state); assert(r == 0); - /* Make sure its a local mailbox */ + /* Make sure it is a local mailbox */ if (state->mbentry->server) goto out; /* Check ACL */ @@ -1728,6 +1896,21 @@ static void annotation_get_synccrcs(annotate_state_t *state, buf_free(&value); } +static void annotation_get_foldermodseq(annotate_state_t *state, + struct annotate_entry_list *entry) +{ + struct buf value = BUF_INITIALIZER; + + assert(state); + annotate_state_need_mbentry(state); + assert(state->mbentry); + + buf_printf(&value, "%llu", state->mbentry->foldermodseq); + output_entryatt(state, entry->name, "", &value); + + buf_free(&value); +} + static void annotation_get_usermodseq(annotate_state_t *state, struct annotate_entry_list *entry) { @@ -1777,6 +1960,43 @@ static void annotation_get_usercounters(annotate_state_t *state, buf_free(&value); } +static void annotation_get_userrawquota(annotate_state_t *state, + struct annotate_entry_list *entry) +{ + struct buf value = BUF_INITIALIZER; + const char *sep = ""; + int r = 0; + struct quota q; + int res; + + assert(state); + assert(state->mailbox); + + quota_init(&q, mailbox_name(state->mailbox)); + // read without conversations here, to get the raw quota + r = quota_read(&q, NULL, 0); + + // logic duplicated from imapd.c:print_quota_used + if (!r) { + buf_putc(&value, '('); + for (res = 0 ; res < QUOTA_NUMRESOURCES ; res++) { + if (q.limits[res] >= 0) { + buf_printf(&value, "%s%s " QUOTA_T_FMT " " QUOTA_T_FMT, + sep, quota_names[res], + q.useds[res]/quota_units[res], + q.limits[res]); + sep = " "; + } + } + buf_putc(&value, ')'); + } + + output_entryatt(state, entry->name, "", &value); + + quota_free(&q); + buf_free(&value); +} + static void annotation_get_uniqueid(annotate_state_t *state, struct annotate_entry_list *entry) { @@ -1784,8 +2004,8 @@ static void annotation_get_uniqueid(annotate_state_t *state, assert(state->mailbox); - if (state->mailbox->uniqueid) - buf_appendcstr(&value, state->mailbox->uniqueid); + if (mailbox_uniqueid(state->mailbox)) + buf_appendcstr(&value, mailbox_uniqueid(state->mailbox)); output_entryatt(state, entry->name, "", &value); buf_free(&value); @@ -1800,8 +2020,6 @@ static int rw_cb(const char *mailbox __attribute__((unused)), { annotate_state_t *state = (annotate_state_t *)rock; - if (!userid) userid = ""; - if (!userid[0] || !strcmp(userid, state->userid)) { output_entryatt(state, entry, userid, value); } @@ -1812,10 +2030,14 @@ static int rw_cb(const char *mailbox __attribute__((unused)), static void annotation_get_fromdb(annotate_state_t *state, struct annotate_entry_list *entry) { - const char *mboxname = (state->mailbox ? state->mailbox->name : ""); state->found = 0; - annotatemore_findall(mboxname, state->uid, entry->name, 0, &rw_cb, state, 0); + // if mailbox present, will be a mailbox fetch, otherwise will be a server fetch + // (emtpy mboxname) + if (state->mailbox) + annotatemore_findall_mailbox(state->mailbox, state->uid, entry->name, 0, &rw_cb, state, 0); + else + annotatemore_findall_mboxname("", state->uid, entry->name, 0, &rw_cb, state, 0); if (state->found != state->attribs && (!strchr(entry->name, '%') && !strchr(entry->name, '*'))) { @@ -1841,7 +2063,7 @@ static void annotation_get_fromdb(annotate_state_t *state, static const annotate_entrydesc_t message_builtin_entries[] = { { - /* RFC5257 defines /altsubject with both .shared & .priv */ + /* RFC 5257 defines /altsubject with both .shared & .priv */ "/altsubject", ATTRIB_TYPE_STRING, BACKEND_ONLY, @@ -1849,9 +2071,11 @@ static const annotate_entrydesc_t message_builtin_entries[] = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL - },{ - /* RFC5257 defines /comment with both .shared & .priv */ + }, + { + /* RFC 5257 defines /comment with both .shared & .priv */ "/comment", ATTRIB_TYPE_STRING, BACKEND_ONLY, @@ -1859,8 +2083,81 @@ static const annotate_entrydesc_t message_builtin_entries[] = 0, annotation_get_fromdb, annotation_set_todb, + NULL, + NULL + }, + { + /* we use 'basethrid' to support split threads */ + IMAP_ANNOT_NS "basethrid", + ATTRIB_TYPE_STRING, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED | ATTRIB_VALUE_PRIV, + 0, + annotation_get_fromdb, + annotation_set_todb, + NULL, + NULL + }, + { + /* prior to version 12, there was no storage for thrid, so it became an annotation */ + IMAP_ANNOT_NS "thrid", + ATTRIB_TYPE_STRING, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED | ATTRIB_VALUE_PRIV, + 0, + annotation_get_fromdb, + annotation_set_todb, + NULL, + NULL + }, + { + /* prior to version 15, there was no storage for savedate, so it became an annotation */ + IMAP_ANNOT_NS "savedate", + ATTRIB_TYPE_STRING, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED | ATTRIB_VALUE_PRIV, + 0, + annotation_get_fromdb, + annotation_set_todb, + NULL, + NULL + }, + { + /* prior to version 16, there was no storage for createdmodseq, so it became an annotation */ + IMAP_ANNOT_NS "createdmodseq", + ATTRIB_TYPE_STRING, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED | ATTRIB_VALUE_PRIV, + 0, + annotation_get_fromdb, + annotation_set_todb, + NULL, + NULL + }, + { + /* Deprecated in favor of "snoozed" */ + IMAP_ANNOT_NS "snoozed-until", + ATTRIB_TYPE_STRING, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + 0, + annotation_get_fromdb, + annotation_set_todb, + NULL, + NULL + }, + { + IMAP_ANNOT_NS "snoozed", + ATTRIB_TYPE_STRING, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + 0, + annotation_get_fromdb, + annotation_set_todb, + NULL, NULL - },{ NULL, 0, ANNOTATION_PROXY_T_INVALID, 0, 0, NULL, NULL, NULL } + }, + { NULL, 0, ANNOTATION_PROXY_T_INVALID, 0, 0, NULL, NULL, NULL, NULL } }; static const annotate_entrydesc_t message_db_entry = @@ -1872,6 +2169,7 @@ static const annotate_entrydesc_t message_db_entry = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL }; @@ -1891,6 +2189,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ /* @@ -1906,9 +2205,10 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ - /* RFC5464 defines /shared/comment and /private/comment */ + /* RFC 5464 defines /shared/comment and /private/comment */ "/comment", ATTRIB_TYPE_STRING, BACKEND_ONLY, @@ -1916,6 +2216,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ /* @@ -1931,10 +2232,11 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ /* - * RFC6154 defines /private/specialuse. + * RFC 6154 defines /private/specialuse. */ "/specialuse", ATTRIB_TYPE_STRING, @@ -1943,6 +2245,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_fromdb, annotation_set_specialuse, + NULL, NULL },{ /* @@ -1958,6 +2261,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ IMAP_ANNOT_NS "annotsize", @@ -1967,24 +2271,27 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_annotsize, /*set*/NULL, + NULL, NULL },{ IMAP_ANNOT_NS "archive", - ATTRIB_TYPE_UINT, + ATTRIB_TYPE_DURATION, BACKEND_ONLY, ATTRIB_VALUE_SHARED, ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ IMAP_ANNOT_NS "delete", - ATTRIB_TYPE_UINT, + ATTRIB_TYPE_DURATION, BACKEND_ONLY, ATTRIB_VALUE_SHARED, ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ IMAP_ANNOT_NS "duplicatedeliver", @@ -1994,15 +2301,17 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_mailboxopt, annotation_set_mailboxopt, + NULL, (void *)OPT_IMAP_DUPDELIVER },{ IMAP_ANNOT_NS "expire", - ATTRIB_TYPE_UINT, + ATTRIB_TYPE_DURATION, BACKEND_ONLY, ATTRIB_VALUE_SHARED, ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ IMAP_ANNOT_NS "lastpop", @@ -2012,6 +2321,27 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_lastpop, /*set*/NULL, + NULL, + NULL + },{ + IMAP_ANNOT_NS "hasalarms", + ATTRIB_TYPE_BOOLEAN, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + 0, + annotation_get_mailboxopt, + /*set*/NULL, + NULL, + (void *)OPT_IMAP_HAS_ALARMS + },{ + IMAP_ANNOT_NS "foldermodseq", + ATTRIB_TYPE_UINT, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + 0, + annotation_get_foldermodseq, + /*set*/NULL, + NULL, NULL },{ IMAP_ANNOT_NS "lastupdate", @@ -2021,6 +2351,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_lastupdate, /*set*/NULL, + NULL, NULL },{ IMAP_ANNOT_NS "news2mail", @@ -2030,16 +2361,28 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ - IMAP_ANNOT_NS "partition", - /* _get_partition does its own access control check */ - ATTRIB_TYPE_STRING | ATTRIB_NO_FETCH_ACL_CHECK, + IMAP_ANNOT_NS "noexpire_until", + ATTRIB_TYPE_UINT, BACKEND_ONLY, ATTRIB_VALUE_SHARED, - 0, + ACL_ADMIN, + annotation_get_fromdb, + annotation_set_todb, + NULL, + NULL + },{ + IMAP_ANNOT_NS "partition", + /* _get_partition does its own access control check */ + ATTRIB_TYPE_STRING | ATTRIB_NO_FETCH_ACL_CHECK, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + 0, annotation_get_partition, /*set*/NULL, + NULL, NULL },{ IMAP_ANNOT_NS "pop3newuidl", @@ -2049,6 +2392,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_mailboxopt, annotation_set_mailboxopt, + NULL, (void *)OPT_POP3_NEW_UIDL },{ IMAP_ANNOT_NS "pop3showafter", @@ -2058,6 +2402,29 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_pop3showafter, annotation_set_pop3showafter, + NULL, + NULL + },{ + /* The "userrawquota" was added when conversations quota was added, + * to allow fetching a user's raw quota values */ + IMAP_ANNOT_NS "userrawquota", + ATTRIB_TYPE_STRING, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + 0, + annotation_get_userrawquota, + /*set*/NULL, + NULL, + NULL + },{ + IMAP_ANNOT_NS "search-fuzzy-always", + ATTRIB_TYPE_STRING, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + 0, + annotation_get_fromdb, + annotation_set_fuzzyalways, + NULL, NULL },{ IMAP_ANNOT_NS "server", @@ -2068,6 +2435,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_server, /*set*/NULL, + NULL, NULL },{ IMAP_ANNOT_NS "sharedseen", @@ -2077,6 +2445,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_mailboxopt, annotation_set_mailboxopt, + NULL, (void *)OPT_IMAP_SHAREDSEEN },{ IMAP_ANNOT_NS "sieve", @@ -2086,6 +2455,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ IMAP_ANNOT_NS "size", @@ -2095,6 +2465,17 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_size, /*set*/NULL, + NULL, + NULL + },{ + IMAP_ANNOT_NS "sortorder", + ATTRIB_TYPE_UINT, + BACKEND_ONLY, + ATTRIB_VALUE_PRIV, + 0, + annotation_get_fromdb, + annotation_set_todb, + NULL, NULL },{ IMAP_ANNOT_NS "squat", @@ -2104,6 +2485,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ IMAP_ANNOT_NS "synccrcs", @@ -2114,6 +2496,7 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = annotation_get_synccrcs, NULL, NULL, + NULL, },{ IMAP_ANNOT_NS "uniqueid", ATTRIB_TYPE_STRING, @@ -2122,8 +2505,9 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = 0, annotation_get_uniqueid, NULL, + NULL, NULL - },{ NULL, 0, ANNOTATION_PROXY_T_INVALID, 0, 0, NULL, NULL, NULL } + },{ NULL, 0, ANNOTATION_PROXY_T_INVALID, 0, 0, NULL, NULL, NULL, NULL } }; static const annotate_entrydesc_t mailbox_db_entry = @@ -2135,13 +2519,14 @@ static const annotate_entrydesc_t mailbox_db_entry = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL }; static const annotate_entrydesc_t server_builtin_entries[] = { { - /* RFC5464 defines /shared/admin. */ + /* RFC 5464 defines /shared/admin. */ "/admin", ATTRIB_TYPE_STRING, PROXY_AND_BACKEND, @@ -2149,9 +2534,10 @@ static const annotate_entrydesc_t server_builtin_entries[] = ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ - /* RFC5464 defines /shared/comment. */ + /* RFC 5464 defines /shared/comment. */ "/comment", ATTRIB_TYPE_STRING, PROXY_AND_BACKEND, @@ -2159,6 +2545,7 @@ static const annotate_entrydesc_t server_builtin_entries[] = ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ /* @@ -2174,6 +2561,7 @@ static const annotate_entrydesc_t server_builtin_entries[] = 0, annotation_get_fromfile, annotation_set_tofile, + NULL, (void *)"motd" },{ /* The "usemodseq" was added with conversations support, to allow @@ -2185,26 +2573,29 @@ static const annotate_entrydesc_t server_builtin_entries[] = 0, annotation_get_usermodseq, /*set*/NULL, + NULL, NULL },{ /* The "usemodseq" was added with conversations support, to allow * a single value to show any changes to anything about a user */ IMAP_ANNOT_NS "usercounters", - ATTRIB_TYPE_UINT, + ATTRIB_TYPE_STRING, BACKEND_ONLY, ATTRIB_VALUE_PRIV, 0, annotation_get_usercounters, /*set*/NULL, + NULL, NULL },{ IMAP_ANNOT_NS "expire", - ATTRIB_TYPE_UINT, + ATTRIB_TYPE_DURATION, PROXY_AND_BACKEND, ATTRIB_VALUE_SHARED, ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ IMAP_ANNOT_NS "freespace", @@ -2214,6 +2605,7 @@ static const annotate_entrydesc_t server_builtin_entries[] = 0, annotation_get_freespace, /*set*/NULL, + NULL, NULL },{ IMAP_ANNOT_NS "freespace/total", @@ -2223,6 +2615,7 @@ static const annotate_entrydesc_t server_builtin_entries[] = 0, annotation_get_freespace_total, /*set*/NULL, + NULL, NULL },{ IMAP_ANNOT_NS "freespace/percent/most", @@ -2232,7 +2625,8 @@ static const annotate_entrydesc_t server_builtin_entries[] = 0, annotation_get_freespace_percent_most, /*set*/NULL, - NULL + NULL, + NULL },{ IMAP_ANNOT_NS "shutdown", ATTRIB_TYPE_STRING, @@ -2241,6 +2635,7 @@ static const annotate_entrydesc_t server_builtin_entries[] = 0, annotation_get_fromfile, annotation_set_tofile, + NULL, (void *)"shutdown" },{ IMAP_ANNOT_NS "squat", @@ -2250,9 +2645,10 @@ static const annotate_entrydesc_t server_builtin_entries[] = ACL_ADMIN, annotation_get_fromdb, annotation_set_todb, + NULL, NULL },{ NULL, 0, ANNOTATION_PROXY_T_INVALID, - 0, 0, NULL, NULL, NULL } + 0, 0, NULL, NULL, NULL, NULL } }; static const annotate_entrydesc_t server_db_entry = @@ -2264,6 +2660,7 @@ static const annotate_entrydesc_t server_db_entry = 0, annotation_get_fromdb, annotation_set_todb, + NULL, NULL }; @@ -2482,20 +2879,9 @@ EXPORTED int annotate_state_fetch(annotate_state_t *state, /************************** Annotation Storing *****************************/ -EXPORTED int annotatemore_lookup(const char *mboxname, const char *entry, - const char *userid, struct buf *value) -{ - return annotatemore_msg_lookup(mboxname, /*uid*/0, entry, userid, value); -} - -EXPORTED int annotatemore_lookupmask(const char *mboxname, const char *entry, - const char *userid, struct buf *value) -{ - return annotatemore_msg_lookupmask(mboxname, /*uid*/0, entry, userid, value); -} - -EXPORTED int annotatemore_msg_lookup(const char *mboxname, uint32_t uid, const char *entry, - const char *userid, struct buf *value) +static int _annotate_lookup(const char *mboxname, const char *mboxid, + uint32_t uid, const char *entry, + const char *userid, struct buf *value) { char key[MAX_MAILBOX_PATH+1]; size_t keylen, datalen; @@ -2503,14 +2889,30 @@ EXPORTED int annotatemore_msg_lookup(const char *mboxname, uint32_t uid, const c const char *data; annotate_db_t *d = NULL; struct annotate_metadata mdata; + mbentry_t *mbentry = NULL; init_internal(); - r = _annotate_getdb(mboxname, uid, 0, &d); - if (r) - return (r == CYRUSDB_NOTFOUND ? 0 : r); + if (!mboxid) { + if (mboxname && *mboxname) { + r = mboxlist_lookup_allow_all(mboxname, &mbentry, NULL); + if (r || !mbentry->uniqueid ||(mbentry->mbtype & MBTYPE_DELETED)) { + buf_free(value); + if (r == IMAP_MAILBOX_NONEXISTENT) r = 0; + goto done; + } + } + + mboxid = mbentry ? mbentry->uniqueid : ""; + } + + r = _annotate_getdb(uid ? mboxid : NULL, NULL, uid, 0, &d); + if (r) { + if (r == CYRUSDB_NOTFOUND) r = 0; + goto done; + } - keylen = make_key(mboxname, uid, entry, userid, key, sizeof(key)); + keylen = make_key(mboxname, mboxid, uid, entry, userid, key, sizeof(key)); do { r = cyrusdb_fetch(d->db, key, keylen, &data, &datalen, tid(d)); @@ -2530,12 +2932,15 @@ EXPORTED int annotatemore_msg_lookup(const char *mboxname, uint32_t uid, const c } if (r == CYRUSDB_NOTFOUND) r = 0; + done: + mboxlist_entry_free(&mbentry); annotate_putdb(&d); return r; } -EXPORTED int annotatemore_msg_lookupmask(const char *mboxname, uint32_t uid, const char *entry, - const char *userid, struct buf *value) +EXPORTED int _annotate_lookupmask(const char *mboxname, const char *mboxid, + uint32_t uid, const char *entry, + const char *userid, struct buf *value) { int r = 0; value->len = 0; /* just in case! */ @@ -2544,16 +2949,79 @@ EXPORTED int annotatemore_msg_lookupmask(const char *mboxname, uint32_t uid, con /* only if the user isn't the owner, we look for a masking value */ if (!mboxname_userownsmailbox(userid, mboxname)) - r = annotatemore_msg_lookup(mboxname, uid, entry, userid, value); + r = _annotate_lookup(mboxname, mboxid, uid, entry, userid, value); /* and if there isn't one, we fall through to the shared value */ if (value->len == 0) - r = annotatemore_msg_lookup(mboxname, uid, entry, "", value); + r = _annotate_lookup(mboxname, mboxid, uid, entry, "", value); /* and because of Bron's use of NULL rather than "" at FastMail... */ if (value->len == 0) - r = annotatemore_msg_lookup(mboxname, uid, entry, NULL, value); + r = _annotate_lookup(mboxname, mboxid, uid, entry, NULL, value); return r; } +EXPORTED int annotatemore_lookup(const char *mboxname, const char *entry, + const char *userid, struct buf *value) +{ + return _annotate_lookup(mboxname, /*mboxid*/NULL, + /*uid*/0, entry, userid, value); +} + +EXPORTED int annotatemore_lookup_mbe(const mbentry_t *mbentry, const char *entry, + const char *userid, struct buf *value) +{ + return _annotate_lookup(mbentry->name, mbentry->uniqueid, + /*uid*/0, entry, userid, value); +} + +EXPORTED int annotatemore_lookup_mbox(const struct mailbox *mailbox, + const char *entry, + const char *userid, struct buf *value) +{ + return _annotate_lookup(mailbox_name(mailbox), mailbox_uniqueid(mailbox), + /*uid*/0, entry, userid, value); +} + +EXPORTED int annotatemore_lookupmask(const char *mboxname, const char *entry, + const char *userid, struct buf *value) +{ + return _annotate_lookupmask(mboxname, /*mboxid*/NULL, + /*uid*/0, entry, userid, value); +} + +EXPORTED int annotatemore_lookupmask_mbe(const mbentry_t *mbentry, + const char *entry, + const char *userid, struct buf *value) +{ + return _annotate_lookupmask(mbentry->name, mbentry->uniqueid, + /*uid*/0, entry, userid, value); +} + +EXPORTED int annotatemore_lookupmask_mbox(const struct mailbox *mailbox, + const char *entry, + const char *userid, struct buf *value) +{ + return _annotate_lookupmask(mailbox_name(mailbox), mailbox_uniqueid(mailbox), + /*uid*/0, entry, userid, value); +} + +EXPORTED int annotatemore_msg_lookup(const struct mailbox *mailbox, + uint32_t uid, const char *entry, + const char *userid, struct buf *value) +{ + return _annotate_lookup(mailbox ? mailbox_name(mailbox) : "", + mailbox ? mailbox_uniqueid(mailbox) : NULL, + uid, entry, userid, value); +} + +EXPORTED int annotatemore_msg_lookupmask(const struct mailbox *mailbox, + uint32_t uid, const char *entry, + const char *userid, struct buf *value) +{ + return _annotate_lookupmask(mailbox ? mailbox_name(mailbox) : "", + mailbox ? mailbox_uniqueid(mailbox) : NULL, + uid, entry, userid, value); +} + static int read_old_value(annotate_db_t *d, const char *key, int keylen, struct buf *valp, @@ -2627,34 +3095,52 @@ static int write_entry(struct mailbox *mailbox, const struct buf *value, int ignorequota, int silent, - const struct annotate_metadata *mdata) + const struct annotate_metadata *mdata, + int maywrite) { char key[MAX_MAILBOX_PATH+1]; int keylen, r; annotate_db_t *d = NULL; struct buf oldval = BUF_INITIALIZER; - const char *mboxname = mailbox ? mailbox->name : ""; + const char *mboxname = mailbox ? mailbox_name(mailbox) : ""; + const char *mboxid = mailbox ? mailbox_uniqueid(mailbox) : ""; modseq_t modseq = mdata ? mdata->modseq : 0; - r = _annotate_getdb(mboxname, uid, CYRUSDB_CREATE, &d); - if (r) - return r; + r = _annotate_getdb(mboxid, mailbox, uid, CYRUSDB_CREATE, &d); + if (r) { + xsyslog(LOG_ERR, "_annotate_getdb failed", + "mailbox=<%s> uid=<%u> error=<%s>", + mailbox_name(mailbox), uid, cyrusdb_strerror(r)); + r = IMAP_IOERROR; + goto out; + } /* must be in a transaction to modify the db */ annotate_begin(d); - keylen = make_key(mboxname, uid, entry, userid, key, sizeof(key)); + keylen = make_key(mboxname, mboxid, uid, entry, userid, key, sizeof(key)); - if (mailbox) { - struct annotate_metadata oldmdata; - r = read_old_value(d, key, keylen, &oldval, &oldmdata); - if (r) goto out; + struct annotate_metadata oldmdata; + r = read_old_value(d, key, keylen, &oldval, &oldmdata); + if (r) { + xsyslog(LOG_ERR, "read_old_value failed", + "mailbox=<%s> uid=<%u> key=<%.*s> error=<%s>", + mailbox_name(mailbox), uid, keylen, key, cyrusdb_strerror(r)); + r = IMAP_IOERROR; + goto out; + } - /* if the value is identical, don't touch the mailbox */ - if (oldval.len == value->len && (!value->len || !memcmp(oldval.s, value->s, value->len))) - goto out; + /* if the value is identical, don't touch the mailbox */ + if (oldval.len == value->len && (!value->len || !memcmp(oldval.s, value->s, value->len))) + goto out; + if (!maywrite) { + r = IMAP_PERMISSION_DENIED; + goto out; + } + + if (mailbox) { if (!ignorequota) { quota_t qdiffs[QUOTA_NUMRESOURCES] = QUOTA_DIFFS_DONTCARE_INITIALIZER; qdiffs[QUOTA_ANNOTSTORAGE] = value->len - (quota_t)oldval.len; @@ -2683,6 +3169,13 @@ static int write_entry(struct mailbox *mailbox, do { r = cyrusdb_delete(d->db, key, keylen, tid(d), /*force*/1); } while (r == CYRUSDB_AGAIN); + if (r) { + xsyslog(LOG_ERR, "cyrusdb_delete failed", + "mailbox=<%s> uid=<%u> key=<%.*s> error=<%s>", + mailbox_name(mailbox), uid, keylen, key, cyrusdb_strerror(r)); + r = IMAP_IOERROR; + goto out; + } } else { struct buf data = BUF_INITIALIZER; @@ -2690,6 +3183,14 @@ static int write_entry(struct mailbox *mailbox, if (!value->len || value->s == NULL) { flags |= ANNOTATE_FLAG_DELETED; } + else { + // this is only here to allow cleanup of invalid values in the past... + // the calling of this API with a NULL "userid" is bogus, because that's + // supposed to be reserved for the make_key of prefixes - but there has + // been API abuse in the past, so some of these are in the wild. *sigh*. + // Don't allow new ones to be written + if (!userid) goto out; + } make_entry(&data, value, modseq, flags); #if DEBUG @@ -2701,10 +3202,27 @@ static int write_entry(struct mailbox *mailbox, r = cyrusdb_store(d->db, key, keylen, data.s, data.len, tid(d)); } while (r == CYRUSDB_AGAIN); buf_free(&data); + if (r) { + xsyslog(LOG_ERR, "cyrusdb_store failed", + "mailbox=<%s> uid=<%u> key=<%.*s> error=<%s>", + mailbox_name(mailbox), uid, keylen, key, cyrusdb_strerror(r)); + r = IMAP_IOERROR; + goto out; + } } if (!mailbox) sync_log_annotation(""); +#ifdef WITH_DAV + else if (!strncmp(entry, CAL_TZ_ANNOT, strlen(CAL_TZ_ANNOT))) { + char *freeme = NULL; + + if (!userid || !*userid) + userid = freeme = mboxname_to_userid(mailbox_name(mailbox)); + r = caldav_alarm_update_floating(mailbox, userid); + free(freeme); + } +#endif out: annotate_putdb(&d); @@ -2720,16 +3238,25 @@ EXPORTED int annotatemore_rawwrite(const char *mboxname, const char *entry, int keylen, r; annotate_db_t *d = NULL; uint32_t uid = 0; + mbentry_t *mbentry = NULL; + const char *mboxid = ""; init_internal(); - r = _annotate_getdb(mboxname, uid, CYRUSDB_CREATE, &d); + r = _annotate_getdb(NULL, NULL, uid, CYRUSDB_CREATE, &d); if (r) goto done; + if (mboxname && *mboxname) { + r = mboxlist_lookup(mboxname, &mbentry, NULL); + if (r) goto done; + + mboxid = mbentry->uniqueid; + } + /* must be in a transaction to modify the db */ annotate_begin(d); - keylen = make_key(mboxname, uid, entry, userid, key, sizeof(key)); + keylen = make_key(mboxname, mboxid, uid, entry, userid, key, sizeof(key)); if (value->s == NULL) { do { @@ -2751,6 +3278,7 @@ EXPORTED int annotatemore_rawwrite(const char *mboxname, const char *entry, r = annotate_commit(d); done: + mboxlist_entry_free(&mbentry); annotate_putdb(&d); return r; @@ -2765,16 +3293,16 @@ EXPORTED int annotatemore_write(const char *mboxname, const char *entry, init_internal(); - r = _annotate_getdb(mboxname, /*uid*/0, CYRUSDB_CREATE, &d); - if (r) goto done; - if (mboxname) { r = mailbox_open_iwl(mboxname, &mailbox); if (r) goto done; } + r = _annotate_getdb(mailbox_uniqueid(mailbox), mailbox, /*uid*/0, CYRUSDB_CREATE, &d); + if (r) goto done; + r = write_entry(mailbox, /*uid*/0, entry, userid, value, - /*ignorequota*/1, /*silent*/0, NULL); + /*ignorequota*/1, /*silent*/0, NULL, /*maywrite*/1); if (r) goto done; r = annotate_commit(d); @@ -2786,6 +3314,15 @@ EXPORTED int annotatemore_write(const char *mboxname, const char *entry, return r; } +EXPORTED int annotatemore_writemask(const char *mboxname, const char *entry, + const char *userid, const struct buf *value) +{ + if (mboxname_userownsmailbox(userid, mboxname)) + return annotatemore_write(mboxname, entry, "", value); + else + return annotatemore_write(mboxname, entry, userid, value); +} + EXPORTED int annotate_state_write(annotate_state_t *state, const char *entry, const char *userid, @@ -2793,7 +3330,7 @@ EXPORTED int annotate_state_write(annotate_state_t *state, { return write_entry(state->mailbox, state->uid, entry, userid, value, /*ignorequota*/1, - state->silent, NULL); + state->silent, NULL, /*maywrite*/1); } EXPORTED int annotate_state_writesilent(annotate_state_t *state, @@ -2803,7 +3340,7 @@ EXPORTED int annotate_state_writesilent(annotate_state_t *state, { return write_entry(state->mailbox, state->uid, entry, userid, value, /*ignorequota*/1, - /*silent*/1, NULL); + /*silent*/1, NULL, /*maywrite*/1); } EXPORTED int annotate_state_writemdata(annotate_state_t *state, @@ -2813,7 +3350,7 @@ EXPORTED int annotate_state_writemdata(annotate_state_t *state, const struct annotate_metadata *mdata) { return write_entry(state->mailbox, state->uid, entry, userid, value, - /*ignorequota*/1, 0, mdata); + /*ignorequota*/1, /*silent*/1, mdata, /*maywrite*/1); } EXPORTED int annotate_state_writemask(annotate_state_t *state, @@ -2822,7 +3359,7 @@ EXPORTED int annotate_state_writemask(annotate_state_t *state, const struct buf *value) { /* if the user is the owner, then write to the shared namespace */ - if (mboxname_userownsmailbox(userid, state->mailbox->name)) + if (mboxname_userownsmailbox(userid, mailbox_name(state->mailbox))) return annotate_state_write(state, entry, "", value); else return annotate_state_write(state, entry, userid, value); @@ -2844,7 +3381,7 @@ static int annotate_canon_value(struct buf *value, int type) break; case ATTRIB_TYPE_BOOLEAN: - /* make sure its "true" or "false" */ + /* make sure it is "true" or "false" */ if (value->len == 4 && !strncasecmp(value->s, "true", 4)) { buf_reset(value); buf_appendcstr(value, "true"); @@ -2859,7 +3396,7 @@ static int annotate_canon_value(struct buf *value, int type) break; case ATTRIB_TYPE_UINT: - /* make sure its a valid ulong ( >= 0 ) */ + /* make sure it is a valid ulong ( >= 0 ) */ errno = 0; buf_cstring(value); uwhatever = strtoul(value->s, &p, 10); @@ -2874,7 +3411,7 @@ static int annotate_canon_value(struct buf *value, int type) break; case ATTRIB_TYPE_INT: - /* make sure its a valid long */ + /* make sure it is a valid long */ errno = 0; buf_cstring(value); whatever = strtol(value->s, &p, 10); @@ -2887,6 +3424,16 @@ static int annotate_canon_value(struct buf *value, int type) } break; + case ATTRIB_TYPE_DURATION: + /* make sure it is a valid positive duration ( >= 0 ) */ + { + buf_cstring(value); + int secs = -1; + if (config_parseduration(value->s, 'd', &secs) || secs < 0) + return IMAP_ANNOTATION_BADVALUE; + } + break; + default: /* unknown type */ return IMAP_ANNOTATION_BADVALUE; @@ -2906,6 +3453,7 @@ static int _annotate_store_entries(annotate_state_t *state) /* Loop through the list of provided entries to set */ for (ee = state->entry_list ; ee ; ee = ee->next) { + int maystore = 1; /* Skip annotations that can't be stored on frontend */ if ((ee->desc->proxytype == BACKEND_ONLY) && @@ -2914,17 +3462,15 @@ static int _annotate_store_entries(annotate_state_t *state) if (ee->have_shared && !_annotate_may_store(state, /*shared*/1, ee->desc)) { - r = IMAP_PERMISSION_DENIED; - goto done; + maystore = 0; } if (ee->have_priv && !_annotate_may_store(state, /*shared*/0, ee->desc)) { - r = IMAP_PERMISSION_DENIED; - goto done; + maystore = 0; } - r = ee->desc->set(state, ee); + r = ee->desc->set(state, ee, maystore); if (r) goto done; @@ -2965,7 +3511,7 @@ static int _annotate_may_store(annotate_state_t *state, return 1; if (state->which == ANNOTATION_SCOPE_SERVER) { - /* RFC5464 doesn't mention access control for server + /* RFC 5464 doesn't mention access control for server * annotations, but this seems a sensible practice and is * consistent with past Cyrus behaviour */ return !is_shared; @@ -2973,12 +3519,12 @@ static int _annotate_may_store(annotate_state_t *state, else if (state->which == ANNOTATION_SCOPE_MAILBOX) { assert(state->mailbox); - /* Make sure its a local mailbox annotation */ + /* Make sure it is a local mailbox annotation */ if (state->mbentry && state->mbentry->server) return 0; - acl = state->mailbox->acl; - /* RFC5464 is a trifle vague about access control for mailbox + acl = mailbox_acl(state->mailbox); + /* RFC 5464 is a trifle vague about access control for mailbox * annotations but this seems to be compliant */ needed = ACL_LOOKUP; if (is_shared) @@ -2987,8 +3533,8 @@ static int _annotate_may_store(annotate_state_t *state, } else if (state->which == ANNOTATION_SCOPE_MESSAGE) { assert(state->mailbox); - acl = state->mailbox->acl; - /* RFC5257: writing to a private annotation needs 'r'. + acl = mailbox_acl(state->mailbox); + /* RFC 5257: writing to a private annotation needs 'r'. * Writing to a shared annotation needs 'n' */ needed = (is_shared ? ACL_ANNOTATEMSG : ACL_READ); /* fall through to ACL check */ @@ -3004,18 +3550,21 @@ static int _annotate_may_store(annotate_state_t *state, static int annotation_set_tofile(annotate_state_t *state __attribute__((unused)), - struct annotate_entry_list *entry) + struct annotate_entry_list *entry, + int maywrite) { const char *filename = (const char *)entry->desc->rock; char path[MAX_MAILBOX_PATH+1]; int r; FILE *f; + if (!maywrite) return IMAP_PERMISSION_DENIED; + snprintf(path, sizeof(path), "%s/msg/%s", config_dir, filename); /* XXX how do we do this atomically with other annotations? */ if (entry->shared.s == NULL) - return unlink(path); + return xunlink(path); else { r = cyrus_mkdir(path, 0755); if (r) @@ -3034,24 +3583,26 @@ static int annotation_set_tofile(annotate_state_t *state } static int annotation_set_todb(annotate_state_t *state, - struct annotate_entry_list *entry) + struct annotate_entry_list *entry, + int maywrite) { int r = 0; if (entry->have_shared) r = write_entry(state->mailbox, state->uid, entry->name, "", - &entry->shared, 0, state->silent, NULL); + &entry->shared, 0, state->silent, NULL, maywrite); if (!r && entry->have_priv) r = write_entry(state->mailbox, state->uid, entry->name, state->userid, - &entry->priv, 0, state->silent, NULL); + &entry->priv, 0, state->silent, NULL, maywrite); return r; } static int annotation_set_mailboxopt(annotate_state_t *state, - struct annotate_entry_list *entry) + struct annotate_entry_list *entry, + int maywrite) { struct mailbox *mailbox = state->mailbox; uint32_t flag = (unsigned long)entry->desc->rock; @@ -3070,16 +3621,20 @@ static int annotation_set_mailboxopt(annotate_state_t *state, /* only mark dirty if there's been a change */ if (mailbox->i.options != newopts) { + if (!maywrite) return IMAP_PERMISSION_DENIED; mailbox_index_dirty(mailbox); + mailbox_modseq_dirty(mailbox); mailbox->i.options = newopts; - mboxlist_foldermodseq_dirty(mailbox); + if (!state->silent) + mboxlist_update_foldermodseq(mailbox_name(mailbox), mailbox->i.highestmodseq); } return 0; } static int annotation_set_pop3showafter(annotate_state_t *state, - struct annotate_entry_list *entry) + struct annotate_entry_list *entry, + int maywrite) { struct mailbox *mailbox = state->mailbox; int r = 0; @@ -3098,15 +3653,38 @@ static int annotation_set_pop3showafter(annotate_state_t *state, } if (date != mailbox->i.pop3_show_after) { - mailbox->i.pop3_show_after = date; + if (!maywrite) return IMAP_PERMISSION_DENIED; mailbox_index_dirty(mailbox); + mailbox_modseq_dirty(mailbox); + mailbox->i.pop3_show_after = date; + if (!state->silent) + mboxlist_update_foldermodseq(mailbox_name(mailbox), mailbox->i.highestmodseq); } return 0; } +static int annotation_set_fuzzyalways(annotate_state_t *state, + struct annotate_entry_list *entry, + int maywrite) +{ + struct mailbox *mailbox = state->mailbox; + + assert(mailbox); + + if (!mboxname_isusermailbox(mailbox_name(mailbox), /*isinbox*/1)) { + return IMAP_PERMISSION_DENIED; + } + if (buf_len(&entry->shared) && + config_parse_switch(buf_cstring(&entry->shared)) < 0) { + return IMAP_ANNOTATION_BADENTRY; + } + + return annotation_set_todb(state, entry, maywrite); +} + EXPORTED int specialuse_validate(const char *mboxname, const char *userid, - const char *src, struct buf *dest) + const char *src, struct buf *dest, int allow_dups) { const char *specialuse_extra_opt = config_getstring(IMAPOPT_SPECIALUSE_EXTRA); char *strval = NULL; @@ -3145,11 +3723,13 @@ EXPORTED int specialuse_validate(const char *mboxname, const char *userid, strarray_add(valid, "\\Junk"); strarray_add(valid, "\\Sent"); strarray_add(valid, "\\Trash"); + strarray_add(valid, "\\Snoozed"); // JMAP + strarray_add(valid, "\\Scheduled"); // JMAP new_attribs = strarray_split(src, NULL, 0); for (i = 0; i < new_attribs->count; i++) { - int skip_mbcheck = 0; + int skip_mbcheck = allow_dups; const char *item = strarray_nth(new_attribs, i); for (j = 0; j < valid->count; j++) { /* can't use find here */ @@ -3166,7 +3746,7 @@ EXPORTED int specialuse_validate(const char *mboxname, const char *userid, } if (cur_attribs && - (strarray_find_case(cur_attribs, strarray_nth(valid, j), 0) >= 0)) { + (strarray_contains_case(cur_attribs, strarray_nth(valid, j)))) { /* The mailbox has this specialuse attribute set already */ skip_mbcheck = 1; } @@ -3182,6 +3762,23 @@ EXPORTED int specialuse_validate(const char *mboxname, const char *userid, } } + /* some attributes may not be set on mailboxes containing children */ + if (mboxname && config_getstring(IMAPOPT_SPECIALUSE_NOCHILDREN)) { + strarray_t *forbidden = strarray_split( + config_getstring(IMAPOPT_SPECIALUSE_NOCHILDREN), + NULL, + STRARRAY_TRIM + ); + + if (strarray_contains(forbidden, strarray_nth(valid, j)) + && mboxlist_haschildren(mboxname)) + { + r = IMAP_MAILBOX_HASCHILDREN; + } + + strarray_free(forbidden); + } + /* normalise the value */ strarray_set(new_attribs, i, strarray_nth(valid, j)); } @@ -3199,7 +3796,8 @@ EXPORTED int specialuse_validate(const char *mboxname, const char *userid, } static int annotation_set_specialuse(annotate_state_t *state, - struct annotate_entry_list *entry) + struct annotate_entry_list *entry, + int maywrite) { struct buf res = BUF_INITIALIZER; int r = IMAP_PERMISSION_DENIED; @@ -3209,16 +3807,16 @@ static int annotation_set_specialuse(annotate_state_t *state, /* Effectively removes the annotation */ if (entry->priv.s == NULL) { r = write_entry(state->mailbox, state->uid, entry->name, state->userid, - &entry->priv, /*ignorequota*/0, /*silent*/0, NULL); + &entry->priv, /*ignorequota*/0, /*silent*/0, NULL, maywrite); goto done; } - r = specialuse_validate(state->mailbox->name, state->userid, - buf_cstring(&entry->priv), &res); + r = specialuse_validate(mailbox_name(state->mailbox), state->userid, + buf_cstring(&entry->priv), &res, 0); if (r) goto done; r = write_entry(state->mailbox, state->uid, entry->name, state->userid, - &res, /*ignorequota*/0, state->silent, NULL); + &res, /*ignorequota*/0, state->silent, NULL, maywrite); done: buf_free(&res); @@ -3253,9 +3851,10 @@ static int find_desc_store(annotate_state_t *state, return IMAP_INTERNAL; } - /* check for DAV annotations */ - if (state->mailbox && (state->mailbox->mbtype & MBTYPES_DAV) && - !strncmp(name, DAV_ANNOT_NS, strlen(DAV_ANNOT_NS))) { + /* check for DAV and JMAP annotations */ + if (state->mailbox && mbtypes_dav(mailbox_mbtype(state->mailbox)) && + (!strncmp(name, DAV_ANNOT_NS, strlen(DAV_ANNOT_NS)) || + !strncmp(name, JMAP_ANNOT_NS, strlen(JMAP_ANNOT_NS)))) { *descp = db_entry; return 0; } @@ -3429,10 +4028,12 @@ static int rename_cb(const char *mboxname __attribute__((unused)), { struct rename_rock *rrock = (struct rename_rock *) rock; int r = 0; + int silent = rrock->oldmailbox->silentchanges; if (rrock->newmailbox && - /* displayname stores the UTF-8 encoded JMAP name of a mailbox */ - strcmp(entry, IMAP_ANNOT_NS "displayname")) { + /* snoozed MUST only appear on one copy of a message */ + strcmp(entry, IMAP_ANNOT_NS "snoozed")) { + /* create newly renamed entry */ const char *newuserid = userid; @@ -3442,32 +4043,32 @@ static int rename_cb(const char *mboxname __attribute__((unused)), newuserid = rrock->newuserid; } r = write_entry(rrock->newmailbox, rrock->newuid, entry, newuserid, - value, /*ignorequota*/0, /*silent*/0, NULL); + value, /*ignorequota*/0, silent, NULL, /*maywrite*/1); } if (!rrock->copy && !r) { /* delete existing entry */ struct buf dattrib = BUF_INITIALIZER; r = write_entry(rrock->oldmailbox, uid, entry, userid, &dattrib, - /*ignorequota*/0, /*silent*/0, NULL); + /*ignorequota*/0, silent, NULL, /*maywrite*/1); } return r; } EXPORTED int annotate_rename_mailbox(struct mailbox *oldmailbox, - struct mailbox *newmailbox) + struct mailbox *newmailbox) { /* rename one mailbox */ - char *olduserid = mboxname_to_userid(oldmailbox->name); - char *newuserid = mboxname_to_userid(newmailbox->name); + char *olduserid = mboxname_to_userid(mailbox_name(oldmailbox)); + char *newuserid = mboxname_to_userid(mailbox_name(newmailbox)); annotate_db_t *d = NULL; int r = 0; init_internal(); /* rewrite any per-folder annotations from the global db */ - r = _annotate_getdb(NULL, 0, /*don't create*/0, &d); + r = _annotate_getdb(NULL, NULL, 0, /*don't create*/0, &d); if (r == CYRUSDB_NOTFOUND) { /* no global database, must not be anything to rename */ r = 0; @@ -3477,10 +4078,48 @@ EXPORTED int annotate_rename_mailbox(struct mailbox *oldmailbox, annotate_begin(d); - /* copy here - delete will dispose of old records later */ - r = _annotate_rewrite(oldmailbox, 0, olduserid, - newmailbox, 0, newuserid, - /*copy*/1); + if (mailbox_uniqueid(newmailbox) && + strcmp(mailbox_uniqueid(oldmailbox), mailbox_uniqueid(newmailbox))) { + /* copy here - delete will dispose of old records later + + XXX This code appears to only be necessary to allow + the annotate:rename unit test to pass. + In fact, that test may be obsolete for mbpath-by-id. + */ + r = _annotate_rewrite(oldmailbox, 0, olduserid, + newmailbox, 0, newuserid, + /*copy*/1); + } + + /* delete displayname records, as they're wrong now + * + * XXX this is awfully tricky -- there's no direct api for deleting + * XXX a single annotation, so instead we're abusing "rename_cb". + * XXX but which trickery to use depends on how we got here... + * .newmailbox = NULL: + * nothing to copy, we already took care of that above + * .copy = 0: + * it's a move, so delete it from "oldmailbox" after "copying" it + * .oldmailbox = whichever object will persist... + * so the quota updates get applied to the correct place + */ + struct rename_rock rrock = { .newmailbox = NULL, .copy = 0 }; + if (mailbox_mbtype(oldmailbox) & MBTYPE_LEGACY_DIRS) { + /* legacy mailbox. "newmailbox" is the object that will persist */ + rrock.oldmailbox = newmailbox; + } + else { + /* uuid mailbox. "oldmailbox" is the object that will persist */ + rrock.oldmailbox = oldmailbox; + } + + /* XXX regardless of rrock trickiness above, the mailbox argument here + * XXX must be "oldmailbox", because "newmailbox" doesn't fully exist + * XXX (yet) and won't be found in either case + */ + r = annotatemore_findall_mailbox(oldmailbox, /*olduid*/0, + IMAP_ANNOT_NS "displayname", /*modseq*/0, + &rename_cb, &rrock, /*flags*/0); if (r) goto done; r = annotate_commit(d); @@ -3521,8 +4160,8 @@ static int _annotate_rewrite(struct mailbox *oldmailbox, rrock.newuid = newuid; rrock.copy = copy; - return annotatemore_findall(oldmailbox->name, olduid, "*", /*modseq*/0, - &rename_cb, &rrock, /*flags*/0); + return annotatemore_findall_mailbox(oldmailbox, olduid, "*", /*modseq*/0, + &rename_cb, &rrock, /*flags*/0); } EXPORTED int annotate_delete_mailbox(struct mailbox *mailbox) @@ -3530,40 +4169,44 @@ EXPORTED int annotate_delete_mailbox(struct mailbox *mailbox) int r = 0; char *fname = NULL; annotate_db_t *d = NULL; + int is_rename = 0; init_internal(); assert(mailbox); - /* remove any per-folder annotations from the global db */ - r = _annotate_getdb(NULL, 0, /*don't create*/0, &d); - if (r == CYRUSDB_NOTFOUND) { - /* no global database, must not be anything to rename */ - r = 0; - goto out; + if (!mboxname_isdeletedmailbox(mailbox_name(mailbox), NULL)) { + mbentry_t *mbentry = NULL; + + r = mboxlist_lookup_by_uniqueid(mailbox_uniqueid(mailbox), &mbentry, NULL); + if (r) goto out; + + is_rename = strcmp(mailbox_name(mailbox), mbentry->name); + mboxlist_entry_free(&mbentry); } - if (r) goto out; - annotate_begin(d); + if (!is_rename) { + /* remove any per-folder annotations from the global db */ + r = _annotate_getdb(NULL, NULL, 0, /*don't create*/0, &d); + if (r == CYRUSDB_NOTFOUND) { + /* no global database, must not be anything to rename */ + r = 0; + goto out; + } + if (r) goto out; - r = _annotate_rewrite(mailbox, - /*olduid*/0, /*olduserid*/NULL, - /*newmailbox*/NULL, - /*newuid*/0, /*newuserid*/NULL, - /*copy*/0); - if (r) goto out; + annotate_begin(d); - /* remove the entire per-folder database */ - r = annotate_dbname_mailbox(mailbox, &fname); - if (r) goto out; + r = _annotate_rewrite(mailbox, + /*olduid*/0, /*olduserid*/NULL, + /*newmailbox*/NULL, + /*newuid*/0, /*newuserid*/NULL, + /*copy*/0); + if (r && r != IMAP_MAILBOX_NONEXISTENT) goto out; - /* (gnb)TODO: do we even need to do this?? */ - if (unlink(fname) < 0 && errno != ENOENT) { - syslog(LOG_ERR, "cannot unlink %s: %m", fname); + r = annotate_commit(d); } - r = annotate_commit(d); - out: annotate_putdb(&d); free(fname); @@ -3579,7 +4222,9 @@ EXPORTED int annotate_msg_copy(struct mailbox *oldmailbox, uint32_t olduid, init_internal(); - r = _annotate_getdb(newmailbox->name, newuid, CYRUSDB_CREATE, &d); + assert(newmailbox != NULL); + + r = _annotate_getdb(mailbox_uniqueid(newmailbox), newmailbox, newuid, CYRUSDB_CREATE, &d); if (r) return r; annotate_begin(d); @@ -3616,9 +4261,10 @@ HIDDEN int annotate_msg_cleanup(struct mailbox *mailbox, unsigned int uid) int r = 0; annotate_db_t *d = NULL; + assert(mailbox != NULL); assert(uid); - r = _annotate_getdb(mailbox->name, uid, 0, &d); + r = _annotate_getdb(mailbox_uniqueid(mailbox), mailbox, uid, 0, &d); if (r) return r; /* must be in a transaction to modify the db */ @@ -3629,7 +4275,8 @@ HIDDEN int annotate_msg_cleanup(struct mailbox *mailbox, unsigned int uid) assert(mailbox->annot_state != NULL); assert(mailbox->annot_state->d == d); - keylen = make_key(mailbox->name, uid, "", NULL, key, sizeof(key)); + keylen = make_key(mailbox_name(mailbox), mailbox_uniqueid(mailbox), + uid, "", NULL, key, sizeof(key)); r = cyrusdb_foreach(d->db, key, keylen, NULL, &cleanup_cb, d, tid(d)); @@ -3868,7 +4515,13 @@ static void init_annotation_definitions(void) parse_error(&state, "annotation under " IMAP_ANNOT_NS); goto bad; } - ae->name = xstrdup(p); + + /* we implement case-insensitivity by lcase-and-compare, so make + * sure the source is lcase'd! + */ + ae->freeme = xstrdup(p); + lcase(ae->freeme); + ae->name = ae->freeme; if (!(p = get_token(&state, ".-_/"))) goto bad; switch (table_lookup(annotation_scope_names, p)) { @@ -3880,7 +4533,7 @@ static void init_annotation_definitions(void) break; case ANNOTATION_SCOPE_MESSAGE: if (!strncmp(ae->name, "/flags/", 7)) { - /* RFC5257 reserves the /flags/ hierarchy for future use */ + /* RFC 5257 reserves the /flags/ hierarchy for future use */ state.context = ae->name; parse_error(&state, "message entry under /flags/"); goto bad; @@ -3931,7 +4584,7 @@ static void init_annotation_definitions(void) continue; bad: - free((char *)ae->name); + free(ae->freeme); free(ae); tok_fini(&state.tok); continue; @@ -3951,3 +4604,121 @@ static void init_annotation_definitions(void) fclose(f); } + +static int _check_rec_cb(void *rock, + const char *key, size_t keylen, + const char *data __attribute__((unused)), + size_t datalen __attribute__((unused))) +{ + int *do_upgrade = (int *) rock; + annotate_db_t db = { NULL, 0, NULL, NULL, NULL, NULL, 0 }; + const char *mboxid, *entry, *userid; + unsigned uid; + + split_key(&db, key, keylen, &mboxid, &uid, &entry, &userid); + *do_upgrade = (mboxlist_lookup_by_uniqueid(mboxid, NULL, NULL) != 0); + + return CYRUSDB_DONE; +} + +static int _upgrade_cb(void *rock, + const char *key, size_t keylen, + const char *data, size_t datalen) +{ + annotate_db_t *db = (annotate_db_t *) rock; + mbentry_t *mbentry = NULL; + const char *mboxname, *entry, *userid; + char newkey[MAX_MAILBOX_PATH+1]; + unsigned uid; + int r; + + split_key(db, key, keylen, &mboxname, &uid, &entry, &userid); + + r = mboxlist_lookup(mboxname, &mbentry, NULL); + if (r) return 0; + + keylen = make_key(mboxname, mbentry->uniqueid, uid, entry, userid, + newkey, sizeof(newkey)); + mboxlist_entry_free(&mbentry); + + do { + r = cyrusdb_store(db->db, newkey, keylen, data, datalen, tid(db)); + } while (r == CYRUSDB_AGAIN); + + return 0; +} + +EXPORTED int annotatemore_upgrade(void) +{ + annotate_db_t *db; + int r, r2 = 0, do_upgrade = 0; + struct buf buf = BUF_INITIALIZER; + struct db *backup = NULL; + char *fname; + + /* check if we need to upgrade */ + annotatemore_open(); + r = _annotate_getdb(NULL, NULL, 0, 0, &db); + if (r) goto done; + + r = cyrusdb_foreach(db->db, "", 0, NULL, _check_rec_cb, &do_upgrade, NULL); + annotatemore_close(); + + if (r != CYRUSDB_DONE) return r; + else if (!do_upgrade) return 0; + + /* create db file names */ + annotate_dbname_mbentry(NULL, &fname); + buf_setcstr(&buf, fname); + buf_appendcstr(&buf, ".OLD"); + + /* rename db file to backup */ + r = rename(fname, buf_cstring(&buf)); + free(fname); + if (r) goto done; + + /* open backup db file */ + r = cyrusdb_open(DB, buf_cstring(&buf), 0, &backup); + + if (r) { + syslog(LOG_ERR, "DBERROR: opening %s: %s", buf_cstring(&buf), + cyrusdb_strerror(r)); + fatal("can't open annotations file", EX_TEMPFAIL); + } + + /* open a new db file */ + annotatemore_open(); + r = _annotate_getdb(NULL, NULL, 0, CYRUSDB_CREATE, &db); + if (r) goto done; + + /* perform upgrade from backup to new db */ + annotate_begin(db); + r = cyrusdb_foreach(backup, "", 0, NULL, _upgrade_cb, db, NULL); + + r2 = cyrusdb_close(backup); + if (r2) { + syslog(LOG_ERR, "DBERROR: error closing %s: %s", buf_cstring(&buf), + cyrusdb_strerror(r2)); + } + + /* complete txn on new db */ + if (db->in_txn) { + if (r) { + annotate_abort(db); + } else { + r2 = annotate_commit(db); + } + + if (r2) { + syslog(LOG_ERR, "DBERROR: error %s txn in annotations_upgrade: %s", + r ? "aborting" : "committing", cyrusdb_strerror(r2)); + } + } + + annotatemore_close(); + + done: + buf_free(&buf); + + return r; +} diff --git a/imap/annotate.h b/imap/annotate.h index 13bd68771e..c9847fe33a 100644 --- a/imap/annotate.h +++ b/imap/annotate.h @@ -55,11 +55,11 @@ #define IMAP_ANNOT_NS "/vendor/cmu/cyrus-imapd/" #define DAV_ANNOT_NS "/vendor/cmu/cyrus-httpd/" +#define JMAP_ANNOT_NS "/vendor/cmu/cyrus-jmap/" /* List of strings, for fetch and search argument blocks */ struct strlist { char *s; /* String */ - comp_pat *p; /* Compiled pattern, for search */ struct strlist *next; }; @@ -88,7 +88,6 @@ struct annotate_metadata }; typedef struct annotate_state annotate_state_t; -typedef struct annotate_recalc_state annotate_recalc_state_t; annotate_state_t *annotate_state_new(void); /* either of these close */ @@ -107,9 +106,16 @@ int annotate_state_set_message(annotate_state_t *state, struct mailbox *mailbox, unsigned int uid); +#define ANNOTATION_SCOPE_UNKNOWN (-1) +enum { + ANNOTATION_SCOPE_SERVER = 1, + ANNOTATION_SCOPE_MAILBOX = 2, + ANNOTATION_SCOPE_MESSAGE = 3 +}; +int annotate_state_scope(annotate_state_t *state); + /* String List Management */ void appendstrlist(struct strlist **l, char *s); -void appendstrlistpat(struct strlist **l, char *s); void freestrlist(struct strlist *l); /* Attribute Management (also used by ID) */ @@ -129,6 +135,7 @@ void clearentryatt(struct entryattlist **l, const char *entry, void dupentryatt(struct entryattlist **l, const struct entryattlist *); size_t sizeentryatts(const struct entryattlist *); +char *dumpentryatt(const struct entryattlist *l); void freeentryatts(struct entryattlist *l); /* initialize database structures */ @@ -154,9 +161,21 @@ typedef int (*annotatemore_find_proc_t)(const char *mailbox, /* For findall() matches also tombstones */ #define ANNOTATE_TOMBSTONES (1<<0) -/* 'proc'ess all annotations matching 'mailbox' and 'entry' */ -int annotatemore_findall(const char *mboxname, uint32_t uid, - const char *entry, +/* 'proc'ess all annotations matching 'mailbox' and 'entry'. + * if 'mailbox' is NULL, then 'pattern' is a pattern for + * mboxlist_findall and will return all matching entries.. */ +EXPORTED int annotatemore_findall_mailbox(const struct mailbox *mailbox, + uint32_t uid, const char *entry, + modseq_t since_modseq, + annotatemore_find_proc_t proc, void *rock, + int flags); +EXPORTED int annotatemore_findall_pattern(const char *pattern, + uint32_t uid, const char *entry, + modseq_t since_modseq, + annotatemore_find_proc_t proc, void *rock, + int flags); +EXPORTED int annotatemore_findall_mboxname(const char *mboxname, + uint32_t uid, const char *entry, modseq_t since_modseq, annotatemore_find_proc_t proc, void *rock, int flags); @@ -174,6 +193,9 @@ int annotate_state_fetch(annotate_state_t *state, /* write a single annotation, avoiding all ACL checks and etc */ int annotatemore_write(const char *mboxname, const char *entry, const char *userid, const struct buf *value); +/* same but write to shared if the user own the mailbox */ +int annotatemore_writemask(const char *mboxname, const char *entry, + const char *userid, const struct buf *value); /* flat out ignore modseq and quota and everything */ int annotatemore_rawwrite(const char *mboxname, const char *entry, const char *userid, const struct buf *value); @@ -186,11 +208,22 @@ int annotatemore_lookup(const char *mboxname, const char *entry, int annotatemore_lookupmask(const char *mboxname, const char *entry, const char *userid, struct buf *value); /* lookup a single per-message annotation and return result */ -int annotatemore_msg_lookup(const char *mboxname, uint32_t uid, const char *entry, +int annotatemore_msg_lookup(const struct mailbox *mailbox, + uint32_t uid, const char *entry, + const char *userid, struct buf *value); +int annotatemore_lookup_mbe(const mbentry_t *mbentry, const char *entry, const char *userid, struct buf *value); +int annotatemore_lookup_mbox(const struct mailbox *mailbox, const char *entry, + const char *userid, struct buf *value); /* same but check shared if per-user doesn't exist */ -int annotatemore_msg_lookupmask(const char *mboxname, uint32_t uid, const char *entry, +int annotatemore_msg_lookupmask(const struct mailbox *mailbox, + uint32_t uid, const char *entry, const char *userid, struct buf *value); +int annotatemore_lookupmask_mbe(const mbentry_t *mbentry, const char *entry, + const char *userid, struct buf *value); +int annotatemore_lookupmask_mbox(const struct mailbox *mailbox, + const char *entry, + const char *userid, struct buf *value); /* store annotations. Requires an open transaction */ int annotate_state_store(annotate_state_t *state, struct entryattlist *l); @@ -229,15 +262,6 @@ int annotate_msg_cleanup(struct mailbox *mailbox, uint32_t uid); * Uses its own transaction. */ int annotate_delete_mailbox(struct mailbox *mailbox); -/* recalc APIs */ -int annotate_recalc_begin(struct mailbox *mailbox, - annotate_recalc_state_t **arsp, - int reconstruct); -void annotate_recalc_add(annotate_recalc_state_t *ars, - uint32_t uid); -int annotate_recalc_commit(annotate_recalc_state_t *ars); -void annotate_recalc_abort(annotate_recalc_state_t *ars); - /* * Annotation DB transactions used to be opened and closed explicitly. * Now they're opened whenever they're needed as a side effect of @@ -270,11 +294,13 @@ void annotate_done(void); * annotations at all on the mailbox. These APIs are for performance * optimisations only; the other annotate APIs will manage their own * references internally. */ -int annotate_getdb(const char *mboxname, annotate_db_t **dbp); +int annotate_getdb(const struct mailbox *mailbox, annotate_db_t **dbp); void annotate_putdb(annotate_db_t **dbp); /* Maybe this isn't the right place - move later */ int specialuse_validate(const char *mboxname, const char *userid, - const char *src, struct buf *dest); + const char *src, struct buf *dest, int allow_dups); + +int annotatemore_upgrade(void); #endif /* ANNOTATE_H */ diff --git a/imap/append.c b/imap/append.c index 3a881e754b..52799ff4a2 100644 --- a/imap/append.c +++ b/imap/append.c @@ -54,6 +54,7 @@ #include #include #include +#include #include "acl.h" #include "assert.h" @@ -73,6 +74,7 @@ #include "retry.h" #include "quota.h" #include "util.h" +#include "xunlink.h" /* generated headers are not necessarily in current directory */ #include "imap/imap_err.h" @@ -93,8 +95,10 @@ struct stagemsg { struct message_guid guid; }; +static uint64_t append_counter; + static int append_addseen(struct mailbox *mailbox, const char *userid, - struct seqset *newseen); + seqset_t *newseen); static int append_setseen(struct appendstate *as, msgrecord_t *mr); /* @@ -121,9 +125,9 @@ EXPORTED int append_check(const char *name, r = mailbox_open_irl(name, &mailbox); if (r) return r; - myrights = cyrus_acl_myrights(auth_state, mailbox->acl); + myrights = cyrus_acl_myrights(auth_state, mailbox_acl(mailbox)); - if ((myrights & aclcheck) != aclcheck) { + if ((myrights & aclcheck) != aclcheck || mboxname_isscheduledmailbox(name, 0)) { r = (myrights & ACL_LOOKUP) ? IMAP_PERMISSION_DENIED : IMAP_MAILBOX_NONEXISTENT; goto done; @@ -163,7 +167,10 @@ EXPORTED int append_setup(struct appendstate *as, const char *name, struct mailbox *mailbox = NULL; r = mailbox_open_iwl(name, &mailbox); - if (r) return r; + if (r) { + memset(as, 0, sizeof(*as)); + return r; + } r = append_setup_mbox(as, mailbox, userid, auth_state, aclcheck, quotacheck, namespace, isadmin, event_type); @@ -189,7 +196,7 @@ EXPORTED int append_setup_mbox(struct appendstate *as, struct mailbox *mailbox, memset(as, 0, sizeof(*as)); - as->myrights = cyrus_acl_myrights(auth_state, mailbox->acl); + as->myrights = cyrus_acl_myrights(auth_state, mailbox_acl(mailbox)); if ((as->myrights & aclcheck) != aclcheck) { r = (as->myrights & ACL_LOOKUP) ? @@ -225,10 +232,6 @@ EXPORTED int append_setup_mbox(struct appendstate *as, struct mailbox *mailbox, as->mailbox = mailbox; - if (config_getswitch(IMAPOPT_OUTBOX_SENDLATER)) { - as->isoutbox = mboxname_isoutbox(mailbox->name); - } - return 0; } @@ -242,8 +245,7 @@ static void append_free(struct appendstate *as) if (!as) return; if (as->s == APPEND_DONE) return; - seqset_free(as->seen_seq); - as->seen_seq = NULL; + seqset_free(&as->seen_seq); mboxevent_freequeue(&as->mboxevents); as->event_type = 0; @@ -267,26 +269,29 @@ EXPORTED int append_commit(struct appendstate *as) as->mailbox->i.last_appenddate = time(0); /* log the append so rolling squatter can index this mailbox */ - sync_log_append(as->mailbox->name); + sync_log_append(mailbox_name(as->mailbox)); /* set seen state */ if (as->userid[0]) append_addseen(as->mailbox, as->userid, as->seen_seq); } + /* Send the list of MessageCopy or MessageAppend event notifications at once. + * We want to do this before the Modseq event gets sent by mailbox_commit(). + */ + mboxevent_notify(&as->mboxevents); + /* We want to commit here to guarantee mailbox on disk vs * duplicate DB consistency */ r = mailbox_commit(as->mailbox); if (r) { - syslog(LOG_ERR, "IOERROR: committing mailbox append %s: %s", - as->mailbox->name, error_message(r)); + xsyslog(LOG_ERR, "IOERROR: committing mailbox append", + "mailbox=<%s> error=<%s>", + mailbox_name(as->mailbox), error_message(r)); append_abort(as); return r; } - /* send the list of MessageCopy or MessageAppend event notifications at once */ - mboxevent_notify(&as->mboxevents); - append_free(as); return 0; } @@ -294,10 +299,18 @@ EXPORTED int append_commit(struct appendstate *as) /* may return non-zero, indicating an internal error of some sort. */ EXPORTED int append_abort(struct appendstate *as) { + int i, r = 0; if (as->s == APPEND_DONE) return 0; + + // nuke any files that we've created + for (i = 0; i < as->nummsg; i++) { + mailbox_cleanup_uid(as->mailbox, as->baseuid + i, "ZZ"); + } + + if (as->mailbox) r = mailbox_abort(as->mailbox); append_free(as); - return 0; + return r; } /* @@ -305,8 +318,8 @@ EXPORTED int append_abort(struct appendstate *as) * with the file for the given mailboxname and returns the open file * so it can double as the spool file */ -EXPORTED FILE *append_newstage(const char *mailboxname, time_t internaldate, - int msgnum, struct stagemsg **stagep) +EXPORTED FILE *append_newstage_full(const char *mailboxname, time_t internaldate __attribute__((unused)), + int msgnum __attribute__((unused)), struct stagemsg **stagep, const char *sourcefile) { struct stagemsg *stage; char stagedir[MAX_MAILBOX_PATH+1], stagefile[MAX_MAILBOX_PATH+1]; @@ -321,8 +334,8 @@ EXPORTED FILE *append_newstage(const char *mailboxname, time_t internaldate, stage = xmalloc(sizeof(struct stagemsg)); strarray_init(&stage->parts); - snprintf(stage->fname, sizeof(stage->fname), "%d-%d-%d", - (int) getpid(), (int) internaldate, msgnum); + snprintf(stage->fname, sizeof(stage->fname), "%d-%" PRIu64, + (int) getpid(), append_counter++); r = mboxlist_findstage(mailboxname, stagedir, sizeof(stagedir)); if (r) { @@ -335,8 +348,20 @@ EXPORTED FILE *append_newstage(const char *mailboxname, time_t internaldate, strlcat(stagefile, stage->fname, sizeof(stagefile)); /* create this file and put it into stage->parts[0] */ - unlink(stagefile); - f = fopen(stagefile, "w+"); + xunlink(stagefile); + if (sourcefile) { + r = mailbox_copyfile(sourcefile, stagefile, 0); + if (r) { + syslog(LOG_ERR, "couldn't copy stagefile '%s' for mbox: '%s': %s", + sourcefile, mailboxname, error_message(r)); + free(stage); + return NULL; + } + f = fopen(stagefile, "r+"); + } + else { + f = fopen(stagefile, "w+"); + } if (!f) { if (mkdir(stagedir, 0755) != 0) { syslog(LOG_ERR, "couldn't create stage directory: %s: %m", @@ -348,8 +373,8 @@ EXPORTED FILE *append_newstage(const char *mailboxname, time_t internaldate, } } if (!f) { - syslog(LOG_ERR, "IOERROR: creating message file %s: %m", - stagefile); + xsyslog(LOG_ERR, "IOERROR: creating message file", + "filename=<%s>", stagefile); strarray_fini(&stage->parts); free(stage); return NULL; @@ -417,10 +442,9 @@ static int callout_receive_reply(const char *callout, } p = prot_new(fd, /*write*/0); - prot_setisclient(p, 1); /* read and parse the reply as a dlist */ - c = dlist_parse(results, /*parsekeys*/0, /*isbackup*/0, p); + c = dlist_parse(results, /*parsekeys*/0, /*isarchive*/0, /*isbackup*/0, p); r = (c == EOF ? IMAP_SYS_ERROR : 0); out: @@ -508,11 +532,11 @@ static int callout_run_executable(const char *callout, /* child process */ close(inpipe[PIPE_WRITE]); - dup2(inpipe[PIPE_READ], /*FILENO_STDIN*/0); + dup2(inpipe[PIPE_READ], STDIN_FILENO); close(inpipe[PIPE_READ]); close(outpipe[PIPE_READ]); - dup2(outpipe[PIPE_WRITE], /*FILENO_STDOUT*/1); + dup2(outpipe[PIPE_WRITE], STDOUT_FILENO); close(outpipe[PIPE_WRITE]); execl(callout, callout, (char *)NULL); @@ -786,48 +810,67 @@ static int append_apply_flags(struct appendstate *as, for (i = 0; i < flags->count; i++) { const char *flag = strarray_nth(flags, i); + long need_rights = 0; // keep track of required ACLs + if (!strcasecmp(flag, "\\seen")) { - r = append_setseen(as, msgrec); - if (r) goto out; - mboxevent_add_flag(mboxevent, flag); + if (as->myrights & (need_rights = ACL_SETSEEN)) { + r = append_setseen(as, msgrec); + if (r) goto out; + mboxevent_add_flag(mboxevent, flag); + } } else if (!strcasecmp(flag, "\\expunged")) { /* NOTE - this is a fake internal name */ - if (as->myrights & ACL_DELETEMSG) { + if (as->myrights & (need_rights = ACL_DELETEMSG)) { internal_flags |= FLAG_INTERNAL_EXPUNGED; } } + else if (!strcasecmp(flag, "\\snoozed")) { + /* NOTE - this is a fake internal name */ + if (as->myrights & (need_rights = ACL_WRITE)) { + internal_flags |= FLAG_INTERNAL_SNOOZED; + } + } else if (!strcasecmp(flag, "\\deleted")) { - if (as->myrights & ACL_DELETEMSG) { + if (as->myrights & (need_rights = ACL_DELETEMSG)) { system_flags |= FLAG_DELETED; mboxevent_add_flag(mboxevent, flag); } } else if (!strcasecmp(flag, "\\draft")) { - if (as->myrights & ACL_WRITE) { + if (as->myrights & (need_rights = ACL_WRITE)) { system_flags |= FLAG_DRAFT; mboxevent_add_flag(mboxevent, flag); } } else if (!strcasecmp(flag, "\\flagged")) { - if (as->myrights & ACL_WRITE) { + if (as->myrights & (need_rights = ACL_WRITE)) { system_flags |= FLAG_FLAGGED; mboxevent_add_flag(mboxevent, flag); } } else if (!strcasecmp(flag, "\\answered")) { - if (as->myrights & ACL_WRITE) { + if (as->myrights & (need_rights = ACL_WRITE)) { system_flags |= FLAG_ANSWERED; mboxevent_add_flag(mboxevent, flag); } } - else if (as->myrights & ACL_WRITE) { + else if (as->myrights & (need_rights = ACL_WRITE)) { r = mailbox_user_flag(as->mailbox, flag, &userflag, 1); if (r) goto out; r = msgrecord_set_userflag(msgrec, userflag, 1); if (r) goto out; mboxevent_add_flag(mboxevent, flag); } + + if (need_rights && !(as->myrights & need_rights)) { + // One or more ACLs were missing to set the flag + char aclstr[ACL_STRING_MAX]; + cyrus_acl_masktostr(need_rights, aclstr); + xsyslog(LOG_ERR, "could not write flag due missing ACL", + "flag=<%s> need_rights=<%s> mailboxid=<%s>", + flag, aclstr, mailbox_uniqueid(as->mailbox)); + } } r = msgrecord_add_systemflags(msgrec, system_flags); @@ -840,6 +883,49 @@ static int append_apply_flags(struct appendstate *as, return r; } +struct findstage_cb_rock { + const char *partition; + const char *guid; + char *fname; +}; + +static int findstage_cb(const conv_guidrec_t *rec, void *vrock) +{ + struct findstage_cb_rock *rock = vrock; + mbentry_t *mbentry = NULL; + + if (rec->part) return 0; + // no point copying from archive, spool is on data + if (rec->internal_flags & FLAG_INTERNAL_ARCHIVED) return 0; + + int r = conv_guidrec_mbentry(rec, &mbentry); + if (r) return 0; + + if (!strcmp(rock->partition, mbentry->partition)) { + struct stat sbuf; + const char *msgpath = mbentry_datapath(mbentry, rec->uid); + if (msgpath && !stat(msgpath, &sbuf)) { + FILE *file = fopen(msgpath, "r"); + // errors just mean "skip this file" + if (file) { + struct body *body = NULL; + r = message_parse_file(file, NULL, NULL, &body, msgpath); + if (!r && !strcmp(rock->guid, message_guid_encode(&body->guid))) + rock->fname = xstrdup(msgpath); + if (body) { + message_free_body(body); + free(body); + } + fclose(file); + } + } + } + + mboxlist_entry_free(&mbentry); + + return rock->fname ? CYRUSDB_DONE : 0; +} + /* * staging, to allow for single-instance store. the complication here * is multiple partitions. @@ -847,17 +933,19 @@ static int append_apply_flags(struct appendstate *as, * Note: @user_annots needs to be freed by the caller but * may be modified during processing of callout responses. */ -EXPORTED int append_fromstage(struct appendstate *as, struct body **body, - struct stagemsg *stage, time_t internaldate, - modseq_t createdmodseq, - const strarray_t *flags, int nolink, - struct entryattlist *user_annots) +EXPORTED int append_fromstage_full(struct appendstate *as, struct body **body, + struct stagemsg *stage, + time_t internaldate, time_t savedate, + modseq_t createdmodseq, + const strarray_t *flags, int nolink, + struct entryattlist **user_annotsp) { struct mailbox *mailbox = as->mailbox; msgrecord_t *msgrec = NULL; const char *fname; int i, r; strarray_t *newflags = NULL; + struct entryattlist *user_annots = user_annotsp ? *user_annotsp : NULL; struct entryattlist *system_annots = NULL; struct mboxevent *mboxevent = NULL; #if defined ENABLE_OBJECTSTORE @@ -865,7 +953,8 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, #endif /* for staging */ - char stagefile[MAX_MAILBOX_PATH+1]; + char stagefile[MAX_MAILBOX_PATH+1] = ""; + char *linkfile = NULL; assert(stage != NULL && stage->parts.count); @@ -873,7 +962,7 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, if (!*body) { FILE *file = fopen(stage->parts.data[0], "r"); if (file) { - r = message_parse_file(file, NULL, NULL, body); + r = message_parse_file(file, NULL, NULL, body, stage->parts.data[0]); fclose(file); } else @@ -882,9 +971,33 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, } /* xxx check errors */ - mboxlist_findstage(mailbox->name, stagefile, sizeof(stagefile)); + mboxlist_findstage(mailbox_name(mailbox), stagefile, sizeof(stagefile)); strlcat(stagefile, stage->fname, sizeof(stagefile)); + if (!nolink) { + /* attempt to find an existing message with the same guid + and use it as the stagefile */ + struct conversations_state *cstate = mailbox_get_cstate(mailbox); + + if (cstate) { + char *guid = xstrdup(message_guid_encode(&(*body)->guid)); + struct findstage_cb_rock rock = { mailbox_partition(mailbox), guid, NULL }; + + // ignore errors, it's OK for this to fail + conversations_guid_foreach(cstate, guid, findstage_cb, &rock); + + // if we found a file, remember it + if (rock.fname) { + syslog(LOG_NOTICE, "found existing file %s for %s; linking", guid, rock.fname); + linkfile = rock.fname; + } + + free(guid); + } + } + + if (linkfile) goto havefile; + for (i = 0 ; i < stage->parts.count ; i++) { /* ok, we've successfully created the file */ if (!strcmp(stagefile, stage->parts.data[i])) { @@ -903,7 +1016,7 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, char stagedir[MAX_MAILBOX_PATH+1]; /* xxx check errors */ - mboxlist_findstage(mailbox->name, stagedir, sizeof(stagedir)); + mboxlist_findstage(mailbox_name(mailbox), stagedir, sizeof(stagedir)); if (mkdir(stagedir, 0755) != 0) { syslog(LOG_ERR, "couldn't create stage directory: %s: %m", stagedir); @@ -916,16 +1029,18 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, if (r) { /* oh well, we tried */ - syslog(LOG_ERR, "IOERROR: creating message file %s: %m", - stagefile); - unlink(stagefile); + xsyslog(LOG_ERR, "IOERROR: creating message file", + "filename=<%s>", stagefile); + xunlink(stagefile); goto out; } strarray_append(&stage->parts, stagefile); } - /* 'stagefile' contains the message and is on the same partition +havefile: + + /* 'linkfile' or 'stagefile' contains the message and is on the same partition as the mailbox we're looking at */ /* Setup */ @@ -948,6 +1063,10 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, if (r) goto out; r = msgrecord_set_bodystructure(msgrec, *body); if (r) goto out; + if (savedate) { + r = msgrecord_set_savedate(msgrec, savedate); + if (r) goto out; + } /* And make sure it has a timestamp */ r = msgrecord_get_internaldate(msgrec, &internaldate); @@ -973,7 +1092,7 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, r = msgrecord_get_fname(msgrec, &fname); if (r) goto out; - r = mailbox_copyfile(stagefile, fname, nolink); + r = mailbox_copyfile(linkfile ? linkfile : stagefile, fname, nolink); if (r) goto out; FILE *destfile = fopen(fname, "r"); @@ -995,10 +1114,11 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, newflags = strarray_new(); r = callout_run(fname, *body, &user_annots, &system_annots, newflags); if (r) { - syslog(LOG_ERR, "Annotation callout failed, ignoring\n"); + syslog(LOG_ERR, "Annotation callout failed, ignoring"); r = 0; } flags = newflags; + if (user_annotsp) *user_annotsp = user_annots; } /* straight to archive? */ @@ -1040,34 +1160,22 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, } } - if (as->isoutbox) { - char num[10]; - uint32_t uid; - - r = msgrecord_get_uid(msgrec, &uid); - if (r) goto out; - r = msgrecord_get_internaldate(msgrec, &internaldate); - if (r) goto out; - - snprintf(num, 10, "%u", uid); - r = notify_at(internaldate, "sendemail", "append", "", "", as->mailbox->name, 0, NULL, num); - if (r) goto out; - } - /* Write the new message record */ r = msgrecord_append(msgrec); - if (r) return r; + if (r) goto out; if (in_object_storage) { // must delete local file - if (unlink(fname) != 0) // unlink should do it. + if (xunlink(fname) != 0) // unlink should do it. if (!remove (fname)) // we must insist - syslog(LOG_ERR, "Removing local file <%s> error \n", fname); + syslog(LOG_ERR, "Removing local file <%s> error", fname); } /* Apply the annotations */ if (user_annots || system_annots) { + /* pretend to be admin to avoid ACL checks when writing annotations here, since there calling user + * didn't control them */ if (user_annots) { - r = msgrecord_annot_set_auth(msgrec, as->isadmin, as->userid, as->auth_state); + r = msgrecord_annot_set_auth(msgrec, /*isadmin*/1, as->userid, as->auth_state); if (!r) r = msgrecord_annot_writeall(msgrec, user_annots); } if (r) { @@ -1075,7 +1183,6 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, goto out; } if (system_annots) { - /* pretend to be admin to avoid ACL checks */ r = msgrecord_annot_set_auth(msgrec, /*isadmin*/1, as->userid, as->auth_state); if (!r) r = msgrecord_annot_writeall(msgrec, system_annots); } @@ -1089,8 +1196,10 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, if (newflags) strarray_free(newflags); freeentryatts(system_annots); + free(linkfile); if (r) { append_abort(as); + msgrecord_unref(&msgrec); return r; } @@ -1099,10 +1208,10 @@ EXPORTED int append_fromstage(struct appendstate *as, struct body **body, * present in body structure ? */ mboxevent_extract_msgrecord(mboxevent, msgrec); mboxevent_extract_mailbox(mboxevent, mailbox); - mboxevent_set_access(mboxevent, NULL, NULL, as->userid, as->mailbox->name, 1); + mboxevent_set_access(mboxevent, NULL, NULL, as->userid, mailbox_name(as->mailbox), 1); mboxevent_set_numunseen(mboxevent, mailbox, -1); - if (msgrec) msgrecord_unref(&msgrec); + msgrecord_unref(&msgrec); return r; } @@ -1114,8 +1223,9 @@ EXPORTED int append_removestage(struct stagemsg *stage) while ((p = strarray_pop(&stage->parts))) { /* unlink the staging file */ - if (unlink(p) != 0) { - syslog(LOG_ERR, "IOERROR: error unlinking file %s: %m", p); + if (xunlink(p) != 0) { + xsyslog(LOG_ERR, "IOERROR: error unlinking file", + "filename=<%s>", p); } free(p); } @@ -1166,10 +1276,11 @@ EXPORTED int append_fromstream(struct appendstate *as, struct body **body, if (r) goto out; as->nummsg++; - unlink(fname); + xunlink(fname); destfile = fopen(fname, "w+"); if (!destfile) { - syslog(LOG_ERR, "IOERROR: creating message file %s: %m", fname); + xsyslog(LOG_ERR, "IOERROR: creating message file", + "filename=<%s>", fname); r = IMAP_IOERROR; goto out; } @@ -1186,7 +1297,7 @@ EXPORTED int append_fromstream(struct appendstate *as, struct body **body, r = message_copy_strict(messagefile, destfile, size, 0); if (!r) { if (!*body || (as->nummsg - 1)) - r = message_parse_file(destfile, NULL, NULL, body); + r = message_parse_file(destfile, NULL, NULL, body, fname); if (!r) r = msgrecord_set_bodystructure(msgrec, *body); /* messageContent may be included with MessageAppend and MessageNew */ @@ -1217,7 +1328,7 @@ EXPORTED int append_fromstream(struct appendstate *as, struct body **body, * present in body structure */ mboxevent_extract_msgrecord(mboxevent, msgrec); mboxevent_extract_mailbox(mboxevent, mailbox); - mboxevent_set_access(mboxevent, NULL, NULL, as->userid, as->mailbox->name, 1); + mboxevent_set_access(mboxevent, NULL, NULL, as->userid, mailbox_name(as->mailbox), 1); mboxevent_set_numunseen(mboxevent, mailbox, -1); msgrecord_unref(&msgrec); @@ -1257,7 +1368,7 @@ HIDDEN int append_run_annotator(struct appendstate *as, goto out; } - r = message_parse_file(f, NULL, NULL, &body); + r = message_parse_file(f, NULL, NULL, &body, fname); if (r) goto out; fclose(f); @@ -1275,15 +1386,6 @@ HIDDEN int append_run_annotator(struct appendstate *as, } if (r) goto out; - /* Reset internal flags */ - uint32_t internal_flags; - r = msgrecord_get_internalflags(msgrec, &internal_flags); - if (!r) { - internal_flags &= (FLAGS_INTERNAL); - r = msgrecord_set_internalflags(msgrec, internal_flags); - } - if (r) goto out; - /* Reset user flags */ uint32_t user_flags[MAX_USER_FLAGS/32]; memset(user_flags, 0, sizeof(user_flags)); @@ -1299,26 +1401,17 @@ HIDDEN int append_run_annotator(struct appendstate *as, goto out; } - if (user_annots) { - r = msgrecord_annot_set_auth(msgrec, as->isadmin, as->userid, as->auth_state); - if (r) goto out; - r = msgrecord_annot_writeall(msgrec, user_annots); - if (r) { - syslog(LOG_ERR, "Setting user annotations from annotator " - "callout failed (%s)", - error_message(r)); - goto out; - } - } if (system_annots) { /* pretend to be admin to avoid ACL checks */ r = msgrecord_annot_set_auth(msgrec, /*isadmin*/1, as->userid, as->auth_state); if (r) goto out; r = msgrecord_annot_writeall(msgrec, system_annots); if (r) { + char *res = dumpentryatt(system_annots); syslog(LOG_ERR, "Setting system annotations from annotator " - "callout failed (%s)", - error_message(r)); + "callout failed (%s) for %s", + error_message(r), res); + free(res); goto out; } } @@ -1345,7 +1438,8 @@ HIDDEN int append_run_annotator(struct appendstate *as, * contains the name of the user whose \Seen flag gets set. */ EXPORTED int append_copy(struct mailbox *mailbox, struct appendstate *as, - ptrarray_t *msgrecs, int nolink, int is_same_user) + ptrarray_t *msgrecs, int nolink, int is_same_user, + struct progress_rock *prock) { int msg; char *srcfname = NULL; @@ -1377,6 +1471,8 @@ EXPORTED int append_copy(struct mailbox *mailbox, struct appendstate *as, uint32_t src_system_flags; uint32_t src_internal_flags; + if (prock) prock->cb(msg, msgrecs->count, prock); + r = msgrecord_get_uid(src_msgrec, &src_uid); if (r) goto out; r = msgrecord_get_systemflags(src_msgrec, &src_system_flags); @@ -1390,15 +1486,23 @@ EXPORTED int append_copy(struct mailbox *mailbox, struct appendstate *as, if (r) goto out; /* wipe out the bits that aren't magically copied */ - uint32_t dst_system_flags; + uint32_t dst_system_flags, dst_internal_flags; uint32_t dst_user_flags[MAX_USER_FLAGS/32]; dst_msgrec = msgrecord_copy_msgrecord(as->mailbox, src_msgrec); + /* clear savedate */ + r = msgrecord_set_savedate(dst_msgrec, 0); + if (r) goto out; + r = msgrecord_get_systemflags(dst_msgrec, &dst_system_flags); if (r) goto out; dst_system_flags &= ~FLAG_SEEN; + r = msgrecord_get_internalflags(dst_msgrec, &dst_internal_flags); + if (r) goto out; + dst_internal_flags &= ~FLAG_INTERNAL_SNOOZED; + for (i = 0; i < MAX_USER_FLAGS/32; i++) { dst_user_flags[i] = 0; } @@ -1428,13 +1532,18 @@ EXPORTED int append_copy(struct mailbox *mailbox, struct appendstate *as, for (userflag = 0; userflag < MAX_USER_FLAGS; userflag++) { bit32 flagmask = src_user_flags[userflag/32]; - if (mailbox->flagname[userflag] && (flagmask & (1<<(userflag&31)))) { + if (mailbox->h.flagname[userflag] && (flagmask & (1<<(userflag&31)))) { int num; - r = mailbox_user_flag(as->mailbox, mailbox->flagname[userflag], &num, 1); + r = mailbox_user_flag(as->mailbox, mailbox->h.flagname[userflag], &num, 1); if (r) - syslog(LOG_ERR, "IOERROR: unable to copy flag %s from %s to %s for UID %u: %s", - mailbox->flagname[userflag], mailbox->name, as->mailbox->name, - src_uid, error_message(r)); + xsyslog(LOG_ERR, "IOERROR: unable to copy flag", + "flag=<%s> src_mailbox=<%s> dest_mailbox=<%s>" + " uid=<%u> error=<%s>", + mailbox->h.flagname[userflag], + mailbox_name(mailbox), + mailbox_name(as->mailbox), + src_uid, + error_message(r)); else dst_user_flags[num/32] |= 1<<(num&31); } @@ -1442,8 +1551,10 @@ EXPORTED int append_copy(struct mailbox *mailbox, struct appendstate *as, r = msgrecord_set_userflags(dst_msgrec, dst_user_flags); if (r) { - syslog(LOG_ERR, "IOERROR: unable to copy user flags from %s to %s for UID %u: %s", - mailbox->name, as->mailbox->name, src_uid, error_message(r)); + xsyslog(LOG_ERR, "IOERROR: unable to copy user flags", + "source=<%s> dest=<%s> uid=<%u> error=<%s>", + mailbox_name(mailbox), mailbox_name(as->mailbox), + src_uid, error_message(r)); } } else { @@ -1463,7 +1574,7 @@ EXPORTED int append_copy(struct mailbox *mailbox, struct appendstate *as, if (r) goto out; /* set internal flags */ - r = msgrecord_set_internalflags(dst_msgrec, src_internal_flags); + r = msgrecord_set_internalflags(dst_msgrec, dst_internal_flags); if (r) goto out; /* should this message be marked \Seen? */ @@ -1499,16 +1610,6 @@ EXPORTED int append_copy(struct mailbox *mailbox, struct appendstate *as, } #endif - if (as->isoutbox) { - char num[10]; - time_t internaldate; - r = msgrecord_get_internaldate(dst_msgrec, &internaldate); - if (r) goto out; - snprintf(num, 10, "%u", dst_uid); - r = notify_at(internaldate, "sendemail", "append", "", "", as->mailbox->name, 0, NULL, num); - if (r) goto out; - } - /* Write out index file entry */ r = msgrecord_append(dst_msgrec); if (r) goto out; @@ -1533,16 +1634,15 @@ EXPORTED int append_copy(struct mailbox *mailbox, struct appendstate *as, out: free(srcfname); free(destfname); + msgrecord_unref(&dst_msgrec); + if (r) { append_abort(as); return r; } - if (dst_msgrec) { - msgrecord_unref(&dst_msgrec); - } mboxevent_extract_mailbox(mboxevent, as->mailbox); - mboxevent_set_access(mboxevent, NULL, NULL, as->userid, as->mailbox->name, 1); + mboxevent_set_access(mboxevent, NULL, NULL, as->userid, mailbox_name(as->mailbox), 1); mboxevent_set_numunseen(mboxevent, as->mailbox, -1); return 0; @@ -1569,21 +1669,30 @@ static int append_setseen(struct appendstate *as, msgrecord_t *msgrec) */ static int append_addseen(struct mailbox *mailbox, const char *userid, - struct seqset *newseen) + seqset_t *newseen) { int r; struct seen *seendb = NULL; struct seendata sd = SEENDATA_INITIALIZER; - struct seqset *oldseen; + seqset_t *oldseen; - if (!newseen->len) + if (!seqset_first(newseen)) return 0; r = seen_open(userid, SEEN_CREATE, &seendb); - if (r) goto done; + if (r) { + xsyslog(LOG_ERR, "IOERROR: seen_open failed", + "userid=<%s>", userid); + goto done; + } - r = seen_lockread(seendb, mailbox->uniqueid, &sd); - if (r) goto done; + r = seen_lockread(seendb, mailbox_uniqueid(mailbox), &sd); + if (r) { + xsyslog(LOG_ERR, "IOERROR: seen_lockread failed", + "userid=<%s> uniqueid=<%s>", + userid, mailbox_uniqueid(mailbox)); + goto done; + } /* parse the old sequence */ oldseen = seqset_parse(sd.seenuids, NULL, mailbox->i.last_uid); @@ -1592,11 +1701,16 @@ static int append_addseen(struct mailbox *mailbox, /* add the extra items */ seqset_join(oldseen, newseen); sd.seenuids = seqset_cstring(oldseen); - seqset_free(oldseen); + seqset_free(&oldseen); /* and write it out */ sd.lastchange = time(NULL); - r = seen_write(seendb, mailbox->uniqueid, &sd); + r = seen_write(seendb, mailbox_uniqueid(mailbox), &sd); + if (r) { + xsyslog(LOG_ERR, "IOERROR: seen_write failed", + "userid=<%s> uniqueid=<%s>", + userid, mailbox_uniqueid(mailbox)); + } seen_freedata(&sd); done: diff --git a/imap/append.h b/imap/append.h index 3bc610be0f..27eaf6d89d 100644 --- a/imap/append.h +++ b/imap/append.h @@ -43,11 +43,12 @@ #ifndef INCLUDED_APPEND_H #define INCLUDED_APPEND_H +#include "index.h" #include "mailbox.h" #include "mboxevent.h" #include "message.h" #include "prot.h" -#include "sequence.h" +#include "seqset.h" #include "strarray.h" #include "annotate.h" #include "conversations.h" @@ -58,8 +59,7 @@ struct appendstate { /* mailbox we're appending to */ struct mailbox *mailbox; /* do we own it? */ - int close_mailbox_when_done:1; - int isoutbox:1; + unsigned int close_mailbox_when_done:1; int myrights; char userid[MAX_MAILBOX_BUFFER]; @@ -72,7 +72,7 @@ struct appendstate { /* set seen on these message on commit */ int internalseen; - struct seqset *seen_seq; + seqset_t *seen_seq; /* for annotations */ const struct namespace *namespace; @@ -113,15 +113,20 @@ extern int append_commit(struct appendstate *as); extern int append_abort(struct appendstate *as); /* creates a new stage and returns stage file corresponding to mailboxname */ -extern FILE *append_newstage(const char *mailboxname, time_t internaldate, - int msgnum, struct stagemsg **stagep); +extern FILE *append_newstage_full(const char *mailboxname, time_t internaldate, + int msgnum, struct stagemsg **stagep, + const char *sourcefile); +#define append_newstage(m, i, n, s) append_newstage_full((m), (i), (n), (s), NULL) /* adds a new mailbox to the stage initially created by append_newstage() */ -extern int append_fromstage(struct appendstate *mailbox, struct body **body, - struct stagemsg *stage, time_t internaldate, - modseq_t createdmodseq, - const strarray_t *flags, int nolink, - struct entryattlist *annotations); +extern int append_fromstage_full(struct appendstate *mailbox, struct body **body, + struct stagemsg *stage, + time_t internaldate, time_t savedate, + modseq_t createdmodseq, + const strarray_t *flags, int nolink, + struct entryattlist **annotations); +#define append_fromstage(m, b, s, i, c, f, n, a) \ + append_fromstage_full((m), (b), (s), (i), 0, (c), (f), (n), (a)) /* removes the stage (frees memory, deletes the staging files) */ extern int append_removestage(struct stagemsg *stage); @@ -134,13 +139,14 @@ extern int append_fromstream(struct appendstate *as, struct body **body, extern int append_copy(struct mailbox *mailbox, struct appendstate *append_mailbox, ptrarray_t *msgrecs, - int nolink, int is_same_user); + int nolink, int is_same_user, + struct progress_rock *prock); extern int append_collectnews(struct appendstate *mailbox, const char *group, unsigned long feeduid); -#define append_getuidvalidity(as) ((as)->m.uidvalidity); -#define append_getlastuid(as) ((as)->m.last_uid); +#define append_getuidvalidity(as) ((as)->m.uidvalidity) +#define append_getlastuid(as) ((as)->m.last_uid) extern int append_run_annotator(struct appendstate *as, msgrecord_t *msgrec); diff --git a/imap/arbitron.c b/imap/arbitron.c index 6ef2b76522..882e25b960 100644 --- a/imap/arbitron.c +++ b/imap/arbitron.c @@ -45,6 +45,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -107,7 +108,7 @@ static void process_seen(const char *path, const char *user); static void process_subs(const char *path, const char *user); static int do_mailbox(struct findall_data *data, void *rock); -int main(int argc,char **argv) +int main(int argc, char **argv) { int opt, r; int report_days = 30; @@ -116,11 +117,27 @@ int main(int argc,char **argv) char *alt_config = NULL; time_t now = time(0); + /* keep these in alphabetical order */ + static const char short_options[] = "C:D:d:lop:u"; + + static const struct option long_options[] = { + /* n.b. no long form for -C option */ + { "date", required_argument, NULL, 'D' }, + { "days", required_argument, NULL, 'd' }, + { "detailed", no_argument, NULL, 'l' }, + { "no-subscribers", no_argument, NULL, 'o' }, + { "prune-seen", required_argument, NULL, 'p' }, + { "include-userids", no_argument, NULL, 'u' }, + { 0, 0, 0, 0 }, + }; + strcpy(pattern, "*"); report_end_time = now; - while ((opt = getopt(argc, argv, "C:oud:D:p:l")) != EOF) { + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; @@ -186,7 +203,7 @@ int main(int argc,char **argv) cyrus_init(alt_config, "arbitron", 0, 0); /* Set namespace -- force standard (internal) */ - if ((r = mboxname_init_namespace(&arb_namespace, 1)) != 0) { + if ((r = mboxname_init_namespace(&arb_namespace, NAMESPACE_OPTION_ADMIN))) { syslog(LOG_ERR, "%s", error_message(r)); fatal(error_message(r), EX_CONFIG); } @@ -242,6 +259,7 @@ static void usage(void) static int do_mailbox(struct findall_data *data, void *rock __attribute__((unused))) { if (!data) return 0; + if (!data->is_exactmatch) return 0; int r; struct mailbox *mailbox = NULL; const char *name = mbname_intname(data->mbname); @@ -257,7 +275,7 @@ static int do_mailbox(struct findall_data *data, void *rock __attribute__((unuse d->readers = NULL; d->subscribers = NULL; - hash_insert(mailbox->uniqueid, d, &mailbox_table); + hash_insert(mailbox_uniqueid(mailbox), d, &mailbox_table); hash_insert(name, d, &mboxname_table); mailbox_close(&mailbox); diff --git a/imap/attachextract.c b/imap/attachextract.c new file mode 100644 index 0000000000..ea1a69dd14 --- /dev/null +++ b/imap/attachextract.c @@ -0,0 +1,651 @@ +/* attachextract.c -- Routines for extracting text from attachments + * + * Copyright (c) 1994-2008 Carnegie Mellon University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#include +#include + +#include "backend.h" +#include "global.h" +#include "http_client.h" +#include "map.h" +#include "retry.h" +#include "util.h" +#include "xunlink.h" + +/* generated headers are not necessarily in current directory */ +#include "imap/http_err.h" +#include "imap/imap_err.h" + +#include "attachextract.h" + +struct extractor_ctx { + struct protstream *clientin; + char *hostname; + char *path; + struct backend *be; +}; + +static char *attachextract_cachedir = NULL; +static int attachextract_cacheonly = 0; +static unsigned attachextract_idle_timeout = 5 * 60; +static unsigned attachextract_request_timeout = 5 * 60; + +static struct extractor_ctx *global_extractor = NULL; + +static void extractor_disconnect(struct extractor_ctx *ext) +{ + if (!ext) return; + + struct backend *be = ext->be; + syslog(LOG_DEBUG, "extractor_disconnect(%p)", be); + + if (!be || (be->sock == -1)) { + /* already disconnected */ + return; + } + + /* need to logout of server */ + backend_disconnect(be); + + /* remove the timeout */ + if (be->timeout) prot_removewaitevent(be->clientin, be->timeout); + be->timeout = NULL; + be->clientin = NULL; +} + +static struct prot_waitevent * +extractor_idle_timeout_cb(struct protstream *s __attribute__((unused)), + struct prot_waitevent *ev __attribute__((unused)), + void *rock) +{ + struct extractor_ctx *ext = rock; + + syslog(LOG_DEBUG, "extractor_idle_timeout(%p)", ext); + + /* too long since we last used the extractor - disconnect */ + extractor_disconnect(ext); + + return NULL; +} + +static int login(struct backend *s __attribute__((unused)), + const char *userid __attribute__((unused)), + sasl_callback_t *cb __attribute__((unused)), + const char **status __attribute__((unused)), + int noauth __attribute__((unused))) +{ + return 0; +} + +static int ping(struct backend *s __attribute__((unused)), + const char *userid __attribute__((unused))) +{ + return 0; +} + +static int logout(struct backend *s __attribute__((unused))) +{ + return 0; +} + + +static struct protocol_t http = +{ "http", "HTTP", TYPE_SPEC, { .spec = { &login, &ping, &logout } } }; + +static int extractor_connect(struct extractor_ctx *ext) +{ + struct backend *be; + time_t now = time(NULL); + + syslog(LOG_DEBUG, "extractor_connect()"); + + be = ext->be; + if (be && be->sock != -1) { + // extend the timeout + if (be->timeout) { + be->timeout->mark = now + attachextract_idle_timeout; + xsyslog(LOG_DEBUG, "keep using socket with timeout mark", + "sockfd=<%d> timeout_mark=<" TIME_T_FMT ">", + be->sock, be->timeout->mark); + } + else { + xsyslog(LOG_DEBUG, "keep using socket", + "sockfd=<%d>", be->sock); + } + return 0; + } + + // clean up any existing connection + extractor_disconnect(ext); + be = ext->be = backend_connect(be, ext->hostname, + &http, NULL, NULL, NULL, -1); + + if (!be) { + syslog(LOG_ERR, "extract_connect: failed to connect to %s", + ext->hostname); + return IMAP_IOERROR; + } + + // set request timeout + prot_settimeout(be->in, attachextract_request_timeout); + + if (ext->clientin) { + /* set idle timeout */ + be->clientin = ext->clientin; + be->timeout = prot_addwaitevent(ext->clientin, + now + attachextract_idle_timeout, extractor_idle_timeout_cb, ext); + } + + return 0; +} + +static int extractor_httpreq(struct extractor_ctx *ext, + const char *method, + const char *guidstr, + const char *req_ctype, + const struct buf *req_body, + unsigned *res_statuscode, + struct body_t *res_body) +{ + struct buf req_buf = BUF_INITIALIZER; + size_t hostlen = strcspn(ext->hostname, "/"); + hdrcache_t res_hdrs = NULL; + const char **hdr; + *res_statuscode = HTTP_BAD_GATEWAY; + int r = IMAP_INTERNAL; + struct buf url_buf = BUF_INITIALIZER; + buf_printf(&url_buf, "%s/%s", ext->path, guidstr); + const char *url = buf_cstring(&url_buf); + + xsyslog(LOG_DEBUG, "starting HTTP request", + "method=<%s> guid=<%s>", method, guidstr); + + // Prepare request + buf_printf(&req_buf, + "%s %s %s\r\n" + "Host: %.*s\r\n" + "User-Agent: Cyrus/%s\r\n" + "Connection: Keep-Alive\r\n" + "Keep-Alive: timeout=%u\r\n" + "Accept: text/plain\r\n" + "X-Truncate-Length: " SIZE_T_FMT "\r\n", + method, url, HTTP_VERSION, + (int) hostlen, ext->be->hostname, CYRUS_VERSION, + attachextract_idle_timeout, config_search_maxsize); + + if (req_body) { + buf_printf(&req_buf, + "Content-Type: %s\r\n", + req_ctype ? req_ctype : "application/octet-stream"); + + buf_printf(&req_buf, + "Content-Length: " SIZE_T_FMT "\r\n", + buf_len(req_body)); + } + + buf_appendcstr(&req_buf, "\r\n"); + + int retry = 0; + do { + // Connect to backend + r = extractor_connect(ext); + if (r) goto done; + + struct backend *be = ext->be; + + // Send request + prot_settimeout(be->in, attachextract_idle_timeout); + + r = prot_putbuf(be->out, &req_buf); + + if (!r && req_body) + r = prot_putbuf(be->out, req_body); + + if (!r) + r = prot_flush(be->out); + + if (r == EOF) { + r = IMAP_IOERROR; + xsyslog(LOG_DEBUG, + "failed to send HTTP request", + "method=<%s> url=<%s> err=<%s>", + method, url, error_message(r)); + extractor_disconnect(ext); + retry++; + continue; + } + + // Read response + const char *res_err = NULL; + *res_statuscode = 599; + uint64_t prev_bytes_in = be->in->bytes_in; + + do { + r = http_read_response(be, + !strcmp(method, "GET") ? METH_GET : METH_PUT, + res_statuscode, &res_hdrs, res_body, &res_err); + } while (*res_statuscode < 200 && !r); + + // Reconnect if the socket is closed + if (r == HTTP_BAD_GATEWAY && + be->in->eof && prev_bytes_in == be->in->bytes_in && + time(NULL) < be->in->timeout_mark) { + xsyslog(LOG_DEBUG, + "no bytes read from socket - retrying", + "method=<%s> url=<%s>", method, url); + extractor_disconnect(ext); + retry++; + } + // Reconnect if the connection expired + else if (r == HTTP_TIMEOUT && + (res_hdrs && + (hdr = spool_getheader(res_hdrs, "Connection")) && + !strcasecmpsafe(hdr[0], "close") && + time(NULL) < be->in->timeout_mark)) { + xsyslog(LOG_DEBUG, + "keep-alive connection got closed - retrying", + "method=<%s> url=<%s>", method, url); + extractor_disconnect(ext); + retry++; + } + // Handle response + else { + if (r) { + xsyslog(LOG_ERR, + "failed to read HTTP response", + "method=<%s> url=<%s> res_err=<%s> err=<%s>", + method, url, res_err, error_message(r)); + *res_statuscode = 599; + } + else xsyslog( + (*res_statuscode == 200 || *res_statuscode == 201 || + *res_statuscode == 404) ? LOG_DEBUG : LOG_WARNING, + "got HTTP response", "method=<%s> url=<%s> statuscode=<%d>", + method, url, *res_statuscode); + + if (*res_statuscode == 200 || *res_statuscode == 201) { + /* Abide by server's timeout, if any */ + const char *p; + if (res_hdrs && + (hdr = spool_getheader(res_hdrs, "Keep-Alive")) && + (p = strstr(hdr[0], "timeout="))) { + int timeout = atoi(p+8); + if (be->timeout) be->timeout->mark = time(NULL) + timeout; + } + } + retry = 0; + } + } while (retry && retry < 3); + +done: + xsyslog(LOG_DEBUG, "ending HTTP request", + "method=<%s> guid=<%s> statuscode=<%d> r=<%s>", + method, guidstr, *res_statuscode, error_message(r)); + + if (r) { + xsyslog(LOG_WARNING, "failed HTTP request - resetting connection", + "method=<%s> guid=<%s> statuscode=<%d> r=<%s>", + method, guidstr, *res_statuscode, error_message(r)); + extractor_disconnect(ext); + } + + spool_free_hdrcache(res_hdrs); + buf_free(&req_buf); + buf_free(&url_buf); + return r; +} + + +static void generate_record_id(struct buf *id, const struct attachextract_record *rec) +{ + // encode content guid + buf_putc(id, 'G'); + buf_appendcstr(id, message_guid_encode(&rec->guid)); + + // encode media type, make sure it's safe to use as file name + buf_putc(id, '-'); + const char *types[2] = { rec->type, rec->subtype }; + for (int i = 0; i < 2; i++) { + + if (i) buf_putc(id, '_'); + + for (const char *s = types[i]; *s; s++) { + if (('a' <= *s && *s <= 'z') || + ('A' <= *s && *s <= 'Z') || + ('0' <= *s && *s <= '9')) { + buf_putc(id, TOLOWER(*s)); + } + else { + buf_putc(id, '%'); + buf_printf(id, "%02x", (unsigned char) *s); + } + } + } +} + +EXPORTED int attachextract_extract(const struct attachextract_record *axrec, + const struct buf *data, + int encoding, + struct buf *text) +{ + struct extractor_ctx *ext = global_extractor; + struct body_t body = { 0, 0, 0, 0, 0, BUF_INITIALIZER }; + const char *guidstr = message_guid_encode(&axrec->guid); + char *cachefname = NULL; + char *ctype = NULL; + struct buf buf = BUF_INITIALIZER; + unsigned statuscode = 0; + int is_cached = 0; + int retry; + int r = 0; + + if (!global_extractor) { + /* This is a legitimate case for sieve and lmtpd (so we don't need + * to spam the logs! */ + xsyslog(LOG_DEBUG, "ignoring uninitialized extractor", NULL); + return 0; + } + + if (!axrec->type || !axrec->subtype) { + xsyslog(LOG_DEBUG, "ignoring incomplete MIME type", + "type=<%s> subtype<%s>", + axrec->type ? axrec->type : "", + axrec->subtype ? axrec->subtype : ""); + return IMAP_NOTFOUND; + } + + if (message_guid_isnull(&axrec->guid)) { + xsyslog(LOG_DEBUG, "ignoring null guid", "mime_type=<%s/%s>", + axrec->type, axrec->subtype); + return 0; + } + + if (attachextract_cachedir) { + generate_record_id(&buf, axrec); + cachefname = strconcat(attachextract_cachedir, "/", buf_cstring(&buf), NULL); + buf_reset(&buf); + } + else if (attachextract_cacheonly) { + xsyslog(LOG_ERR, + "cache-only flag is set, but no cache directory is configured", NULL); + r = IMAP_NOTFOUND; + goto done; + } + + /* Build Content-Type */ + buf_printf(&buf, "%s/%s", axrec->type, axrec->subtype); + ctype = buf_release(&buf); + + /* Fetch from cache */ + if (cachefname) { + int fd = open(cachefname, O_RDONLY); + if (fd != -1) { + struct buf cache_data = BUF_INITIALIZER; + buf_refresh_mmap(&cache_data, 1, fd, cachefname, MAP_UNKNOWN_LEN, NULL); + buf_copy(text, &cache_data); + buf_free(&cache_data); + close(fd); + + xsyslog(LOG_DEBUG, "read from cache", + "cachefname=<%s>", cachefname); + is_cached = 1; + goto gotdata; + } + else { + xsyslog(LOG_DEBUG, "not found in cache", + "cachefname=<%s>", cachefname); + } + } + + if (attachextract_cacheonly) { + xsyslog(LOG_DEBUG, + "cache-only flag is set, will not call extractor", NULL); + r = IMAP_NOTFOUND; + goto done; + } + + /* Fetch from network */ + r = extractor_httpreq(ext, "GET", guidstr, NULL, NULL, &statuscode, &body); + + if (statuscode == 200) { + buf_copy(text, &body.payload); + goto gotdata; + } + else if (statuscode == 422) { + // handle unprocessable content like empty file + buf_reset(text); + goto gotdata; + } + + if (statuscode == 599) goto done; + + // otherwise we're going to try three times to PUT this request to the server! + + /* Decode data */ + if (encoding) { + if (charset_decode(&buf, buf_base(data), buf_len(data), encoding)) { + syslog(LOG_ERR, "extract_attachment: failed to decode data"); + r = IMAP_IOERROR; + goto done; + } + data = &buf; + } + + for (retry = 0; retry < 3; retry++) { + if (retry) { + // second and third time around, sleep + sleep(retry); + } + + /* Send attachment to service for text extraction */ + r = extractor_httpreq(ext, "PUT", guidstr, ctype, data, + &statuscode, &body); + if (r == IMAP_IOERROR) goto done; + + if (statuscode == 200 || statuscode == 201) { + // we got a result, yay + buf_copy(text, &body.payload); + goto gotdata; + } + else if (statuscode == 422) { + // handle unprocessable content like empty file + buf_reset(text); + goto gotdata; + } + + if ((statuscode >= 400 && statuscode <= 499) || statuscode == 599) { + /* indexer can't extract this for some reason, never try again */ + goto done; + } + + // Keep trying + } + + // dropped out of the loop? Then we failed! + xsyslog(LOG_ERR, "exhausted retry attempts - giving up", + "retry=<%d>", retry); + r = IMAP_IOERROR; + goto done; + +gotdata: + xsyslog(LOG_DEBUG, is_cached ? + "read cached attachment extract" : + "extracted text from attachment", + "guid=<%s> content_type=<%s> size=<%zu>", + guidstr, ctype, buf_len(text)); + + if (!is_cached && cachefname) { + /* Add to cache */ + char *tempfname = strconcat(cachefname, ".download.XXXXXX", NULL); + int fd = mkstemp(tempfname); + if (fd != -1) { + int wr = retry_write(fd, buf_base(text), buf_len(text)); + close(fd); + + if (wr == -1) { + xsyslog(LOG_WARNING, "failed to write temp file", + "tempfname=<%s>", tempfname); + } + else { + if (rename(tempfname, cachefname)) { + xsyslog(LOG_WARNING, "failed to rename tempfile to cache file", + "tempfname=<%s> cachefname=<%s>", + tempfname, cachefname); + } + else xsyslog(LOG_DEBUG, "wrote to cache", + "cachefname=<%s>", cachefname); + } + + xunlink(tempfname); + } + else xsyslog(LOG_WARNING, "could not create temp file", + "tempfname=<%s>", tempfname); + free(tempfname); + } + +done: + if (statuscode == 599) { + extractor_disconnect(ext); + } + free(cachefname); + buf_free(&body.payload); + buf_free(&buf); + free(ctype); + return r; +} + +EXPORTED void attachextract_init(struct protstream *clientin) +{ + syslog(LOG_DEBUG, "extractor_init(%p)", clientin); + + /* Read config */ + attachextract_idle_timeout = + config_getduration(IMAPOPT_SEARCH_ATTACHMENT_EXTRACTOR_IDLE_TIMEOUT, 's'); + + attachextract_request_timeout = + config_getduration(IMAPOPT_SEARCH_ATTACHMENT_EXTRACTOR_REQUEST_TIMEOUT, 's'); + + if (attachextract_idle_timeout < attachextract_request_timeout) + attachextract_idle_timeout = attachextract_request_timeout; + + const char *exturl = + config_getstring(IMAPOPT_SEARCH_ATTACHMENT_EXTRACTOR_URL); + if (!exturl) return; + + /* Initialize extractor URL */ + char scheme[6], server[100], path[256], *p; + unsigned https, port; + + /* Parse URL (cheesy parser without having to use libxml2) */ + int n = sscanf(exturl, "%5[^:]://%99[^/]%255[^\n]", + scheme, server, path); + if (n != 3 || + strncmp(lcase(scheme), "http", 4) || (scheme[4] && scheme[4] != 's')) { + syslog(LOG_ERR, + "extract_attachment: unexpected non-HTTP URL %s", exturl); + return; + } + + /* Normalize URL parts */ + https = (scheme[4] == 's'); + if (*(p = path + strlen(path) - 1) == '/') *p = '\0'; + if ((p = strrchr(server, ':'))) { + *p++ = '\0'; + port = atoi(p); + } + else port = https ? 443 : 80; + + /* Build servername, port, and options */ + struct buf buf = BUF_INITIALIZER; + buf_printf(&buf, "%s:%u%s/noauth", server, port, https ? "/tls" : ""); + + global_extractor = xzmalloc(sizeof(struct extractor_ctx)); + global_extractor->clientin = clientin; + global_extractor->path = xstrdup(path); + global_extractor->hostname = buf_release(&buf); +} + +EXPORTED void attachextract_destroy(void) +{ + struct extractor_ctx *ext = global_extractor; + + syslog(LOG_DEBUG, "extractor_destroy(%p)", ext); + + if (!ext) return; + + extractor_disconnect(ext); + free(ext->be); + free(ext->hostname); + free(ext->path); + free(ext); + + global_extractor = NULL; +} + +EXPORTED void attachextract_set_cachedir(const char *cachedir) +{ + char *old_cachedir = attachextract_cachedir; + attachextract_cachedir = xstrdupnull(cachedir); + xsyslog(LOG_DEBUG, "updated attachextract cache directory", + "old_cachedir=<%s> new_cachedir=<%s>", old_cachedir, cachedir); + free(old_cachedir); +} + +EXPORTED const char *attachextract_get_cachedir(void) +{ + return attachextract_cachedir; +} + +EXPORTED void attachextract_set_cacheonly(int cacheonly) +{ + int old_cacheonly = attachextract_cacheonly; + attachextract_cacheonly = cacheonly; + xsyslog(LOG_DEBUG, "updated attachextract cache-only flag", + "old_cacheonly=<%d> new_cacheonly=<%d>", old_cacheonly, cacheonly); +} + +EXPORTED int attachextract_get_cacheonly(void) +{ + return attachextract_cacheonly; +} diff --git a/imap/attachextract.h b/imap/attachextract.h new file mode 100644 index 0000000000..a1f5a5e199 --- /dev/null +++ b/imap/attachextract.h @@ -0,0 +1,92 @@ +/* attachextract.h -- Routines for extracting text from attachments + * + * Copyright (c) 1994-2008 Carnegie Mellon University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#ifndef INCLUDED_ATTACHEXTRACT_H +#define INCLUDED_ATTACHEXTRACT_H + +#include "prot.h" + +/** + * Initialize the attachextract backend. + * + * clientin is an optional protocol stream to wait for timeouts. + */ +extern void attachextract_init(struct protstream *clientin); + +/** + * Destroy the attachextract backend. + */ +extern void attachextract_destroy(void); + +/** + * Identifies the content type of attachment data. + */ +struct attachextract_record { + const char *type; // MIME content type + const char *subtype; // MIME subtype + struct message_guid guid; // content guid of undecoded data +}; + +/** + * Extracts text from attachment data. + * + * Data may be encoded with one of the charset ENCODING enums. + * + * Returns 0 on success or an IMAP error. + */ +extern int attachextract_extract(const struct attachextract_record *record, + const struct buf *data, int encoding, + struct buf *text); + +/** + * Sets or gets where to read and cache extract in. + */ +extern void attachextract_set_cachedir(const char *cachedir); +extern const char *attachextract_get_cachedir(void); + +/** + * Sets or gets if extracted text may only read from the cache. + */ +extern void attachextract_set_cacheonly(int cacheonly); +extern int attachextract_get_cacheonly(void); + +#endif diff --git a/imap/autocreate.c b/imap/autocreate.c index d1f1c17ecf..9963a4af80 100644 --- a/imap/autocreate.c +++ b/imap/autocreate.c @@ -59,12 +59,14 @@ #include #include "global.h" +#include "acl.h" #include "annotate.h" #include "util.h" #include "user.h" #include "xmalloc.h" #include "mailbox.h" #include "mboxlist.h" +#include "xunlink.h" /* generated headers are not necessarily in current directory */ #include "imap/imap_err.h" @@ -198,7 +200,7 @@ static int autocreate_sieve(const char *userid, const char *source_script) bytecode_info_t *bc = NULL; char *err = NULL; FILE *in_stream = NULL, *out_fp; - int out_fd, in_fd, r, w; + int out_fd = -1, in_fd = -1, r; int do_compile = 0; const char *compiled_source_script = NULL; const char *sievename = get_script_name(source_script); @@ -216,7 +218,7 @@ static int autocreate_sieve(const char *userid, const char *source_script) goto failed_start; } - /* check if sievedir is defined in impad.conf */ + /* check if sievedir is defined in imapd.conf */ if (!config_getstring(IMAPOPT_SIEVEDIR)) { syslog(LOG_ERR, "autocreate_sieve: sievedir option is not defined in" "imapd.conf"); @@ -275,7 +277,7 @@ static int autocreate_sieve(const char *userid, const char *source_script) O_CREAT|O_TRUNC|O_WRONLY, S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH); if (out_fd < 0 && errno != EEXIST) { - syslog(LOG_ERR, "autocreate_sieve: Error opening file %s :%m\n", + syslog(LOG_ERR, "autocreate_sieve: Error opening file %s :%m", script_names.bctmpname); goto failed_start; } @@ -285,7 +287,7 @@ static int autocreate_sieve(const char *userid, const char *source_script) if ((in_fd = open(compiled_source_script, O_RDONLY)) != -1) { do { r = read(in_fd, buf, sizeof(buf)); - w = write(out_fd, buf, r); + int w = write(out_fd, buf, r); if ( w < 0 || w != r) { syslog(LOG_ERR, "autocreate_sieve: Error writing to file" "%s: %m", script_names.bctmpname); @@ -297,8 +299,8 @@ static int autocreate_sieve(const char *userid, const char *source_script) xclose(out_fd); xclose(in_fd); } else if (r < 0) { - syslog(LOG_ERR, "autocreate_sieve: Error reading" - "compiled script %s: %m\n", compiled_source_script); + syslog(LOG_ERR, "autocreate_sieve: Error reading " + "compiled script %s: %m", compiled_source_script); xclose(in_fd); do_compile = 1; if (lseek(out_fd, 0, SEEK_SET)) { @@ -400,7 +402,7 @@ static int autocreate_sieve(const char *userid, const char *source_script) if (rename(script_names.bctmpname, script_names.bcscriptname)) { syslog(LOG_ERR, "autocreate_sieve: rename %s -> %s failed: %m", script_names.bctmpname, script_names.bcscriptname); - unlink(script_names.bcscriptname); + xunlink(script_names.bcscriptname); goto failed2; } @@ -408,8 +410,8 @@ static int autocreate_sieve(const char *userid, const char *source_script) if (symlink(script_names.bclinkname, script_names.defaultname)) { if (errno != EEXIST) { syslog(LOG_WARNING, "autocreate_sieve: error the symlink-ing %m."); - unlink(script_names.scriptname); - unlink(script_names.bcscriptname); + xunlink(script_names.scriptname); + xunlink(script_names.bcscriptname); } } @@ -441,19 +443,19 @@ static int autocreate_sieve(const char *userid, const char *source_script) O_CREAT|O_EXCL|O_WRONLY, S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH); if (out_fd < 0 && errno != EEXIST) { - syslog(LOG_ERR, "autocreate_sieve: Error opening file %s :%m\n", + syslog(LOG_ERR, "autocreate_sieve: Error opening file %s :%m", script_names.tmpname2); xclose(in_fd); goto success; } while ((r = read(in_fd, buf, sizeof(buf))) > 0) { - if ((w = write(out_fd,buf,r)) < 0) { + if (write(out_fd, buf, r) < 0) { syslog(LOG_WARNING, "autocreate_sieve: Error writing to file:" "%s: %m", script_names.tmpname2); xclose(out_fd); xclose(in_fd); - unlink(script_names.tmpname2); + xunlink(script_names.tmpname2); goto success; } } /* while */ @@ -466,15 +468,15 @@ static int autocreate_sieve(const char *userid, const char *source_script) "%s: %m", script_names.bcscriptname); xclose(out_fd); xclose(in_fd); - unlink(script_names.tmpname2); + xunlink(script_names.tmpname2); goto success; } /* if else if */ /* rename the temporary created sieve script to its final name. */ if (rename(script_names.tmpname2, compiled_source_script)) { if (errno != EEXIST) { - unlink(script_names.tmpname2); - unlink(compiled_source_script); + xunlink(script_names.tmpname2); + xunlink(compiled_source_script); } /* if (errno) */ goto success; } @@ -487,9 +489,9 @@ static int autocreate_sieve(const char *userid, const char *source_script) return 0; failed3: - unlink(script_names.tmpname1); + xunlink(script_names.tmpname1); failed2: - unlink(script_names.bctmpname); + xunlink(script_names.bctmpname); xclose(in_fd); failed1: xclose(out_fd); @@ -516,6 +518,7 @@ struct changesub_rock_st { static int autochangesub(struct findall_data *data, void *rock) { if (!data) return 0; + if (!data->is_exactmatch) return 0; struct changesub_rock_st *crock = (struct changesub_rock_st *)rock; const char *userid = crock->userid; struct auth_state *auth_state = crock->auth_state; @@ -526,7 +529,7 @@ static int autochangesub(struct findall_data *data, void *rock) /* ignore all user mailboxes, we only want shared */ if (mboxname_isusermailbox(name, 0)) return 0; - r = mboxlist_changesub(name, userid, auth_state, 1, 0, 1); + r = mboxlist_changesub(name, userid, auth_state, 1, 0, 1, 1); /* unless this name was explicitly chosen, ignore the failure */ if (!was_explicit) return 0; @@ -637,10 +640,67 @@ static void autocreate_specialuse_cb(const char *key, const char *val, void *roc buf_free(&usebuf); } +struct autocreate_acl_rock { + struct namespace *namespace; + const char *intname; + const char *shortname; + struct auth_state *auth_state; + const char *userid; +}; + +static void autocreate_acl_cb(const char *key, const char *val, void *rock) +{ + char *freeme = NULL, *folder, *identifier, *rights, *junk; + char *err = NULL; + struct autocreate_acl_rock *acl_rock = (struct autocreate_acl_rock *) rock; + int r; + + if (strcmp(key, "autocreate_acl")) return; + + freeme = xstrdup(val); + folder = strtok(freeme, " "); + identifier = strtok(NULL, " "); + rights = strtok(NULL, " "); + junk = strtok(NULL, " "); + + if (strcmpnull(folder, acl_rock->shortname)) goto done; + + if (!folder || !identifier || !rights || junk) { + syslog(LOG_WARNING, "autocreate: ignoring invalid autocreate_acl: %s", + val); + goto done; + } + + r = cyrus_acl_checkstr(rights, &err); + if (r) { + syslog(LOG_WARNING, "autocreate_acl %s: ignoring invalid rights string '%s': %s", + acl_rock->shortname, rights, err); + goto done; + } + + r = mboxlist_setacl(acl_rock->namespace, + acl_rock->intname, + identifier, rights, + /* isadmin */ 1, acl_rock->userid, + acl_rock->auth_state); + + if (r) { + syslog(LOG_ERR, "autocreate_acl %s: unable to setacl for %s to %s: %s", + acl_rock->shortname, identifier, rights, + error_message(r)); + goto done; + } + +done: + free(freeme); + free(err); + return; +} + int autocreate_user(struct namespace *namespace, const char *userid) { int r = IMAP_MAILBOX_NONEXISTENT; /* default error if we break early */ - int autocreatequota = config_getint(IMAPOPT_AUTOCREATE_QUOTA); + int64_t autocreatequota = config_getbytesize(IMAPOPT_AUTOCREATE_QUOTA, 'K'); int autocreatequotamessage = config_getint(IMAPOPT_AUTOCREATE_QUOTA_MESSAGES); int n; struct auth_state *auth_state = NULL; @@ -648,6 +708,7 @@ int autocreate_user(struct namespace *namespace, const char *userid) strarray_t *subscribe = NULL; int numcrt = 0; int numsub = 0; + struct mboxlock *namespacelock = user_namespacelock(userid); #ifdef USE_SIEVE const char *source_script; #endif @@ -708,13 +769,15 @@ int autocreate_user(struct namespace *namespace, const char *userid) goto done; } - r = mboxlist_createmailbox(inboxname, /*mbtype*/0, /*partition*/NULL, - /*isadmin*/1, userid, auth_state, - /*localonly*/0, /*forceuser*/0, - /*dbonly*/0, /*notify*/1, - /*mailboxptr*/NULL); + mbentry_t mbentry = MBENTRY_INITIALIZER; + mbentry.name = inboxname; + mbentry.mbtype = MBTYPE_EMAIL; - if (!r) r = mboxlist_changesub(inboxname, userid, auth_state, 1, 1, 1); + r = mboxlist_createmailbox(&mbentry, 0/*options*/, 0/*highestmodseq*/, + 1/*isadmin*/, userid, auth_state, + MBOXLIST_CREATE_NOTIFY, NULL/*mailboxptr*/); + + if (!r) r = mboxlist_changesub(inboxname, userid, auth_state, 1, 1, 1, 1); if (r) { syslog(LOG_ERR, "autocreateinbox: User %s, INBOX failed. %s", userid, error_message(r)); @@ -728,8 +791,8 @@ int autocreate_user(struct namespace *namespace, const char *userid) for (res = 0 ; res < QUOTA_NUMRESOURCES ; res++) newquotas[res] = QUOTA_UNLIMITED; - if (autocreatequota) - newquotas[QUOTA_STORAGE] = autocreatequota; + if (autocreatequota > 0) + newquotas[QUOTA_STORAGE] = autocreatequota / 1024; if (autocreatequotamessage) newquotas[QUOTA_MESSAGE] = autocreatequotamessage; @@ -752,12 +815,15 @@ int autocreate_user(struct namespace *namespace, const char *userid) const char *name = strarray_nth(create, n); char *foldername = mboxname_user_mbox(userid, name); struct autocreate_specialuse_rock specialrock = { userid, foldername, name }; + struct autocreate_acl_rock aclrock = { namespace, foldername, name, + auth_state, userid }; + + mbentry.name = foldername; + mbentry.mbtype = MBTYPE_EMAIL; - r = mboxlist_createmailbox(foldername, /*mbtype*/0, /*partition*/NULL, - /*isadmin*/1, userid, auth_state, - /*localonly*/0, /*forceuser*/0, - /*dbonly*/0, /*notify*/1, - /*mailboxptr*/NULL); + r = mboxlist_createmailbox(&mbentry, 0/*options*/, 0/*highestmodseq*/, + 1/*isadmin*/, userid, auth_state, + MBOXLIST_CREATE_NOTIFY, NULL/*mailboxptr*/); if (!r) { numcrt++; @@ -772,8 +838,8 @@ int autocreate_user(struct namespace *namespace, const char *userid) } /* subscribe if requested */ - if (strarray_find(subscribe, name, 0) >= 0) { - r = mboxlist_changesub(foldername, userid, auth_state, 1, 1, 1); + if (strarray_contains(subscribe, name)) { + r = mboxlist_changesub(foldername, userid, auth_state, 1, 1, 1, 1); if (!r) { numsub++; syslog(LOG_NOTICE,"autocreateinbox: User %s, subscription to %s succeeded", @@ -788,6 +854,9 @@ int autocreate_user(struct namespace *namespace, const char *userid) /* set specialuse if requested */ config_foreachoverflowstring(autocreate_specialuse_cb, &specialrock); + /* add additional acl's if requested */ + config_foreachoverflowstring(autocreate_acl_cb, &aclrock); + free(foldername); } @@ -815,6 +884,7 @@ int autocreate_user(struct namespace *namespace, const char *userid) #endif done: + mboxname_release(&namespacelock); free(inboxname); strarray_free(create); strarray_free(subscribe); diff --git a/imap/backend.c b/imap/backend.c index b686bdf34f..0bbb65ed08 100644 --- a/imap/backend.c +++ b/imap/backend.c @@ -651,6 +651,7 @@ static int backend_authenticate(struct backend *s, const char *userid, if (iptostring((struct sockaddr *)&saddr_l, addrsize, localip, 60) != 0) return SASL_FAIL; + s->sasl_cb = NULL; if (!cb) { strlcpy(optstr, s->hostname, sizeof(optstr)); p = strchr(optstr, '.'); @@ -670,10 +671,18 @@ static int backend_authenticate(struct backend *s, const char *userid, (userid && *userid ? SASL_NEED_PROXY : 0) | (prot->u.std.sasl_cmd.parse_success ? SASL_SUCCESS_DATA : 0), &s->saslconn); - if (r != SASL_OK) return r; + if (r != SASL_OK) { + free_callbacks(s->sasl_cb); + s->sasl_cb = NULL; + return r; + } r = sasl_setprop(s->saslconn, SASL_SEC_PROPS, &secprops); - if (r != SASL_OK) return r; + if (r != SASL_OK) { + free_callbacks(s->sasl_cb); + s->sasl_cb = NULL; + return r; + } /* Get SASL mechanism list. We can force a particular mechanism using a _mechs option */ @@ -822,7 +831,7 @@ static int backend_login(struct backend *ret, const char *userid, * automatically send capabilities, so we treat it * as optional. */ - char ch; + int ch; /* wait and probe for possible auto-capability response */ usleep(250000); @@ -941,12 +950,11 @@ EXPORTED struct backend *backend_connect_pipe(int infd, int outfd, ret->in = prot_new(infd, 0); ret->out = prot_new(outfd, 1); ret->sock = -1; - prot_settimeout(ret->in, config_getint(IMAPOPT_CLIENT_TIMEOUT)); + prot_settimeout(ret->in, config_getduration(IMAPOPT_CLIENT_TIMEOUT, 's')); prot_setflushonread(ret->in, ret->out); ret->prot = prot; /* use literal+ to send literals */ - prot_setisclient(ret->in, 1); prot_setisclient(ret->out, 1); /* Start TLS if required */ @@ -973,6 +981,7 @@ EXPORTED struct backend *backend_connect_pipe(int infd, int outfd, return ret; error: + backend_disconnect(ret); ret->sock = -1; free(ret); return NULL; @@ -1085,7 +1094,7 @@ EXPORTED struct backend *backend_connect(struct backend *ret_backend, const char int n; fd_set wfds, rfds; time_t now = time(NULL); - time_t timeout = now + config_getint(IMAPOPT_CLIENT_TIMEOUT); + time_t timeout = now + config_getduration(IMAPOPT_CLIENT_TIMEOUT, 's'); struct timeval waitfor; /* select() socket for writing until we succeed, fail, or timeout */ @@ -1138,12 +1147,11 @@ EXPORTED struct backend *backend_connect(struct backend *ret_backend, const char ret->in = prot_new(sock, 0); ret->out = prot_new(sock, 1); ret->sock = sock; - prot_settimeout(ret->in, config_getint(IMAPOPT_CLIENT_TIMEOUT)); + prot_settimeout(ret->in, config_getduration(IMAPOPT_CLIENT_TIMEOUT, 's')); prot_setflushonread(ret->in, ret->out); ret->prot = prot; /* use literal+ to send literals */ - prot_setisclient(ret->in, 1); prot_setisclient(ret->out, 1); /* Start TLS if required */ @@ -1245,11 +1253,15 @@ EXPORTED void backend_disconnect(struct backend *s) } #ifdef HAVE_SSL - /* Free tlsconn */ + /* Free tlsconn and tlssess */ if (s->tlsconn) { tls_reset_servertls(&s->tlsconn); s->tlsconn = NULL; } + if (s->tlssess) { + SSL_SESSION_free(s->tlssess); + s->tlssess = NULL; + } #endif /* HAVE_SSL */ /* close/free socket & prot layer */ @@ -1279,3 +1291,112 @@ EXPORTED void backend_disconnect(struct backend *s) forget_capabilities(s); } + +EXPORTED int backend_version(struct backend *be) +{ + const char *banner_version, *two_three_minor; + int major, minor; + + /* IMPORTANT: + * + * When adding checks for new versions, you must also backport these + * checks to previous versions (especially 2.4 and 2.5). + * + * Otherwise, old versions will be unable to recognise the new version, + * assume it is ancient, and downgrade the index to the oldest version + * supported (version 6, prior to v2.3). + * + * In 3.2 and earlier, this function lives in imapd.c + */ + + /* identical banner? identical version! */ + if (strstr(be->banner, CYRUS_VERSION)) { + return MAILBOX_MINOR_VERSION; + } + + /* contemporary numbering */ + banner_version = strstr(be->banner, "Cyrus IMAP "); + if (banner_version != NULL + && (2 == sscanf(banner_version, + "Cyrus IMAP %d.%d.%*d server ready", + &major, &minor) + || 2 == sscanf(banner_version, + "Cyrus IMAP %d.%d.%*d-%*s server ready", + &major, &minor) + )) + { + if (major > 3) { + /* unrecognised future version surely supports at least whatever + * this version supports, which is a much better assumption than 6 + */ + syslog(LOG_INFO, "%s: did not recognise remote Cyrus version from " + "banner \"%s\". Assuming index version %d!", + __func__, be->banner, MAILBOX_MINOR_VERSION); + return MAILBOX_MINOR_VERSION; + } + else if (major == 3) { + if (minor >= 3) { + /* all versions since 3.3 have been 17 so far */ + return 17; + } + else if (minor == 2) { + /* version 3.2 is 16 */ + return 16; + } + else { + /* version 3.0 and 3.1 are 13 */ + return 13; + } + } + /* didn't recognise it? fall through to specific checks */ + } + + /* version 2.5 is 13 */ + if (strstr(be->banner, "Cyrus IMAP 2.5.") + || strstr(be->banner, "Cyrus IMAP Murder 2.5.") + || strstr(be->banner, "git2.5.")) { + return 13; + } + + /* version 2.4 was all 12 */ + if (strstr(be->banner, "v2.4.") || strstr(be->banner, "git2.4.")) { + return 12; + } + + two_three_minor = strstr(be->banner, "v2.3."); + if (!two_three_minor) goto unrecognised; + two_three_minor += strlen("v2.3."); + + /* at least version 2.3.10 */ + if (two_three_minor[1] != ' ') { + return 10; + } + /* single digit version, figure out which */ + switch (two_three_minor[0]) { + case '0': + case '1': + case '2': + case '3': + return 7; + break; + + case '4': + case '5': + case '6': + return 8; + break; + + case '7': + case '8': + case '9': + return 9; + break; + } + +unrecognised: + /* fallthrough, shouldn't happen */ + syslog(LOG_WARNING, "%s: did not recognise remote Cyrus version from " + "banner \"%s\". Assuming index version 6!", + __func__, be->banner); + return 6; +} diff --git a/imap/backend.h b/imap/backend.h index 50ec8b0445..f0cdd5ef3b 100644 --- a/imap/backend.h +++ b/imap/backend.h @@ -113,6 +113,8 @@ void backend_disconnect(struct backend *s); char *intersect_mechlists(char *config, char *server); char *backend_get_cap_params(const struct backend *, unsigned long capa); -#define CAPA(s, c) ((s)->capability & (c)) +int backend_version(struct backend *); + +#define CAPA(s, c) ((s) ? (s)->capability & (c) : 0) #endif /* _INCLUDED_BACKEND_H */ diff --git a/imap/calalarmd.c b/imap/calalarmd.c index 668d988471..72ac749f89 100644 --- a/imap/calalarmd.c +++ b/imap/calalarmd.c @@ -66,6 +66,8 @@ extern char *optarg; static int debugmode = 0; +struct namespace calalarmd_namespace; + EXPORTED void fatal(const char *msg, int err) { if (debugmode) fprintf(stderr, "dying with %s %d\n", msg, err); @@ -74,6 +76,8 @@ EXPORTED void fatal(const char *msg, int err) cyrus_done(); + if (err != EX_PROTOCOL && config_fatals_abort) abort(); + exit(err); } @@ -115,13 +119,16 @@ int main(int argc, char **argv) cyrus_init(alt_config, "calalarmd", 0, 0); + mboxname_init_namespace(&calalarmd_namespace, NAMESPACE_OPTION_ADMIN); + mboxevent_setnamespace(&calalarmd_namespace); + if (upgrade) { caldav_alarm_upgrade(); shut_down(0); } if (runattime) { - caldav_alarm_process(runattime); + caldav_alarm_process(runattime, NULL, /*dryrun*/0); shut_down(0); } @@ -148,19 +155,23 @@ int main(int argc, char **argv) struct timeval start, end; double totaltime; int tosleep; + time_t interval = 10; signals_poll(); gettimeofday(&start, 0); - caldav_alarm_process(0); + caldav_alarm_process(0, &interval, /*dryrun*/0); + libcyrus_run_delayed(); gettimeofday(&end, 0); signals_poll(); totaltime = timesub(&start, &end); - tosleep = 10 - (int) (totaltime + 0.5); /* round to nearest int */ + tosleep = interval - (int) (totaltime + 0.5); /* round to nearest int */ if (tosleep > 0) sleep(tosleep); + + session_new_id(); // so we know which actions happened in the same run } /* NOTREACHED */ diff --git a/imap/caldav_alarm.c b/imap/caldav_alarm.c index 4a8c7b4e1c..c57320f6ad 100644 --- a/imap/caldav_alarm.c +++ b/imap/caldav_alarm.c @@ -49,15 +49,26 @@ #include +#include "append.h" #include "caldav_alarm.h" +#include "caldav_db.h" +#include "calsched_support.h" +#include "caldav_util.h" #include "cyrusdb.h" +#include "defaultalarms.h" +#include "hashset.h" #include "httpd.h" #include "http_dav.h" #include "ical_support.h" +#include "jmap_util.h" +#include "json_support.h" #include "libconfig.h" #include "mboxevent.h" #include "mboxlist.h" #include "mboxname.h" +#include "msgrecord.h" +#include "times.h" +#include "user.h" #include "util.h" #include "xstrlcat.h" #include "xmalloc.h" @@ -70,12 +81,17 @@ struct caldav_alarm_data { char *mboxname; uint32_t imap_uid; time_t nextcheck; + uint32_t type; + uint32_t num_rcpts; + uint32_t num_retries; + time_t last_run; + char *last_err; }; -void caldav_alarm_fini(struct caldav_alarm_data *alarmdata) +static void caldav_alarm_fini(struct caldav_alarm_data *alarmdata) { - free(alarmdata->mboxname); - alarmdata->mboxname = NULL; + xzfree(alarmdata->mboxname); + xzfree(alarmdata->last_err); } struct get_alarm_rock { @@ -86,20 +102,26 @@ struct get_alarm_rock { time_t last; time_t now; time_t nextcheck; + int dryrun; }; static struct namespace caldav_alarm_namespace; +static int min_interval; EXPORTED int caldav_alarm_init(void) { int r; /* Set namespace -- force standard (internal) */ - if ((r = mboxname_init_namespace(&caldav_alarm_namespace, 1))) { + if ((r = mboxname_init_namespace(&caldav_alarm_namespace, NAMESPACE_OPTION_ADMIN))) { syslog(LOG_ERR, "%s", error_message(r)); fatal(error_message(r), EX_CONFIG); } + min_interval = + config_getduration(IMAPOPT_CALENDAR_MINIMUM_ALARM_INTERVAL, 'm'); + if (min_interval < 0) min_interval = 0; + return sqldb_init(); } @@ -110,30 +132,82 @@ EXPORTED int caldav_alarm_done(void) } -#define CMD_CREATE \ - "CREATE TABLE IF NOT EXISTS events (" \ +#define CMD_CREATE_INDEXES \ + "CREATE INDEX IF NOT EXISTS checktime ON events (nextcheck);" \ + "CREATE INDEX IF NOT EXISTS idx_type ON events (type);" + +#define CMD_CREATE_TABLE(name) \ + "CREATE TABLE IF NOT EXISTS " name " (" \ " mboxname TEXT NOT NULL," \ " imap_uid INTEGER NOT NULL," \ " nextcheck INTEGER NOT NULL," \ + " type INTEGER NOT NULL," \ + " num_rcpts INTEGER NOT NULL," \ + " num_retries INTEGER NOT NULL," \ + " last_run INTEGER NOT NULL," \ + " last_err TEXT," \ " PRIMARY KEY (mboxname, imap_uid)" \ ");" \ - "CREATE INDEX IF NOT EXISTS checktime ON events (nextcheck);" + +#define CMD_CREATE \ + CMD_CREATE_TABLE("events") \ + CMD_CREATE_INDEXES -#define DBVERSION 2 +#define DBVERSION 4 -/* the command loop will do the upgrade and then drop the old tables. - * Sadly there's no other way to do it without creating a lock inversion! */ -#define CMD_UPGRADEv2 CMD_CREATE +static int upgradev4(sqldb_t *db); static struct sqldb_upgrade upgrade[] = { - { 2, CMD_UPGRADEv2, NULL }, + /* Don't upgrade to version 2. */ + /* Don't upgrade to version 3. This was an intermediate DB version */ + { 4, NULL, &upgradev4 }, /* always finish with an empty row */ { 0, NULL, NULL } }; +#define CMD_REPLACE \ + "REPLACE INTO events" \ + " ( mboxname, imap_uid, nextcheck, type, num_rcpts," \ + " num_retries, last_run, last_err)" \ + " VALUES" \ + " ( :mboxname, :imap_uid, :nextcheck, :type, :num_rcpts," \ + " :num_retries, :last_run, :last_err)" \ + ";" + +#define CMD_DELETE \ + "DELETE FROM events" \ + " WHERE mboxname = :mboxname" \ + " AND imap_uid = :imap_uid" \ + ";" + +#define CMD_DELETEMAILBOX \ + "DELETE FROM events WHERE" \ + " mboxname = :mboxname" \ + ";" + +#define CMD_DELETEUSER \ + "DELETE FROM events WHERE" \ + " mboxname LIKE :prefix" \ + ";" + +#define CMD_SELECTUSER \ + "SELECT mboxname, imap_uid, nextcheck, type, num_rcpts,"\ + " num_retries, last_run, last_err" \ + " FROM events WHERE" \ + " mboxname LIKE :prefix" \ + ";" + +#define CMD_SELECT_ALARMS \ + "SELECT mboxname, imap_uid, nextcheck, type, num_rcpts,"\ + " num_retries, last_run, last_err" \ + " FROM events WHERE" \ + " nextcheck < :before" \ + " ORDER BY mboxname, imap_uid, nextcheck" \ + ";" + static sqldb_t *my_alarmdb; -int refcount; +static int refcount; static struct mboxlock *my_alarmdb_lock; /* get a database handle to the alarm db */ @@ -155,7 +229,7 @@ static sqldb_t *caldav_alarm_open() // XXX - config option? char *dbfilename = strconcat(config_dir, "/caldav_alarm.sqlite3", NULL); my_alarmdb = sqldb_open(dbfilename, CMD_CREATE, DBVERSION, upgrade, - config_getint(IMAPOPT_DAV_LOCK_TIMEOUT) * 1000); + config_getduration(IMAPOPT_DAV_LOCK_TIMEOUT, 's') * 1000); if (!my_alarmdb) { syslog(LOG_ERR, "DBERROR: failed to open %s", dbfilename); @@ -180,21 +254,117 @@ static int caldav_alarm_close(sqldb_t *alarmdb) return 0; } +/* set up a reconstruct database to override regular open/close */ +EXPORTED int caldav_alarm_set_reconstruct(sqldb_t *db) +{ + // make sure we're not already open + assert(!my_alarmdb); + assert(!refcount); + + // create the events table + int rc = sqldb_exec(db, CMD_CREATE, NULL, NULL, NULL); + if (rc != SQLITE_OK) return IMAP_IOERROR; + + // preload the DB into our refcounter + my_alarmdb = db; + refcount = 1; + + return 0; +} + +static int copydb(sqlite3_stmt *stmt, void *rock) +{ + sqldb_t *destdb = (sqldb_t *)rock; + struct sqldb_bindval bval[] = { + { ":mboxname", SQLITE_TEXT, { .s = (const char *)sqlite3_column_text(stmt, 0) } }, + { ":imap_uid", SQLITE_INTEGER, { .i = sqlite3_column_int(stmt, 1) } }, + { ":nextcheck", SQLITE_INTEGER, { .i = sqlite3_column_int(stmt, 2) } }, + { ":type", SQLITE_INTEGER, { .i = sqlite3_column_int(stmt, 3) } }, + { ":num_rcpts", SQLITE_INTEGER, { .i = sqlite3_column_int(stmt, 4) } }, + { ":num_retries", SQLITE_INTEGER, { .i = sqlite3_column_int(stmt, 5) } }, + { ":last_run", SQLITE_INTEGER, { .i = sqlite3_column_int(stmt, 6) } }, + { ":last_err", SQLITE_TEXT, { .s = (const char *)sqlite3_column_text(stmt, 7) } }, + { NULL, SQLITE_NULL, { .s = NULL } } + }; + return sqldb_exec(destdb, CMD_REPLACE, bval, NULL, NULL); +} + +/* remove all existing alarms for this user and copy all alarms from the + reconstructed database into place instead */ +EXPORTED int caldav_alarm_commit_reconstruct(const char *userid) +{ + sqldb_t *db = my_alarmdb; + + // zero out the override so we can open the correct database + assert(refcount == 1); + refcount = 0; + my_alarmdb = NULL; + + mbname_t *mbname = mbname_from_userid(userid); + const char *mboxname = mbname_intname(mbname); + char *prefix = strconcat(mboxname, ".%", (char *)NULL); + mbname_free(&mbname); + + struct sqldb_bindval bval[] = { + { ":prefix", SQLITE_TEXT, { .s = prefix } }, + { NULL, SQLITE_NULL, { .s = NULL } } + }; + + sqldb_t *alarmdb = caldav_alarm_open(); + int r = sqldb_begin(alarmdb, "replace_alarms"); + if (!r) r = sqldb_exec(alarmdb, CMD_DELETEUSER, bval, NULL, NULL); + if (!r) r = sqldb_exec(db, CMD_SELECTUSER, bval, ©db, alarmdb); + if (!r) r = sqldb_commit(alarmdb, "replace_alarms"); + else sqldb_rollback(alarmdb, "replace_alarms"); + caldav_alarm_close(alarmdb); + + // if we succeeded, drop the copy of events in this DB + if (!r) r = sqldb_exec(db, "DROP TABLE events;", NULL, NULL, NULL); + + free(prefix); + + return r; +} + +/* release the reconstruction database without copying or removing any + * existing alarms */ +EXPORTED void caldav_alarm_rollback_reconstruct() +{ + assert(refcount == 1); + refcount = 0; + my_alarmdb = NULL; + + // we keep the events database in this copy for later examination +} + /* * Extract data from the given ical object */ static int send_alarm(struct get_alarm_rock *rock, icalcomponent *comp, icalcomponent *alarm, icaltimetype start, icaltimetype end, + icaltimetype recurid, + int is_standalone, icaltimetype alarmtime) { const char *userid = rock->userid; struct buf calname = BUF_INITIALIZER; + struct buf calcolor = BUF_INITIALIZER; + struct buf buf = BUF_INITIALIZER; + + /* get the calendar id and calendar owner id */ + mbname_t *mbname = mbname_from_intname(rock->mboxname); + const char *calid = strarray_nth(mbname_boxes(mbname), -1); + const char *ownerid = mbname_userid(mbname); /* get the display name annotation */ const char *displayname_annot = DAV_ANNOT_NS "<" XML_NS_DAV ">displayname"; annotatemore_lookupmask(rock->mboxname, displayname_annot, userid, &calname); - if (!calname.len) buf_setcstr(&calname, strrchr(rock->mboxname, '.') + 1); + if (!calname.len) buf_setcstr(&calname, calid); + + /* get the calendar color annotation */ + const char *color_annot = DAV_ANNOT_NS "<" XML_NS_APPLE ">calendar-color"; + annotatemore_lookupmask(rock->mboxname, color_annot, userid, &calcolor); struct mboxevent *event = mboxevent_new(EVENT_CALENDAR_ALARM); icalproperty *prop; @@ -214,39 +384,64 @@ static int send_alarm(struct get_alarm_rock *rock, } FILL_STRING_PARAM(event, EVENT_CALENDAR_USER_ID, xstrdup(userid)); + FILL_STRING_PARAM(event, EVENT_CALENDAR_CALENDAR_ID, xstrdup(calid)); FILL_STRING_PARAM(event, EVENT_CALENDAR_CALENDAR_NAME, buf_release(&calname)); + FILL_STRING_PARAM(event, EVENT_CALENDAR_CALENDAR_COLOR, buf_release(&calcolor)); + FILL_STRING_PARAM(event, EVENT_CALENDAR_CALENDAR_OWNER, xstrdup(ownerid)); + + struct jmap_caleventid eid = { 0 }; prop = icalcomponent_get_first_property(comp, ICAL_UID_PROPERTY); + if (prop) eid.ical_uid = icalproperty_get_value_as_string(prop); FILL_STRING_PARAM(event, EVENT_CALENDAR_UID, - xstrdup(prop ? icalproperty_get_value_as_string(prop) : "")); + xstrdupsafe(prop ? icalproperty_get_value_as_string(prop) : "")); + + if (!icaltime_is_null_time(recurid) && is_standalone) { + // if the event is a standalone recurrence instance, encode + // the recurrence id in the event id. otherwise use the + // main event id for the alert notification + eid.ical_recurid = icaltime_as_ical_string(recurid); + } + + // set calendarEventId + if (eid.ical_uid) jmap_caleventid_encode(&eid, &buf); + FILL_STRING_PARAM(event, EVENT_CALENDAR_EVENTID, buf_release(&buf)); + + // set recurrenceId + if (!icaltime_is_null_time(recurid)) { + buf_printf(&buf, "%d-%02d-%02dT%02d:%02d:%02d", + recurid.year, recurid.month, recurid.day, + recurid.hour, recurid.minute, recurid.second); + } + FILL_STRING_PARAM(event, EVENT_CALENDAR_RECURID, buf_release(&buf)); prop = icalcomponent_get_first_property(comp, ICAL_SUMMARY_PROPERTY); FILL_STRING_PARAM(event, EVENT_CALENDAR_SUMMARY, - xstrdup(prop ? icalproperty_get_value_as_string(prop) : "")); + xstrdupsafe(prop ? icalproperty_get_value_as_string(prop) : "")); prop = icalcomponent_get_first_property(comp, ICAL_DESCRIPTION_PROPERTY); FILL_STRING_PARAM(event, EVENT_CALENDAR_DESCRIPTION, - xstrdup(prop ? icalproperty_get_value_as_string(prop) : "")); + xstrdupsafe(prop ? icalproperty_get_value_as_string(prop) : "")); prop = icalcomponent_get_first_property(comp, ICAL_LOCATION_PROPERTY); FILL_STRING_PARAM(event, EVENT_CALENDAR_LOCATION, - xstrdup(prop ? icalproperty_get_value_as_string(prop) : "")); + xstrdupsafe(prop ? icalproperty_get_value_as_string(prop) : "")); prop = icalcomponent_get_first_property(comp, ICAL_ORGANIZER_PROPERTY); FILL_STRING_PARAM(event, EVENT_CALENDAR_ORGANIZER, - xstrdup(prop ? icalproperty_get_value_as_string(prop) : "")); + xstrdupsafe(prop ? icalproperty_get_value_as_string(prop) : "")); const char *timezone = NULL; if (!icaltime_is_date(start) && icaltime_is_utc(start)) timezone = "UTC"; else if (icaltime_get_timezone(start)) - timezone = icaltime_get_tzid(start); + timezone = icaltime_get_location_tzid(start); else if (rock->floatingtz) - timezone = icaltimezone_get_tzid(rock->floatingtz); + timezone = icaltimezone_get_location_tzid(rock->floatingtz); else timezone = "[floating]"; FILL_STRING_PARAM(event, EVENT_CALENDAR_TIMEZONE, - xstrdup(timezone)); + xstrdupsafe(timezone)); FILL_STRING_PARAM(event, EVENT_CALENDAR_START, xstrdup(icaltime_as_ical_string(start))); FILL_STRING_PARAM(event, EVENT_CALENDAR_END, @@ -265,6 +460,9 @@ static int send_alarm(struct get_alarm_rock *rock, } FILL_ARRAY_PARAM(event, EVENT_CALENDAR_ALARM_RECIPIENTS, recipients); + jmap_alertid_encode(alarm, &buf); + FILL_STRING_PARAM(event, EVENT_CALENDAR_ALERTID, buf_release(&buf)); + strarray_t *attendee_names = strarray_new(); strarray_t *attendee_emails = strarray_new(); strarray_t *attendee_status = strarray_new(); @@ -296,20 +494,61 @@ static int send_alarm(struct get_alarm_rock *rock, strarray_free(attendee_status); buf_free(&calname); + buf_free(&calcolor); + buf_free(&buf); + mbname_free(&mbname); return 0; } -static int process_alarm_cb(icalcomponent *comp, icaltimetype start, - icaltimetype end, void *rock) +static int process_alarm_cb(icalcomponent *comp, + icaltimetype start, icaltimetype end, + icaltimetype recurid, + int is_standalone, + void *rock) { struct get_alarm_rock *data = (struct get_alarm_rock *)rock; - + strarray_t sched_addrs = STRARRAY_INITIALIZER; icalcomponent *alarm; icalproperty *prop; icalvalue *val; - int alarmno = 0; + int keep_processing_recurrences = 1; + int suppress_duplicates = + config_getswitch(IMAPOPT_CALENDAR_SUPPRESS_DUPLICATE_ALARMS); + size_t suppressed_n_duplicates = 0; + + struct alarm_id { + icaltimetype alarmtime; + icalproperty_action action; + }; + + struct hashset *seen_alarms = hashset_new(sizeof(struct alarm_id)); + + /* Skip cancelled events */ + if (icalcomponent_get_status(comp) == ICAL_STATUS_CANCELLED) goto done; + + /* Skip declined events */ + get_schedule_addresses(data->mboxname, data->userid, &sched_addrs); + + for (prop = icalcomponent_get_first_invitee(comp); + prop; prop = icalcomponent_get_next_invitee(comp)) { + const char *attendee = icalproperty_get_invitee(prop); + + if (!attendee) continue; + if (!strncasecmp(attendee, "mailto:", 7)) attendee += 7; + + if (strarray_contains_case(&sched_addrs, attendee)) { + const char *partstat = + icalproperty_get_parameter_as_string(prop, "PARTSTAT"); + + if (!strcasecmpsafe(partstat, "DECLINED")) { + strarray_fini(&sched_addrs); + goto done; + } + } + } + strarray_fini(&sched_addrs); for (alarm = icalcomponent_get_first_component(comp, ICAL_VALARM_COMPONENT); alarm; @@ -342,16 +581,14 @@ static int process_alarm_cb(icalcomponent *comp, icaltimetype start, /* XXX validate trigger */ icaltimetype alarmtime = icaltime_null_time(); - if (icalvalue_isa(val) == ICAL_DURATION_VALUE) { + unsigned is_duration = (icalvalue_isa(val) == ICAL_DURATION_VALUE); + if (is_duration) { icalparameter *param = icalproperty_get_first_parameter(prop, ICAL_RELATED_PARAMETER); - icaltimetype base = icaltime_null_time(); + icaltimetype base = start; if (param && icalparameter_get_related(param) == ICAL_RELATED_END) { base = end; } - else { - base = start; - } base.is_date = 0; /* need an actual time for triggers */ alarmtime = icaltime_add(base, trigger.duration); } @@ -367,11 +604,23 @@ static int process_alarm_cb(icalcomponent *comp, icaltimetype start, if (check <= data->last) { continue; } + else if (!is_duration && + icaltime_compare(recurid, icalcomponent_get_dtstart(comp)) > 0) { + /* alarms with absolute triggers can only fire once */ + syslog(LOG_DEBUG, "XXX absolute trigger - skipping recurrence"); + continue; + } - if (check <= data->now) { + if (check <= data->now && !data->dryrun) { prop = icalcomponent_get_first_property(comp, ICAL_SUMMARY_PROPERTY); const char *summary = prop ? icalproperty_get_value_as_string(prop) : "[no summary]"; + + struct alarm_id alarm_id; + memset(&alarm_id, 0, sizeof(struct alarm_id)); + alarm_id.alarmtime = alarmtime; + alarm_id.action = action; + int age = data->now - check; if (age > 7200) { // more than 2 hours stale? Just log it syslog(LOG_ERR, "suppressing alarm aged %d seconds " @@ -382,13 +631,16 @@ static int process_alarm_cb(icalcomponent *comp, icaltimetype start, icaltime_as_ical_string(start), alarmno, summary); } + else if (!hashset_add(seen_alarms, &alarm_id) && suppress_duplicates) { + suppressed_n_duplicates++; + } else { syslog(LOG_NOTICE, "sending alarm at %s for %s %u - %s(%d) %s", icaltime_as_ical_string(alarmtime), data->mboxname, data->imap_uid, icaltime_as_ical_string(start), alarmno, summary); - send_alarm(data, comp, alarm, start, end, alarmtime); + send_alarm(data, comp, alarm, start, end, recurid, is_standalone, alarmtime); } } @@ -403,42 +655,48 @@ static int process_alarm_cb(icalcomponent *comp, icaltimetype start, time_t next = data->now + 86400*30; if (!data->nextcheck || next < data->nextcheck) data->nextcheck = next; - return 0; + keep_processing_recurrences = 0; + goto done; } } - return 1; /* keep going */ -} - -#define CMD_REPLACE \ - "REPLACE INTO events" \ - " ( mboxname, imap_uid, nextcheck )" \ - " VALUES" \ - " ( :mboxname, :imap_uid, :nextcheck )" \ - ";" + if (suppressed_n_duplicates) { + xsyslog(LOG_INFO, "suppressed duplicate alarms", + "mboxname=<%s> imap_uid=<%d> n_duplicates=<%zu>", + data->mboxname, data->imap_uid, suppressed_n_duplicates); + } -#define CMD_DELETE \ - "DELETE FROM events" \ - " WHERE mboxname = :mboxname" \ - " AND imap_uid = :imap_uid" \ - ";" +done: + hashset_free(&seen_alarms); + return keep_processing_recurrences; +} static int update_alarmdb(const char *mboxname, - uint32_t imap_uid, time_t nextcheck) + uint32_t imap_uid, time_t nextcheck, + uint32_t type, uint32_t num_rcpts, + uint32_t num_retries, time_t last_run, + const char *last_err) { struct sqldb_bindval bval[] = { - { ":mboxname", SQLITE_TEXT, { .s = mboxname } }, - { ":imap_uid", SQLITE_INTEGER, { .i = imap_uid } }, - { ":nextcheck", SQLITE_INTEGER, { .i = nextcheck } }, - { NULL, SQLITE_NULL, { .s = NULL } } + { ":mboxname", SQLITE_TEXT, { .s = mboxname } }, + { ":imap_uid", SQLITE_INTEGER, { .i = imap_uid } }, + { ":nextcheck", SQLITE_INTEGER, { .i = nextcheck } }, + { ":type", SQLITE_INTEGER, { .i = type } }, + { ":num_rcpts", SQLITE_INTEGER, { .i = num_rcpts } }, + { ":num_retries", SQLITE_INTEGER, { .i = num_retries } }, + { ":last_run", SQLITE_INTEGER, { .i = last_run } }, + { ":last_err", SQLITE_TEXT, { .s = last_err } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; sqldb_t *alarmdb = caldav_alarm_open(); if (!alarmdb) return -1; int rc = SQLITE_OK; - syslog(LOG_DEBUG, "update_alarmdb(%s:%u, %ld)", - mboxname, imap_uid, nextcheck); + syslog(LOG_DEBUG, + "update_alarmdb(%s:%u, " TIME_T_FMT ", %u, %u, %u, " TIME_T_FMT ", %s)", + mboxname, imap_uid, nextcheck, type, num_rcpts, + num_retries, last_run, last_err ? last_err : "NULL"); if (nextcheck) rc = sqldb_exec(alarmdb, CMD_REPLACE, bval, NULL, NULL); @@ -453,40 +711,14 @@ static int update_alarmdb(const char *mboxname, return -1; } -static icaltimezone *get_floatingtz(const char *mailbox, const char *userid) -{ - icaltimezone *floatingtz = NULL; - - struct buf buf = BUF_INITIALIZER; - const char *annotname = DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-timezone"; - if (!annotatemore_lookupmask(mailbox, annotname, userid, &buf)) { - icalcomponent *comp = NULL; - comp = icalparser_parse_string(buf_cstring(&buf)); - icalcomponent *subcomp = - icalcomponent_get_first_component(comp, ICAL_VTIMEZONE_COMPONENT); - if (subcomp) { - floatingtz = icaltimezone_new(); - icalcomponent_remove_component(comp, subcomp); - icaltimezone_set_component(floatingtz, subcomp); - } - icalcomponent_free(comp); - } - buf_free(&buf); - - return floatingtz; -} - -static icalcomponent *vpatch_from_peruserdata(const struct buf *userdata) +static icalcomponent *vpatch_from_peruserdata(struct dlist *dl) { - struct dlist *dl; const char *icalstr; icalcomponent *vpatch; /* Parse the value and fetch the patch */ - dlist_parsemap(&dl, 1, 0, buf_base(userdata), buf_len(userdata)); dlist_getatom(dl, "VPATCH", &icalstr); vpatch = icalparser_parse_string(icalstr); - dlist_free(&dl); return vpatch; } @@ -504,7 +736,8 @@ static int has_peruser_alarms_cb(const char *mailbox, void *rock) { struct has_alarms_rock *hrock = (struct has_alarms_rock *) rock; - icalcomponent *vpatch, *comp; + icalcomponent *vpatch = NULL, *comp; + struct dlist *dl = NULL; if (!mboxname_userownsmailbox(userid, mailbox) && ((hrock->mbox_options & OPT_IMAP_SHAREDSEEN) || @@ -512,32 +745,103 @@ static int has_peruser_alarms_cb(const char *mailbox, /* No per-user-data, or sharee has unsubscribed from this calendar */ return 0; } - + + dlist_parsemap(&dl, 1, 0, buf_base(value), buf_len(value)); + const char *strval = NULL; + if (dlist_getatom(dl, "USEDEFAULTALERTS", &strval)) { + if (!strcasecmp(strval, "YES")) { + *(hrock->has_alarms) = 1; + goto done; + } + } + /* Extract VPATCH from per-user-cal-data annotation */ - vpatch = vpatch_from_peruserdata(value); + vpatch = vpatch_from_peruserdata(dl); /* Check PATCHes for any VALARMs */ for (comp = icalcomponent_get_first_component(vpatch, ICAL_XPATCH_COMPONENT); - comp; + comp && !*(hrock->has_alarms); comp = icalcomponent_get_next_component(vpatch, ICAL_XPATCH_COMPONENT)) { if (icalcomponent_get_first_component(comp, ICAL_VALARM_COMPONENT)) { *(hrock->has_alarms) = 1; break; } + else if (icalcomponent_get_usedefaultalerts(comp)) { + *(hrock->has_alarms) = 1; + break; + } } - icalcomponent_free(vpatch); - +done: + if (vpatch) icalcomponent_free(vpatch); + if (dl) dlist_free(&dl); return 0; } -static int has_alarms(icalcomponent *ical, struct mailbox *mailbox, uint32_t uid) +static int short_cmp(const void *a, const void *b) +{ + return *((short*) a) - *((short*) b); +} + +static int check_by_array(const short *byX, short size, + int recur_interval, short to_seconds) +{ + short *my_byX; + short i, len; + int disable = 0; + + /* make a working copy of the array + (add extra slot for 1st value of next recurrence interval) */ + my_byX = xmalloc((size+1) * sizeof(short)); + memcpy(my_byX, byX, size * sizeof(short)); + + /* convert each value to seconds and determine actual length of data */ + for (len = 0; len < size && my_byX[len] != ICAL_RECURRENCE_ARRAY_MAX; len++) { + my_byX[len] *= to_seconds; + } + + /* append 1st value of next recurrence interval */ + my_byX[len] = my_byX[0] + recur_interval; + + /* sort the array */ + qsort(my_byX, len, sizeof(short), &short_cmp); + + /* check interval between adjacent values */ + for (i = 0; i < len; i++) { + int interval = my_byX[i+1] - my_byX[i]; + + if (interval < min_interval) { + disable = 1; + break; + } + } + + free(my_byX); + + return disable; +} + +static int has_alarms(void *data, struct mailbox *mailbox, + uint32_t uid, unsigned *num_rcpts) { int has_alarms = 0; syslog(LOG_DEBUG, "checking for alarms in mailbox %s uid %u", - mailbox->name, uid); + mailbox_name(mailbox), uid); + + if (mailbox->i.options & OPT_IMAP_HAS_ALARMS) { + if (data && num_rcpts && + mbtype_isa(mailbox_mbtype(mailbox)) == MBTYPE_JMAPSUBMIT) { + json_t *submission = (json_t *) data; + json_t *envelope = json_object_get(submission, "envelope"); + if (envelope) { + *num_rcpts = json_array_size(json_object_get(envelope, "rcptTo")); + } + } + return 1; + } + icalcomponent *ical = (icalcomponent *) data; if (ical) { /* Check iCalendar resource for VALARMs */ icalcomponent *comp = icalcomponent_get_first_real_component(ical); @@ -545,8 +849,84 @@ static int has_alarms(icalcomponent *ical, struct mailbox *mailbox, uint32_t uid syslog(LOG_DEBUG, "checking resource"); for (; comp; comp = icalcomponent_get_next_component(ical, kind)) { + icalproperty *prop; + + /* Disable alarms that fire too frequently */ + for (prop = + icalcomponent_get_first_property(comp, ICAL_RRULE_PROPERTY); + prop; + prop = + icalcomponent_get_next_property(comp, ICAL_RRULE_PROPERTY)) { + + struct icalrecurrencetype rrule = icalproperty_get_rrule(prop); + int recur_interval = rrule.interval; + const char *bypart = ""; + int disable = 0; + + switch (rrule.freq) { + case ICAL_YEARLY_RECURRENCE: + case ICAL_MONTHLY_RECURRENCE: + case ICAL_WEEKLY_RECURRENCE: + /* Any frequency over weekly is pointless, + but we still want to check BY parts for any insanity */ + recur_interval *= 7; + + GCC_FALLTHROUGH + + case ICAL_DAILY_RECURRENCE: + recur_interval *= 24; + + GCC_FALLTHROUGH + + case ICAL_HOURLY_RECURRENCE: + recur_interval *= 60; + + GCC_FALLTHROUGH + + case ICAL_MINUTELY_RECURRENCE: + recur_interval *= 60; + break; + + case ICAL_SECONDLY_RECURRENCE: + default: + break; + } + + if (rrule.by_second[0] != ICAL_RECURRENCE_ARRAY_MAX) { + bypart = "SECOND"; + disable = check_by_array(rrule.by_second, ICAL_BY_SECOND_SIZE, + recur_interval, 1); + } + else if (rrule.by_minute[0] != ICAL_RECURRENCE_ARRAY_MAX) { + bypart = "MINUTE"; + disable = check_by_array(rrule.by_minute, ICAL_BY_MINUTE_SIZE, + recur_interval, 60); + } + else if (rrule.by_hour[0] != ICAL_RECURRENCE_ARRAY_MAX) { + bypart = "HOUR"; + disable = check_by_array(rrule.by_hour, ICAL_BY_HOUR_SIZE, + recur_interval, 3600); + } + else if (recur_interval < min_interval) { + disable = 1; + } + + if (disable) { + xsyslog(LOG_NOTICE, + "Disabling alarms for high frequence calendar entry", + "freq=<%s> interval=<%u> bypart=<%s>" + " mboxname=<%s> imap_uid=<%d>", + icalrecur_freq_to_string(rrule.freq), + rrule.interval, bypart, + mailbox_name(mailbox), uid); + return 0; + } + } + if (icalcomponent_get_first_component(comp, ICAL_VALARM_COMPONENT)) return 1; + else if (icalcomponent_get_usedefaultalerts(comp)) + return 1; } } @@ -555,7 +935,7 @@ static int has_alarms(icalcomponent *ical, struct mailbox *mailbox, uint32_t uid syslog(LOG_DEBUG, "checking per-user-data"); mailbox_get_annotate_state(mailbox, uid, NULL); - annotatemore_findall(mailbox->name, uid, PER_USER_CAL_DATA, /* modseq */ 0, + annotatemore_findall_mailbox(mailbox, uid, PER_USER_CAL_DATA, /* modseq */ 0, &has_peruser_alarms_cb, &hrock, /* flags */ 0); return has_alarms; @@ -563,12 +943,31 @@ static int has_alarms(icalcomponent *ical, struct mailbox *mailbox, uint32_t uid static time_t process_alarms(const char *mboxname, uint32_t imap_uid, const char *userid, icaltimezone *floatingtz, - icalcomponent *ical, time_t lastrun, time_t runtime) + icalcomponent *ical, time_t lastrun, + time_t runtime, int dryrun) { + icalcomponent *myical = NULL; + + /* Add default alarms */ + if (icalcomponent_get_usedefaultalerts(ical)) { + struct defaultalarms defalarms = DEFAULTALARMS_INITIALIZER; + + if (!defaultalarms_load(mboxname, userid, &defalarms)) { + myical = icalcomponent_clone(ical); + defaultalarms_insert(&defalarms, myical, 0); + ical = myical; + } + + defaultalarms_fini(&defalarms); + } + + /* Process alarms */ struct get_alarm_rock rock = - { userid, mboxname, imap_uid, floatingtz, lastrun, runtime, 0 }; + { userid, mboxname, imap_uid, floatingtz, lastrun, runtime, 0, dryrun }; struct icalperiodtype range = icalperiodtype_null_period(); icalcomponent_myforeach(ical, range, floatingtz, process_alarm_cb, &rock); + + if (myical) icalcomponent_free(myical); return rock.nextcheck; } @@ -584,10 +983,10 @@ static int write_lastalarm(struct mailbox *mailbox, struct buf annot_buf = BUF_INITIALIZER; syslog(LOG_DEBUG, "writing last alarm for mailbox %s uid %u", - mailbox->name, record->uid); + mailbox_name(mailbox), record->uid); if (data) { - buf_printf(&annot_buf, "%ld %ld", data->lastrun, data->nextcheck); + buf_printf(&annot_buf, TIME_T_FMT " " TIME_T_FMT, data->lastrun, data->nextcheck); } syslog(LOG_DEBUG, "data: %s", buf_cstring(&annot_buf)); @@ -607,16 +1006,16 @@ static int read_lastalarm(struct mailbox *mailbox, memset(data, 0, sizeof(struct lastalarm_data)); syslog(LOG_DEBUG, "reading last alarm for mailbox %s uid %u", - mailbox->name, record->uid); + mailbox_name(mailbox), record->uid); const char *annotname = DAV_ANNOT_NS "lastalarm"; struct buf annot_buf = BUF_INITIALIZER; mailbox_get_annotate_state(mailbox, record->uid, NULL); - annotatemore_msg_lookup(mailbox->name, record->uid, + annotatemore_msg_lookup(mailbox, record->uid, annotname, "", &annot_buf); if (annot_buf.len && - sscanf(buf_cstring(&annot_buf), "%ld %ld", + sscanf(buf_cstring(&annot_buf), TIME_T_FMT " " TIME_T_FMT, &data->lastrun, &data->nextcheck) == 2) { r = 0; } @@ -625,33 +1024,56 @@ static int read_lastalarm(struct mailbox *mailbox, return r; } +static enum alarm_type mbtype_to_alarm_type(uint32_t mbtype) +{ + enum alarm_type atype = 0; + + switch (mbtype_isa(mbtype)) { + case MBTYPE_CALENDAR: + atype = ALARM_CALENDAR; + break; + case MBTYPE_EMAIL: + atype = ALARM_SNOOZE; + break; + case MBTYPE_JMAPSUBMIT: + atype = ALARM_SEND; + break; + default: + fatal("unknown alarm type", EX_SOFTWARE); + } + + return atype; +} + /* add a calendar alarm */ -EXPORTED int caldav_alarm_add_record(struct mailbox *mailbox, - const struct index_record *record, - icalcomponent *ical) +HIDDEN int caldav_alarm_add_record(struct mailbox *mailbox, + const struct index_record *record, + void *data) { - /* we need to skip silent records (replication) - * because the lastalarm annotation won't be set yet - - * instead, we have an explicit sync from the annotation - * which is done after the annotations are written in sync_support.c */ - if (record->silent) return 0; + unsigned num_rcpts = 0; - if (has_alarms(ical, mailbox, record->uid)) - update_alarmdb(mailbox->name, record->uid, record->internaldate); + if (has_alarms(data, mailbox, record->uid, &num_rcpts)) { + enum alarm_type atype = mbtype_to_alarm_type(mailbox_mbtype(mailbox)); + update_alarmdb(mailbox_name(mailbox), record->uid, record->internaldate, + atype, num_rcpts, 0, 0, NULL); + } return 0; } EXPORTED int caldav_alarm_touch_record(struct mailbox *mailbox, - const struct index_record *record) + const struct index_record *record, + int force) { - /* likewise for touch */ - if (record->silent) return 0; + unsigned num_rcpts = 0; /* if there are alarms in the annotations, * the next alarm may have become earlier, so get calalarmd to check again */ - if (has_alarms(NULL, mailbox, record->uid)) - return update_alarmdb(mailbox->name, record->uid, record->last_updated); + if (force || has_alarms(NULL, mailbox, record->uid, &num_rcpts)) { + enum alarm_type atype = mbtype_to_alarm_type(mailbox_mbtype(mailbox)); + return update_alarmdb(mailbox_name(mailbox), record->uid, + record->last_updated, atype, num_rcpts, 0, 0, NULL); + } return 0; } @@ -659,29 +1081,43 @@ EXPORTED int caldav_alarm_touch_record(struct mailbox *mailbox, /* called by sync_support from sync_server - * set nextcheck in the calalarmdb based on the full state, * record + annotations, after the annotations have been updated too */ -EXPORTED int caldav_alarm_sync_nextcheck(struct mailbox *mailbox, const struct index_record *record) +EXPORTED int caldav_alarm_sync_nextcheck(struct mailbox *mailbox, + const struct index_record *record) { struct lastalarm_data data; - if (!read_lastalarm(mailbox, record, &data)) - return update_alarmdb(mailbox->name, record->uid, data.nextcheck); + if (!read_lastalarm(mailbox, record, &data)) { + enum alarm_type atype = mbtype_to_alarm_type(mailbox_mbtype(mailbox)); + return update_alarmdb(mailbox_name(mailbox), record->uid, + data.nextcheck, atype, 0, 0, 0, NULL); + } /* if there's no lastalarm on the record, nuke any existing alarmdb entry */ - return caldav_alarm_delete_record(mailbox->name, record->uid); + return caldav_alarm_delete_record(mailbox_name(mailbox), record->uid); } /* delete all alarms matching the event */ -EXPORTED int caldav_alarm_delete_record(const char *mboxname, uint32_t imap_uid) +HIDDEN int caldav_alarm_delete_record(const char *mboxname, uint32_t imap_uid) { - return update_alarmdb(mboxname, imap_uid, 0); + return update_alarmdb(mboxname, imap_uid, 0, 0, 0, 0, 0, NULL); } -#define CMD_DELETEMAILBOX \ - "DELETE FROM events WHERE" \ - " mboxname = :mboxname" \ - ";" +static int caldav_alarm_bump_nextcheck(struct caldav_alarm_data *data, + time_t nextcheck, + time_t last_run, const char *last_err) +{ + uint32_t num_retries = data->num_retries; + + if (last_err) num_retries++; + else last_err = data->last_err; + + if (!last_run) last_run = data->last_run; + + return update_alarmdb(data->mboxname, data->imap_uid, nextcheck, data->type, + data->num_rcpts, num_retries, last_run, last_err); +} /* delete all alarms matching the event */ -EXPORTED int caldav_alarm_delete_mailbox(const char *mboxname) +HIDDEN int caldav_alarm_delete_mailbox(const char *mboxname) { struct sqldb_bindval bval[] = { { ":mboxname", SQLITE_TEXT, { .s = mboxname } }, @@ -695,13 +1131,8 @@ EXPORTED int caldav_alarm_delete_mailbox(const char *mboxname) return rc; } -#define CMD_DELETEUSER \ - "DELETE FROM events WHERE" \ - " mboxname LIKE :prefix" \ - ";" - /* delete all alarms matching the event */ -EXPORTED int caldav_alarm_delete_user(const char *userid) +HIDDEN int caldav_alarm_delete_user(const char *userid) { mbname_t *mbname = mbname_from_userid(userid); const char *mboxname = mbname_intname(mbname); @@ -722,24 +1153,33 @@ EXPORTED int caldav_alarm_delete_user(const char *userid) return rc; } -#define CMD_SELECT_ALARMS \ - "SELECT mboxname, imap_uid, nextcheck" \ - " FROM events WHERE" \ - " nextcheck < :before" \ - " ORDER BY mboxname, imap_uid" \ - ";" +struct alarm_read_rock { + ptrarray_t list; + time_t runtime; + time_t next; +}; static int alarm_read_cb(sqlite3_stmt *stmt, void *rock) { - ptrarray_t *target = (ptrarray_t *)rock; - - struct caldav_alarm_data *data = xzmalloc(sizeof(struct caldav_alarm_data)); - - data->mboxname = xstrdup((const char *) sqlite3_column_text(stmt, 0)); - data->imap_uid = sqlite3_column_int(stmt, 1); - data->nextcheck = sqlite3_column_int(stmt, 2); - - ptrarray_append(target, data); + struct alarm_read_rock *alarm = rock; + + time_t nextcheck = sqlite3_column_int(stmt, 2); + + if (nextcheck <= alarm->runtime) { + struct caldav_alarm_data *data = xzmalloc(sizeof(struct caldav_alarm_data)); + data->mboxname = xstrdup((const char *) sqlite3_column_text(stmt, 0)); + data->imap_uid = sqlite3_column_int(stmt, 1); + data->nextcheck = nextcheck; // column 2 + data->type = sqlite3_column_int(stmt, 3); + data->num_rcpts = sqlite3_column_int(stmt, 4); + data->num_retries = sqlite3_column_int(stmt, 5); + data->last_run = sqlite3_column_int(stmt, 6); + data->last_err = xstrdupnull((const char *) sqlite3_column_text(stmt, 7)); + ptrarray_append(&alarm->list, data); + } + else if (nextcheck < alarm->next) { + alarm->next = nextcheck; + } return 0; } @@ -749,6 +1189,8 @@ struct process_alarms_rock { icalcomponent *ical; struct lastalarm_data *alarm; time_t runtime; + int dryrun; + int is_secretarymode; }; static int process_peruser_alarms_cb(const char *mailbox, uint32_t uid, @@ -760,199 +1202,952 @@ static int process_peruser_alarms_cb(const char *mailbox, uint32_t uid, struct process_alarms_rock *prock = (struct process_alarms_rock *) rock; icalcomponent *vpatch, *myical; icaltimezone *floatingtz = NULL; + struct dlist *dl = NULL; + time_t check; if (!mboxname_userownsmailbox(userid, mailbox) && ((prock->mbox_options & OPT_IMAP_SHAREDSEEN) || - mboxlist_checksub(mailbox, userid) != 0)) { - /* No per-user-data, or sharee has unsubscribed from this calendar */ + mboxlist_checksub(mailbox, userid) != 0 || + prock->is_secretarymode)) { + /* No per-user-data, or sharee has unsubscribed from this calendar, + * or calendar is in secretary mode */ return 0; } /* Extract VPATCH from per-user-cal-data annotation */ - vpatch = vpatch_from_peruserdata(value); + dlist_parsemap(&dl, 1, 0, buf_base(value), buf_len(value)); + vpatch = vpatch_from_peruserdata(dl); /* Apply VPATCH to a clone of the iCalendar resource */ - myical = icalcomponent_new_clone(prock->ical); + myical = icalcomponent_clone(prock->ical); icalcomponent_apply_vpatch(myical, vpatch, NULL, NULL); icalcomponent_free(vpatch); /* Fetch per-user timezone for floating events */ - floatingtz = get_floatingtz(mailbox, userid); + floatingtz = caldav_get_calendar_tz(mailbox, userid); /* Process any VALARMs in the patched iCalendar resource */ check = process_alarms(mailbox, uid, userid, floatingtz, myical, - prock->alarm->lastrun, prock->runtime); - if (!prock->alarm->nextcheck || check < prock->alarm->nextcheck) { + prock->alarm->lastrun, prock->runtime, prock->dryrun); + if (!prock->alarm->nextcheck || (check && check < prock->alarm->nextcheck)) { prock->alarm->nextcheck = check; } if (floatingtz) icaltimezone_free(floatingtz, 1); icalcomponent_free(myical); + dlist_free(&dl); return 0; } -static void process_one_record(struct mailbox *mailbox, uint32_t imap_uid, - icaltimezone *floatingtz, time_t runtime) +static int process_valarms(struct mailbox *mailbox, + struct index_record *record, + icaltimezone *floatingtz, time_t runtime, + int dryrun) { - int rc; - icalcomponent *ical = NULL; - - syslog(LOG_DEBUG, "processing alarms for mailbox %s uid %u", - mailbox->name, imap_uid); - - struct index_record record; - memset(&record, 0, sizeof(struct index_record)); - rc = mailbox_find_index_record(mailbox, imap_uid, &record); - if (rc == IMAP_NOTFOUND) { - syslog(LOG_ERR, "not found mailbox %s uid %u", - mailbox->name, imap_uid); - /* no record, no worries */ - caldav_alarm_delete_record(mailbox->name, imap_uid); - goto done_item; - } - if (rc) { - syslog(LOG_ERR, "error reading mailbox %s uid %u (%s)", - mailbox->name, imap_uid, error_message(rc)); - /* XXX no index record? item deleted or transient error? */ - caldav_alarm_delete_record(mailbox->name, imap_uid); - goto done_item; - } - if (record.internal_flags & FLAG_INTERNAL_EXPUNGED) { - syslog(LOG_ERR, "already expunged mailbox %s uid %u", - mailbox->name, imap_uid); - /* no longer exists? nothing to do */ - caldav_alarm_delete_record(mailbox->name, imap_uid); - goto done_item; - } - - ical = record_to_ical(mailbox, &record, NULL); + icalcomponent *ical = record_to_ical(mailbox, record, NULL); + const char *mboxname = mailbox_name(mailbox); if (!ical) { syslog(LOG_ERR, "error parsing ical string mailbox %s uid %u", - mailbox->name, imap_uid); - caldav_alarm_delete_record(mailbox->name, imap_uid); + mboxname, record->uid); + caldav_alarm_delete_record(mboxname, record->uid); + return 0; + } + + /* ensure this record corresponds to the current version of the event */ + struct caldav_db *db = caldav_open_mailbox(mailbox); + struct caldav_data *cdata; + if (!db || + caldav_lookup_uid(db, icalcomponent_get_uid(ical), &cdata) || + record->uid != cdata->dav.imap_uid || + strcmp(cdata->dav.mailbox_byname ? mboxname : mailbox_uniqueid(mailbox), + cdata->dav.mailbox)) { + syslog(LOG_NOTICE, "removing bogus lastalarm check " + "for mailbox %s uid %u which is not current event", + mboxname, record->uid); + caldav_alarm_delete_record(mboxname, record->uid); goto done_item; } /* check for bogus lastalarm data on record which actually shouldn't have it */ - if (!has_alarms(ical, mailbox, imap_uid)) { + if (!has_alarms(ical, mailbox, record->uid, NULL)) { syslog(LOG_NOTICE, "removing bogus lastalarm check " "for mailbox %s uid %u which has no alarms", - mailbox->name, imap_uid); - caldav_alarm_delete_record(mailbox->name, imap_uid); + mboxname, record->uid); + caldav_alarm_delete_record(mboxname, record->uid); + goto done_item; + } + + /* don't process alarms in draft messages */ + if (record->system_flags & FLAG_DRAFT) { + syslog(LOG_NOTICE, "ignoring draft message in mailbox %s uid %u", + mailbox_name(mailbox), record->uid); goto done_item; } struct lastalarm_data data; - if (read_lastalarm(mailbox, &record, &data)) - data.lastrun = record.internaldate; + if (read_lastalarm(mailbox, record, &data)) + data.lastrun = record->internaldate; /* Process VALARMs in iCalendar resource */ - char *userid = mboxname_to_userid(mailbox->name); + char *userid = mboxname_to_userid(mboxname); syslog(LOG_DEBUG, "processing alarms in resource"); - data.nextcheck = process_alarms(mailbox->name, record.uid, userid, - floatingtz, ical, data.lastrun, runtime); + data.nextcheck = process_alarms(mboxname, record->uid, userid, + floatingtz, ical, data.lastrun, runtime, dryrun); free(userid); + + /* Determine JMAP secretary mode for this account */ + int is_secretarymode = 0; + mbname_t *mbname = mbname_from_intname(mailbox_name(mailbox)); + const strarray_t *boxes = mbname_boxes(mbname); + const char *prefix = config_getstring(IMAPOPT_CALENDARPREFIX); + if (strarray_size(boxes) && !strcmpsafe(prefix, strarray_nth(boxes, 0))) { + mbname_truncate_boxes(mbname, 1); + static const char *annot = + DAV_ANNOT_NS "<" XML_NS_JMAPCAL ">sharees-act-as"; + struct buf val = BUF_INITIALIZER; + annotatemore_lookup(mbname_intname(mbname), annot, "", &val); + is_secretarymode = !strcmp(buf_cstring(&val), "secretary"); + buf_free(&val); + } + mbname_free(&mbname); + /* Process VALARMs in per-user-cal-data */ struct process_alarms_rock prock = - { mailbox->i.options, ical, &data, runtime }; + { mailbox->i.options, ical, &data, runtime, dryrun, is_secretarymode }; syslog(LOG_DEBUG, "processing per-user alarms"); - mailbox_get_annotate_state(mailbox, record.uid, NULL); - annotatemore_findall(mailbox->name, record.uid, PER_USER_CAL_DATA, + mailbox_get_annotate_state(mailbox, record->uid, NULL); + annotatemore_findall_mailbox(mailbox, record->uid, PER_USER_CAL_DATA, /* modseq */ 0, &process_peruser_alarms_cb, &prock, /* flags */ 0); data.lastrun = runtime; - write_lastalarm(mailbox, &record, &data); + if (!dryrun) write_lastalarm(mailbox, record, &data); - update_alarmdb(mailbox->name, record.uid, data.nextcheck); + update_alarmdb(mboxname, record->uid, data.nextcheck, + ALARM_CALENDAR, 0, 0, 0, NULL); done_item: if (ical) icalcomponent_free(ical); + caldav_close(db); + return 0; } -static void process_records(ptrarray_t *list, time_t runtime) +#ifdef WITH_JMAP +static int move_to_mailboxid(struct mailbox *srcmbox, + struct index_record *record, + const char *destmboxid, time_t savedate, + json_t *setkeywords, int is_snoozed) + { - struct mailbox *mailbox = NULL; - int rc; - int i; - icaltimezone *floatingtz = NULL; - - syslog(LOG_DEBUG, "processing records"); + struct buf buf = BUF_INITIALIZER; + msgrecord_t *mr = NULL; + mbname_t *mbname = NULL; + struct appendstate as; + struct mailbox *destmbox = NULL; + struct auth_state *authstate = NULL; + const char *userid; + char *destname = NULL; + struct stagemsg *stage = NULL; + struct entryattlist *annots = NULL; + strarray_t *flags = NULL; + struct body *body = NULL; + FILE *f = NULL; + int r = 0; + + syslog(LOG_DEBUG, "moving message %s:%u to mailboxid %s", + mailbox_name(srcmbox), record->uid, destmboxid); + + mr = msgrecord_from_index_record(srcmbox, record); + if (!mr) goto done; + + /* Fetch message */ + r = msgrecord_get_body(mr, &buf); + if (r) goto done; + + /* Fetch annotations */ + r = msgrecord_extract_annots(mr, &annots); + if (r) goto done; + + mbname = mbname_from_intname(mailbox_name(srcmbox)); + mbname_set_boxes(mbname, NULL); + userid = mbname_userid(mbname); + authstate = auth_newstate(userid); + + /* Fetch flags */ + r = msgrecord_extract_flags(mr, userid, &flags); + if (r) goto done; + + if (is_snoozed) { + /* Add \snoozed pseudo-flag */ + strarray_add(flags, "\\snoozed"); + } - for (i = 0; i < list->count; i++) { - struct caldav_alarm_data *data = ptrarray_nth(list, i); + /* (Un)set any client-supplied flags */ + if (setkeywords) { + const char *key; + json_t *val; - if (mailbox && !strcmp(mailbox->name, data->mboxname)) { - /* woot, reuse mailbox */ - } - else { - if (floatingtz) icaltimezone_free(floatingtz, 1); - floatingtz = NULL; - mailbox_close(&mailbox); - rc = mailbox_open_iwl(data->mboxname, &mailbox); - if (rc == IMAP_MAILBOX_NONEXISTENT) { - /* mailbox was deleted or something, nothing we can do */ - data->nextcheck = 0; - continue; - } - if (rc) { - /* transient open error, don't delete this alarm */ - continue; + json_object_foreach(setkeywords, key, val) { + const char *flag = jmap_keyword_to_imap(key); + if (flag) { + if (json_is_true(val)) strarray_add_case(flags, flag); + else strarray_remove_all_case(flags, flag); } - floatingtz = get_floatingtz(mailbox->name, ""); } - process_one_record(mailbox, data->imap_uid, floatingtz, runtime); } - if (floatingtz) icaltimezone_free(floatingtz, 1); - mailbox_close(&mailbox); -} + /* Determine destination mailbox of moved email */ + if (destmboxid) { + mbentry_t *mbentry = NULL; + r = mboxlist_lookup_by_uniqueid(destmboxid, &mbentry, NULL); + if (!r && mbentry && + // MUST be an email mailbox + (mbtype_isa(mbentry->mbtype) == MBTYPE_EMAIL) && + // MUST NOT be deleted + !(mbentry->mbtype & MBTYPE_DELETED) && + // MUST be able to append messages + (cyrus_acl_myrights(authstate, mbentry->acl) & ACL_INSERT) && + // MUST NOT be DELETED mailbox + !mboxname_isdeletedmailbox(mbentry->name, NULL) && + // MUST NOT be our source mailbox + strcmp(mbentry->name, mailbox_name(srcmbox))) { + destname = xstrdup(mbentry->name); + } + mboxlist_entry_free(&mbentry); + + if (!destname && !is_snoozed) { + /* Fallback to \Sent mailbox */ + destname = mboxlist_find_specialuse("\\Sent", userid); + } + } + else if (!is_snoozed) { + /* onSend with no destination, just remove from \Scheduled */ + goto expunge; + } + if (!destname) { + /* Fallback to INBOX */ + destname = xstrdup(mbname_intname(mbname)); + } + + /* Fetch message filename */ + const char *fname; + r = msgrecord_get_fname(mr, &fname); + if (r) goto done; + + /* Prepare to stage the message */ + if (!(f = append_newstage_full(destname, time(0), 0, &stage, fname))) { + syslog(LOG_ERR, "append_newstage(%s) failed", destname); + r = IMAP_IOERROR; + goto done; + } + fclose(f); + + r = mailbox_open_iwl(destname, &destmbox); + if (r) goto done; + + /* XXX: should we look for an existing record with that GUID in the target folder + * first and just remove this copy if so? Otherwise we could duplicate if the + * update fails between the append to the new mailbox and the expunge from Snoozed + */ + + r = append_setup_mbox(&as, destmbox, userid, authstate, + ACL_INSERT, NULL, NULL, 0, + is_snoozed ? EVENT_MESSAGE_NEW : EVENT_MESSAGE_APPEND); + if (r) goto done; + + /* Append the message to the mailbox */ + r = append_fromstage_full(&as, &body, stage, record->internaldate, + savedate, 0, flags, 0, &annots); + if (r) { + append_abort(&as); + goto done; + } + + r = append_commit(&as); + if (r) goto done; + + expunge: + /* Expunge the resource from the source mailbox (also unset \snoozed) */ + record->internal_flags |= FLAG_INTERNAL_EXPUNGED; + if (is_snoozed) record->internal_flags &= ~FLAG_INTERNAL_SNOOZED; + r = mailbox_rewrite_index_record(srcmbox, record); + if (r) { + syslog(LOG_ERR, "expunging record (%s:%u) failed: %s", + mailbox_name(srcmbox), record->uid, error_message(r)); + } + + done: + if (body) { + message_free_body(body); + free(body); + } + strarray_free(flags); + freeentryatts(annots); + append_removestage(stage); + + mailbox_close(&destmbox); + if (authstate) auth_freestate(authstate); + if (mbname) mbname_free(&mbname); + if (mr) msgrecord_unref(&mr); + buf_free(&buf); + free(destname); + + return r; +} + +struct find_sched_rock { + char *userid; + char *mboxname; + uint32_t uid; +}; + +static int find_sched_cb(const conv_guidrec_t *rec, void *rock) +{ + struct find_sched_rock *frock = (struct find_sched_rock *) rock; + mbentry_t *mbentry = NULL; + int res = 0; + + /* We're looking for whole, non-expunged messages */ + if (rec->part || + (rec->system_flags & FLAG_DELETED) || + (rec->internal_flags & FLAG_INTERNAL_EXPUNGED)) { + return 0; + } + + /* Lookup mailbox and make sure it is \Scheduled */ + conv_guidrec_mbentry(rec, &mbentry); + + if (!mbentry) return 0; + + if (mboxname_isscheduledmailbox(mbentry->name, mbentry->mbtype)) { + frock->mboxname = xstrdup(mbentry->name); + frock->uid = rec->uid; + res = IMAP_OK_COMPLETED; + } + + mboxlist_entry_free(&mbentry); + + return res; +} + +static int find_scheduled_email(const char *emailid, + struct find_sched_rock *frock) +{ + struct conversations_state *cstate = NULL; + int r; + + if (emailid[0] != 'M' || strlen(emailid) != 25) { + return IMAP_NOTFOUND; + } + + r = conversations_open_user(frock->userid, 1/*shared*/, &cstate); + if (r) { + syslog(LOG_ERR, "IOERROR: failed to open conversations for user %s", + frock->userid); + return r; + } + + const char *guid = emailid + 1; + r = conversations_guid_foreach(cstate, guid, find_sched_cb, frock); + conversations_commit(&cstate); + + if (r == IMAP_OK_COMPLETED) r = 0; + else if (!frock->mboxname) r = IMAP_NOTFOUND; + + return r; +} + +static int count_cb(sqlite3_stmt *stmt, void *rock) +{ + unsigned *count = (unsigned *) rock; + + *count = sqlite3_column_int(stmt, 0); + + return 0; +} + +#define CMD_GET_UNSCHEDULED_COUNT \ + "SELECT num_retries" \ + " FROM events WHERE" \ + " mboxname = :mboxname AND" \ + " imap_uid = :imap_uid AND" \ + " type = :type" \ + ";" + +static int update_unscheduled(const char *mboxname, time_t nextcheck) +{ + struct sqldb_bindval bval[] = { + { ":mboxname", SQLITE_TEXT, { .s = mboxname } }, + { ":imap_uid", SQLITE_INTEGER, { .i = 0 } }, + { ":nextcheck", SQLITE_INTEGER, { .i = nextcheck } }, + { ":type", SQLITE_INTEGER, { .i = ALARM_UNSCHEDULED } }, + { ":num_rcpts", SQLITE_INTEGER, { .i = 0 } }, + { ":num_retries", SQLITE_INTEGER, { .i = 0 } }, + { ":last_run", SQLITE_INTEGER, { .i = 0 } }, + { ":last_err", SQLITE_TEXT, { .s = NULL } }, + { NULL, SQLITE_NULL, { .s = NULL } } + }; + + sqldb_t *alarmdb = caldav_alarm_open(); + if (!alarmdb) return -1; + + syslog(LOG_DEBUG, "update_unscheduled(%s, " TIME_T_FMT ")", + mboxname, nextcheck); + + unsigned count = 0; + int rc = sqldb_exec(alarmdb, CMD_GET_UNSCHEDULED_COUNT, + bval, &count_cb, &count); + + if (rc == SQLITE_OK) { + bval[5].val.i = ++count; // num_retries used as unscheduled count + rc = sqldb_exec(alarmdb, CMD_REPLACE, bval, NULL, NULL); + } + + caldav_alarm_close(alarmdb); + + if (rc == SQLITE_OK) return 0; + + /* failed? */ + return -1; +} + +static int process_futurerelease(struct caldav_alarm_data *data, + struct mailbox *mailbox, + struct index_record *record, + time_t runtime) + +{ + message_t *m = message_new_from_record(mailbox, record); + struct buf buf = BUF_INITIALIZER; + json_t *submission = NULL, *identity, *envelope, *onSend; + smtpclient_t *sm = NULL; + int do_move = 0; + int r = 0; + + syslog(LOG_DEBUG, "processing future release for mailbox %s uid %u", + mailbox_name(mailbox), record->uid); + + if (record->system_flags & FLAG_ANSWERED) { + syslog(LOG_NOTICE, "email already sent for mailbox %s uid %u", + mailbox_name(mailbox), record->uid); + r = IMAP_NO_NOSUCHMSG; + goto done; + } + + if (record->system_flags & FLAG_FLAGGED) { + syslog(LOG_NOTICE, "submission canceled for mailbox %s uid %u", + mailbox_name(mailbox), record->uid); + r = IMAP_NO_NOSUCHMSG; + goto done; + } + + /* Parse the submission object from the header field */ + r = message_get_field(m, JMAP_SUBMISSION_HDR, MESSAGE_RAW, &buf); + if (!r) { + json_error_t jerr; + submission = json_loadb(buf_base(&buf), buf_len(&buf), + JSON_DISABLE_EOF_CHECK, &jerr); + } + if (!submission) { + syslog(LOG_ERR, + "process_futurerelease: failed to parse submission obj"); + goto done; + } + envelope = json_object_get(submission, "envelope"); + identity = json_object_get(submission, "identityId"); + onSend = json_object_get(submission, "onSend"); + if (JNULL(onSend)) { + /* Treat onSend:null as non-existent */ + onSend = NULL; + } + + /* Load message */ + r = message_get_field(m, "rawbody", MESSAGE_RAW, &buf); + if (r) { + syslog(LOG_ERR, "process_futurerelease: can't get body for %s:%u", + mailbox_name(mailbox), record->uid); + goto done; + } + + /* Open the SMTP connection */ + unsigned code = 0, cancel = 0; + const char *err = NULL; + r = smtpclient_open(&sm); + if (r) { + err = error_message(r); + syslog(LOG_ERR, "smtpclient_open failed: %s", err); + } + else { + /* Set AUTH ID */ + char *authid = mboxname_to_userid(mailbox_name(mailbox)); + smtpclient_set_auth(sm, authid); + free(authid); + + /* Set IDENTITY ID */ + if (JNOTNULL(identity)) { + const char *jmapid = json_string_value(identity); + + /* Prefer mailFrom IDENTITY parameter if it is numeric, + and the toplevel identityId is not */ + if (strchr(jmapid, '@') && envelope) { + json_t *from_params = + json_object_get(json_object_get(envelope, "mailFrom"), + "parameters"); + if (from_params && + (identity = json_object_get(from_params, "IDENTITY")) && + !strchr(json_string_value(identity), '@')) { + jmapid = json_string_value(identity); + } + } + smtpclient_set_jmapid(sm, jmapid); + } + + /* Prepare envelope */ + smtp_envelope_t smtpenv = SMTP_ENVELOPE_INITIALIZER; + jmap_emailsubmission_envelope_to_smtp(&smtpenv, envelope); + + /* Send message */ + r = smtpclient_send(sm, &smtpenv, &buf); + smtp_envelope_fini(&smtpenv); + if (r) { + /* Get the response code and error text. + We treat anything other than 5xx as a temp failure */ + code = smtpclient_get_resp_code(sm); + if (code >= 500) { + /* Permanent failure */ + cancel = 1; + } + if (code) { + err = smtpclient_get_resp_text(sm); + } + if (!err) { + err = error_message(r); + } + syslog(LOG_ERR, "smtpclient_send failed: %s", err); + } + } + + const char *destmboxid = NULL; + json_t *setkeywords = NULL; + char *userid = NULL; + + if (r) { + /* Determine if we should retry (again) or cancel the submission. + We try at 5m, 15m, 30m, 60m after original scheduled time. */ + unsigned duration; + switch (data->num_retries) { + case 0: duration = 300; break; + case 1: duration = 600; break; + case 2: duration = 900; break; + case 3: duration = 1800; break; + default: cancel = 1; break; + } + + if (!cancel) { + /* Retry */ + caldav_alarm_bump_nextcheck(data, runtime + duration, runtime, err); + if (sm) smtpclient_close(&sm); + goto done; + } + else if (onSend) { + /* Move the scheduled message back into Drafts mailbox. + Use INBOX as a fallback. */ + do_move = 1; + userid = mboxname_to_userid(data->mboxname); + + char *destname = mboxlist_find_specialuse("\\Drafts", userid); + mbentry_t *mbentry = NULL; + + if (!destname) { + destname = mboxname_user_mbox(userid, NULL); + } + + mboxlist_lookup(destname, &mbentry, NULL); + if (mbentry) { + buf_setcstr(&buf, mbentry->uniqueid); + + destmboxid = buf_cstring(&buf); + setkeywords = json_pack("{ s:b }", "$draft", 1); + + mboxlist_entry_free(&mbentry); + } + free(destname); + } + } + else { + /* Mark the email as sent */ + record->system_flags |= FLAG_ANSWERED; + + /* Get any onSend instructions */ + if (onSend) { + do_move = 1; + destmboxid = + json_string_value(json_object_get(onSend, "moveToMailboxId")); + setkeywords = json_deep_copy(json_object_get(onSend, "setKeywords")); + } + } + if (sm) smtpclient_close(&sm); + + if (cancel || config_getswitch(IMAPOPT_JMAPSUBMISSION_DELETEONSEND)) { + /* Delete the EmailSubmission object immediately */ + record->system_flags |= FLAG_DELETED; + record->internal_flags |= FLAG_INTERNAL_EXPUNGED; + } + + r = mailbox_rewrite_index_record(mailbox, record); + if (r) { + syslog(LOG_ERR, "IOERROR: marking emailsubmission as %s (%s:%u) failed: %s", + cancel ? "cancelled" : "sent", + mailbox_name(mailbox), record->uid, error_message(r)); + // email is already sent, so we don't want to try to send it again! + // go ahead and delete the record still... + } + + caldav_alarm_delete_record(mailbox_name(mailbox), record->uid); + + if (do_move) { + /* Move the scheduled message into the specified mailbox */ + if (!userid) userid = mboxname_to_userid(data->mboxname); + + const char *emailid = + json_string_value(json_object_get(submission, "emailId")); + struct find_sched_rock frock = { userid, NULL, 0 }; + struct mailbox *sched_mbox = NULL; + struct index_record sched_rec; + + /* Locate email in \Scheduled mailbox */ + r = find_scheduled_email(emailid, &frock); + + if (r || !frock.mboxname) { + syslog(LOG_ERR, + "IOERROR: failed to find \\Scheduled mailbox for user %s (%s)", + frock.userid, error_message(r)); + } + else if ((r = mailbox_open_iwl(frock.mboxname, &sched_mbox))) { + syslog(LOG_ERR, "IOERROR: failed to open %s: %s", + frock.mboxname, error_message(r)); + } + else if ((r = mailbox_find_index_record(sched_mbox, + frock.uid, &sched_rec))) { + syslog(LOG_ERR, "IOERROR: failed find message %u in %s: %s", + frock.uid, frock.mboxname, error_message(r)); + } + else { + r = move_to_mailboxid(sched_mbox, &sched_rec, destmboxid, + time(0), setkeywords, 0/*is_snoozed*/); + + if (r) { + syslog(LOG_ERR, "IOERROR: failed to move %s:%u (%s)", + frock.mboxname, frock.uid, error_message(r)); + + } + else if (cancel) { + update_unscheduled(data->mboxname, runtime + 300); + } + } + + if (setkeywords) json_decref(setkeywords); + mailbox_close(&sched_mbox); + free(frock.mboxname); + } + free(userid); + + done: + if (submission) json_decref(submission); + if (m) message_unref(&m); + buf_free(&buf); + + return r; +} + +static int process_snoozed(struct caldav_alarm_data *data, + struct mailbox *mailbox, + struct index_record *record, + time_t runtime, + int dryrun) +{ + json_t *snoozed, *destmboxid, *setkeywords; + time_t wakeup; + int r = 0; + + syslog(LOG_DEBUG, "processing snoozed email for mailbox %s uid %u", + mailbox_name(mailbox), record->uid); + + /* Get the snoozed annotation */ + snoozed = jmap_fetch_snoozed(mailbox_name(mailbox), record->uid); + if (!snoozed) { + // no worries, let's not try again + caldav_alarm_delete_record(mailbox_name(mailbox), record->uid); + goto done; + } + + /* Extract until (wakeup) */ + time_from_iso8601(json_string_value(json_object_get(snoozed, "until")), + &wakeup); + + /* Check runtime against wakeup and adjust as necessary */ + if (dryrun || wakeup > runtime) { + caldav_alarm_bump_nextcheck(data, wakeup, 0, NULL); + goto done; + } + + destmboxid = json_object_get(snoozed, "moveToMailboxId"); + setkeywords = json_object_get(snoozed, "setKeywords"); + + r = move_to_mailboxid(mailbox, record, json_string_value(destmboxid), + wakeup, setkeywords, 1/*is_snoozed*/); + + if (r) { + syslog(LOG_ERR, "IOERROR: failed to unsnooze %s:%u (%s)", + mailbox_name(mailbox), record->uid, error_message(r)); + /* try again in 5 minutes */ + caldav_alarm_bump_nextcheck(data, runtime + 300, runtime, error_message(r)); + } + + done: + if (snoozed) json_decref(snoozed); + + return r; +} +#endif /* WITH_JMAP */ + +static void process_unscheduled(struct caldav_alarm_data *data) +{ + struct mboxevent *event = mboxevent_new(EVENT_MESSAGES_UNSCHEDULED); + + if (event) { + char *userid = mboxname_to_userid(data->mboxname); + + FILL_STRING_PARAM(event, EVENT_MESSAGES_UNSCHEDULED_USERID, userid); + FILL_UNSIGNED_PARAM(event, EVENT_MESSAGES_UNSCHEDULED_COUNT, data->num_retries); + + mboxevent_notify(&event); + mboxevent_free(&event); + } + else { + syslog(LOG_NOTICE, + "failed to create UNSCHEDULED notification for mailbox %s", + data->mboxname); + + } + + caldav_alarm_delete_record(data->mboxname, data->imap_uid); +} + +static void process_one_record(struct caldav_alarm_data *data, time_t runtime, int dryrun) +{ + int r; + struct mailbox *mailbox = NULL; + + syslog(LOG_DEBUG, + "processing alarms for mailbox %s uid %u type %u retries %u", + data->mboxname, data->imap_uid, data->type, data->num_retries); + + if (data->type == ALARM_UNSCHEDULED) { + process_unscheduled(data); + return; + } + + r = dryrun ? mailbox_open_irl(data->mboxname, &mailbox) : mailbox_open_iwl(data->mboxname, &mailbox); + if (r == IMAP_MAILBOX_NONEXISTENT) { + syslog(LOG_ERR, "not found mailbox %s", data->mboxname); + /* no record, no worries */ + caldav_alarm_delete_record(data->mboxname, data->imap_uid); + return; + } + else if (r) { + /* Temporary error - skip over this message for now and try again in 5 minutes */ + syslog(LOG_ERR, "IOERROR: failed to open mailbox %s for uid %u (%s)", + data->mboxname, data->imap_uid, error_message(r)); + caldav_alarm_bump_nextcheck(data, runtime + 300, runtime, error_message(r)); + return; + } + + struct index_record record; + memset(&record, 0, sizeof(struct index_record)); + r = mailbox_find_index_record(mailbox, data->imap_uid, &record); + if (r == IMAP_NOTFOUND) { + syslog(LOG_NOTICE, "not found mailbox %s uid %u", + data->mboxname, data->imap_uid); + /* no record, no worries */ + caldav_alarm_delete_record(data->mboxname, data->imap_uid); + goto done; + } + if (r) { + syslog(LOG_ERR, "IOERROR: error reading mailbox %s uid %u (%s)", + data->mboxname, data->imap_uid, error_message(r)); + /* XXX no index record? item deleted or transient error? */ + caldav_alarm_bump_nextcheck(data, runtime + 300, runtime, error_message(r)); + goto done; + } + + if (record.internal_flags & FLAG_INTERNAL_EXPUNGED) { + syslog(LOG_NOTICE, "already expunged mailbox %s uid %u", + data->mboxname, data->imap_uid); + /* no longer exists? nothing to do */ + caldav_alarm_delete_record(data->mboxname, data->imap_uid); + goto done; + } + + switch (data->type) { + case ALARM_CALENDAR: { + icaltimezone *floatingtz = caldav_get_calendar_tz(mailbox_name(mailbox), ""); + r = process_valarms(mailbox, &record, floatingtz, runtime, dryrun); + if (floatingtz) icaltimezone_free(floatingtz, 1); + break; + } +#ifdef WITH_JMAP + case ALARM_SEND: + if (record.internaldate > runtime || dryrun) { + caldav_alarm_bump_nextcheck(data, record.internaldate, 0, NULL); + goto done; + } + r = process_futurerelease(data, mailbox, &record, runtime); + break; + + case ALARM_SNOOZE: + /* XXX Check special-use flag on mailbox */ + r = process_snoozed(data, mailbox, &record, runtime, dryrun); + break; +#endif + default: + /* XXX Should never get here */ + syslog(LOG_ERR, "Unknown/unsupported alarm triggered for" + " mailbox %s uid %u of type %d with options 0x%02x", + data->mboxname, data->imap_uid, + mailbox_mbtype(mailbox), mailbox->i.options); + caldav_alarm_delete_record(data->mboxname, data->imap_uid); + break; + } + +done: + if (r) mailbox_abort(mailbox); + mailbox_close(&mailbox); +} + +#define MAX_CONSECUTIVE_ALARMS_PER_USER 50 /* process alarms with triggers before a given time */ -EXPORTED int caldav_alarm_process(time_t runtime) +EXPORTED int caldav_alarm_process(time_t runtime, time_t *intervalp, int dryrun) { + int i; + + // we don't process alarms on replicas + if (config_getswitch(IMAPOPT_REPLICAONLY)) + return 0; + + // temporarily disable alarms + const char *suppress_file = config_getstring(IMAPOPT_CALDAV_ALARM_SUPPRESS_FILE); + if (suppress_file) { + struct stat sbuf; + if (!stat(suppress_file, &sbuf)) { + syslog(LOG_NOTICE, "NOTICE: suppressing alarms due to existence of %s", suppress_file); + return 0; + } + } + syslog(LOG_DEBUG, "processing alarms"); if (!runtime) { - /* check 10s into the future - - * we run every 10, so that guarantees we will - * deliver on or before the target time */ - runtime = time(NULL) + 10; + runtime = time(NULL); } + struct alarm_read_rock rock = { PTRARRAY_INITIALIZER, runtime, runtime + 10 }; + + // check 10 seconds into the future - if there's something in there, + // we'll run again - otherwise we'll wait the 10 seconds before checking again struct sqldb_bindval bval[] = { - { ":before", SQLITE_INTEGER, { .i = runtime } }, - { NULL, SQLITE_NULL, { .s = NULL } } + { ":before", SQLITE_INTEGER, { .i = rock.next } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; sqldb_t *alarmdb = caldav_alarm_open(); if (!alarmdb) return HTTP_SERVER_ERROR; - ptrarray_t list = PTRARRAY_INITIALIZER; - - int rc = sqldb_exec(alarmdb, CMD_SELECT_ALARMS, bval, &alarm_read_cb, &list); + int rc = sqldb_exec(alarmdb, CMD_SELECT_ALARMS, bval, &alarm_read_cb, &rock); caldav_alarm_close(alarmdb); - process_records(&list, runtime); + if (intervalp) { + // we want to restrict the number of records processed per user per run, + // and also take a non-blocking lock so we're never waiting while other + // things process + int skipped_some = 0; + int did_some = 0; + int num_user_records = 0; + int skip_user = 0; + char *userid = NULL; + struct mboxlock *nslock = NULL; + for (i = 0; i < rock.list.count; i++) { + struct caldav_alarm_data *data = ptrarray_nth(&rock.list, i); + + // only alarms for mailboxes with userids + mbname_t *mbname = mbname_from_intname(data->mboxname); + if (!mbname_userid(mbname)) { + mbname_free(&mbname); + continue; + } - int i; - for (i = 0; i < list.count; i++) { - struct caldav_alarm_data *data = ptrarray_nth(&list, i); - caldav_alarm_fini(data); - free(data); + // we are sorted by mboxname, so all the mailboxes for the same + // userid will be next to each other + if (strcmpsafe(userid, mbname_userid(mbname))) { + num_user_records = 0; + skip_user = 0; + free(userid); + mboxname_release(&nslock); + userid = xstrdup(mbname_userid(mbname)); + if (user_isreplicaonly(userid)) { + skip_user = 1; + continue; + } + nslock = user_namespacelock_full(userid, LOCK_NONBLOCKING); + } + mbname_free(&mbname); + + if (skip_user) continue; + + // if we failed to lock the user, or have done too many for this user, skip + if (!nslock || ++num_user_records > MAX_CONSECUTIVE_ALARMS_PER_USER) { + skipped_some++; + caldav_alarm_fini(data); + free(data); + continue; + } + + did_some++; + process_one_record(data, runtime, dryrun); + caldav_alarm_fini(data); + free(data); + } + + free(userid); + mboxname_release(&nslock); + + // if we both made some progress AND skipped some, then retry again immediately + if (did_some && skipped_some) *intervalp = 0; + else *intervalp = rock.next - runtime; + } + else { + // we're testing or reconstructing, run everything! + for (i = 0; i < rock.list.count; i++) { + struct caldav_alarm_data *data = ptrarray_nth(&rock.list, i); + process_one_record(data, runtime, dryrun); + caldav_alarm_fini(data); + free(data); + } } - ptrarray_fini(&list); + + ptrarray_fini(&rock.list); syslog(LOG_DEBUG, "done"); @@ -1003,7 +2198,7 @@ EXPORTED int caldav_alarm_upgrade() caldav_alarm_close(alarmdb); if (rc) continue; - icaltimezone *floatingtz = get_floatingtz(mailbox->name, ""); + icaltimezone *floatingtz = caldav_get_calendar_tz(mailbox_name(mailbox), ""); /* add alarms for all records */ struct mailbox_iter *iter = @@ -1014,16 +2209,15 @@ EXPORTED int caldav_alarm_upgrade() icalcomponent *ical = record_to_ical(mailbox, record, NULL); if (ical) { - if (has_alarms(ical, mailbox, record->uid)) { - char *userid = mboxname_to_userid(mailbox->name); - time_t nextcheck = process_alarms(mailbox->name, record->uid, + if (has_alarms(ical, mailbox, record->uid, NULL)) { + char *userid = mboxname_to_userid(mailbox_name(mailbox)); + time_t nextcheck = process_alarms(mailbox_name(mailbox), record->uid, userid, floatingtz, ical, - runtime, runtime); + runtime, runtime, /*dryrun*/1); free(userid); - struct lastalarm_data data = { runtime, nextcheck }; - write_lastalarm(mailbox, record, &data); - update_alarmdb(mailbox->name, record->uid, data.nextcheck); + update_alarmdb(mailbox_name(mailbox), record->uid, nextcheck, + ALARM_CALENDAR, 0, 0, 0, NULL); } icalcomponent_free(ical); } @@ -1044,3 +2238,152 @@ EXPORTED int caldav_alarm_upgrade() return rc; } + + +#define CMD_UPGRADEv4_SET_TYPE_LIKE \ + "INSERT INTO new_events" \ + " SELECT *, :type, 0, 0, 0, NULL FROM events" \ + " WHERE mboxname LIKE :mboxpat1 ;" + +#define CMD_UPGRADEv4_SET_TYPE_NOT_LIKE \ + "INSERT INTO new_events" \ + " SELECT *, :type, 0, 0, 0, NULL FROM events" \ + " WHERE mboxname NOT LIKE :mboxpat1" \ + " AND mboxname NOT LIKE :mboxpat2 ;" + +#define CMD_UPGRADEv4_FINISH \ + "DROP TABLE events;" \ + "ALTER TABLE new_events RENAME TO events;" \ + CMD_CREATE_INDEXES + +static int upgradev4(sqldb_t *db) +{ + struct sqldb_bindval bval[] = { + { ":type", SQLITE_INTEGER, { .i = 0 } }, + { ":mboxpat1", SQLITE_TEXT, { .s = NULL } }, + { ":mboxpat2", SQLITE_TEXT, { .s = NULL } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; + struct buf calpat = BUF_INITIALIZER; + struct buf subpat = BUF_INITIALIZER; + int r = 0; + + if (db->version == 2) { + buf_printf(&calpat, "%%.%s.%%", + config_getstring(IMAPOPT_CALENDARPREFIX)); + + buf_printf(&subpat, "%%.%s", + config_getstring(IMAPOPT_JMAPSUBMISSIONFOLDER)); + + /* Create new table */ + r = sqldb_exec(db, CMD_CREATE_TABLE("new_events"), NULL, NULL, NULL); + if (r) goto done; + + /* Rewrite calendar alarm records */ + bval[0].val.i = ALARM_CALENDAR; + bval[1].val.s = buf_cstring(&calpat); + r = sqldb_exec(db, CMD_UPGRADEv4_SET_TYPE_LIKE, bval, NULL, NULL); + if (r) goto done; + + /* Rewrite JMAP submission records */ + bval[0].val.i = ALARM_SEND; + bval[1].val.s = buf_cstring(&subpat); + r = sqldb_exec(db, CMD_UPGRADEv4_SET_TYPE_LIKE, bval, NULL, NULL); + if (r) goto done; + + /* Rewrite JMAP snooze records */ + bval[0].val.i = ALARM_SNOOZE; + bval[1].val.s = buf_cstring(&calpat); + bval[2].val.s = buf_cstring(&subpat); + r = sqldb_exec(db, CMD_UPGRADEv4_SET_TYPE_NOT_LIKE, bval, NULL, NULL); + if (r) goto done; + + /* Drop old table, rename new table, and create indexes. + + XXX We avoid using sqldb_exec() because sqlite3_prepare_v2() + XXX only supports one command at a time. + */ + r = sqlite3_exec(db->db, CMD_UPGRADEv4_FINISH, NULL, NULL, NULL); + } + + done: + buf_free(&calpat); + buf_free(&subpat); + + return r; +} + +struct floating_rock { + struct caldav_db *caldavdb; + struct mailbox *mailbox; + const char *userid; +}; + +static int floating_cb(void *rock, struct caldav_data *cdata) +{ + struct floating_rock *frock = (struct floating_rock *) rock; + + return update_alarmdb(mailbox_name(frock->mailbox), cdata->dav.imap_uid, + time(0), ALARM_CALENDAR, 0, 0, 0, NULL); +} + +static int calendar_cb(const mbentry_t *mbentry, void *rock) +{ + struct floating_rock *frock = (struct floating_rock *) rock; + const char *tzid_annot = + DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-timezone-id"; + const char *tz_annot = + DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-timezone"; + struct buf attrib = BUF_INITIALIZER; + + if ((annotatemore_lookupmask(mbentry->name, + tzid_annot, frock->userid, &attrib) == 0 && + buf_len(&attrib)) || + (annotatemore_lookupmask(mbentry->name, + tz_annot, frock->userid, &attrib) == 0 && + buf_len(&attrib))) { + /* calendar has its own time zone for floating events -- skip it */ + return 0; + } + + int r = mailbox_open_irl(mbentry->name, &frock->mailbox); + if (!r) { + r = caldav_get_floating_events(frock->caldavdb, mbentry, + &floating_cb, &frock); + } + + mailbox_close(&frock->mailbox); + + return r; +} + +EXPORTED int caldav_alarm_update_floating(struct mailbox *mailbox, + const char *userid) +{ + struct floating_rock frock = { NULL, NULL, userid }; + struct caldav_db *caldavdb = NULL; + int r; + + if (!mailbox) return 0; + + caldavdb = frock.caldavdb = caldav_open_mailbox(mailbox); + if (!caldavdb) return -1; + + const char *prefix = config_getstring(IMAPOPT_CALENDARPREFIX); + const char *mboxname = mailbox_name(mailbox); + const char *homeset = strstr(mboxname, prefix); + + if (strlen(homeset) == strlen(prefix)) { + /* update all contained calendars without a time zone on them */ + r = mboxlist_mboxtree(mboxname, calendar_cb, &frock, MBOXTREE_SKIP_ROOT); + } + else { + /* update the specified calendar */ + frock.mailbox = mailbox; + r = caldav_get_floating_events(caldavdb, mailbox->mbentry, + &floating_cb, &frock); + } + + caldav_close(caldavdb); + + return (r == SQLITE_OK ? 0 : -1); +} diff --git a/imap/caldav_alarm.h b/imap/caldav_alarm.h index c75a47796d..bfa4e775b3 100644 --- a/imap/caldav_alarm.h +++ b/imap/caldav_alarm.h @@ -50,20 +50,34 @@ #include "mailbox.h" #include +enum alarm_type { + ALARM_CALENDAR = 1, + ALARM_SNOOZE, + ALARM_SEND, + ALARM_UNSCHEDULED, +}; + /* prepare for caldav alarm operations in this process */ int caldav_alarm_init(void); /* done with all caldav operations for this process */ int caldav_alarm_done(void); +/* reconstruct support */ +int caldav_alarm_set_reconstruct(sqldb_t *db); +int caldav_alarm_commit_reconstruct(const char *userid); +void caldav_alarm_rollback_reconstruct(); + /* add a calendar alarm */ int caldav_alarm_add_record(struct mailbox *mailbox, const struct index_record *record, - icalcomponent *ical); + void *data); -/* make sure that the alarms match the annotation */ +/* make sure that the alarms match the annotation. If forced, + * the event is not checked if it contains alarms */ int caldav_alarm_touch_record(struct mailbox *mailbox, - const struct index_record *record); + const struct index_record *record, + int force); /* set the caldav_alarm db nextcheck field for the record (from sync_support) */ int caldav_alarm_sync_nextcheck(struct mailbox *mailbox, @@ -79,9 +93,12 @@ int caldav_alarm_delete_mailbox(const char *mboxname); int caldav_alarm_delete_user(const char *userid); /* distribute alarms with triggers in the next minute */ -int caldav_alarm_process(time_t runtime); +int caldav_alarm_process(time_t runtime, time_t *next, int dryrun); /* upgrade old databases */ int caldav_alarm_upgrade(); +/* update nextcheck for floating events */ +int caldav_alarm_update_floating(struct mailbox *mailbox, const char *userid); + #endif /* CALDAV_ALARM_H */ diff --git a/imap/caldav_db.c b/imap/caldav_db.c index 850f1d2f36..95a1fe4a1b 100644 --- a/imap/caldav_db.c +++ b/imap/caldav_db.c @@ -52,15 +52,21 @@ #include "caldav_alarm.h" #include "caldav_db.h" #include "cyrusdb.h" +#include "defaultalarms.h" +#include "dynarray.h" +#include "hashset.h" #include "httpd.h" #include "http_dav.h" #include "ical_support.h" +#include "jmap_ical.h" #include "libconfig.h" #include "mboxname.h" #include "util.h" #include "xstrlcat.h" #include "xmalloc.h" +/* generated headers are not necessarily in current directory */ +#include "imap/imap_err.h" struct caldav_db { sqldb_t *db; /* DB handle */ @@ -101,7 +107,7 @@ EXPORTED int caldav_init(void) struct icaltimetype date; /* Set namespace -- force standard (internal) */ - if ((r = mboxname_init_namespace(&caldav_namespace, 1))) { + if ((r = mboxname_init_namespace(&caldav_namespace, NAMESPACE_OPTION_ADMIN))) { syslog(LOG_ERR, "%s", error_message(r)); fatal(error_message(r), EX_CONFIG); } @@ -152,6 +158,16 @@ EXPORTED struct caldav_db *caldav_open_userid(const char *userid) /* Construct mbox name corresponding to userid's scheduling Inbox */ caldavdb->sched_inbox = caldav_mboxname(userid, SCHED_INBOX); + if (db->version >= DB_MBOXID_VERSION) { + /* Lookup mailbox ID of scheduling Inbox */ + mbentry_t *mbentry = NULL; + if (!mboxlist_lookup(caldavdb->sched_inbox, &mbentry, NULL)) { + free(caldavdb->sched_inbox); + caldavdb->sched_inbox = xstrdup(mbentry->uniqueid); + } + mboxlist_entry_free(&mbentry); + } + return caldavdb; } @@ -159,7 +175,7 @@ EXPORTED struct caldav_db *caldav_open_userid(const char *userid) EXPORTED struct caldav_db *caldav_open_mailbox(struct mailbox *mailbox) { struct caldav_db *caldavdb = NULL; - char *userid = mboxname_to_userid(mailbox->name); + char *userid = mboxname_to_userid(mailbox_name(mailbox)); init_internal(); @@ -197,7 +213,7 @@ EXPORTED int caldav_close(struct caldav_db *caldavdb) buf_free(&caldavdb->dtend); buf_free(&caldavdb->sched_tag); - r = sqldb_close(&caldavdb->db); + r = dav_close(&caldavdb->db); free(caldavdb); @@ -228,7 +244,7 @@ struct read_rock { void *rock; }; -static const char *column_text_to_buf(const char *text, struct buf *buf) +static const char *text_to_buf(const char *text, struct buf *buf) { if (text) { buf_setcstr(buf, text); @@ -246,6 +262,10 @@ static void _num_to_comp_flags(struct comp_flags *flags, unsigned num) flags->tzbyref = (num >> 4) & 1; flags->mattach = (num >> 5) & 1; flags->shared = (num >> 6) & 1; + flags->defaultalerts = (num >> 7) & 1; + flags->mayinviteself = (num >> 8) & 1; + flags->mayinviteothers = (num >> 9) & 1; + flags->privacy = (num >> 10) & 3; } static unsigned _comp_flags_to_num(struct comp_flags *flags) @@ -255,30 +275,50 @@ static unsigned _comp_flags_to_num(struct comp_flags *flags) + ((flags->status & 3) << 2) + ((flags->tzbyref & 1) << 4) + ((flags->mattach & 1) << 5) - + ((flags->shared & 1) << 6); + + ((flags->shared & 1) << 6) + + ((flags->defaultalerts & 1) << 7) + + ((flags->mayinviteself & 1) << 8) + + ((flags->mayinviteothers & 1) << 9) + + ((flags->privacy & 3) << 10); } -#define CMD_READFIELDS \ - "SELECT rowid, creationdate, mailbox, resource, imap_uid," \ - " lock_token, lock_owner, lock_ownerid, lock_expire," \ - " comp_type, ical_uid, organizer, dtstart, dtend," \ - " comp_flags, sched_tag, alive, modseq, createdmodseq" \ - " FROM ical_objs" \ - -static int read_cb(sqlite3_stmt *stmt, void *rock) +#define ICALOBJS_FIELDS \ + " ical_objs.rowid," \ + " ical_objs.creationdate," \ + " ical_objs.mailbox," \ + " ical_objs.resource," \ + " ical_objs.imap_uid," \ + " ical_objs.lock_token," \ + " ical_objs.lock_owner," \ + " ical_objs.lock_ownerid," \ + " ical_objs.lock_expire," \ + " ical_objs.comp_type," \ + " ical_objs.ical_uid," \ + " ical_objs.organizer," \ + " ical_objs.dtstart," \ + " ical_objs.dtend," \ + " ical_objs.comp_flags," \ + " ical_objs.sched_tag," \ + " ical_objs.alive," \ + " ical_objs.modseq," \ + " ical_objs.createdmodseq," \ + +#define CMD_READFIELDS \ + "SELECT " ICALOBJS_FIELDS " NULL FROM ical_objs" + +static void read_cdata(sqlite3_stmt *stmt, + struct caldav_db *db, + int skip_tombstones, + struct caldav_data *cdata) { - struct read_rock *rrock = (struct read_rock *) rock; - struct caldav_db *db = rrock->db; - struct caldav_data *cdata = rrock->cdata; - int r = 0; - memset(cdata, 0, sizeof(struct caldav_data)); + cdata->dav.mailbox_byname = (db->db->version < DB_MBOXID_VERSION); cdata->dav.alive = sqlite3_column_int(stmt, 16); cdata->dav.modseq = sqlite3_column_int64(stmt, 17); cdata->dav.createdmodseq = sqlite3_column_int64(stmt, 18); - if (!(rrock->flags && RROCK_FLAG_TOMBSTONES) && !cdata->dav.alive) - return 0; + + if (skip_tombstones && !cdata->dav.alive) return; cdata->dav.rowid = sqlite3_column_int(stmt, 0); cdata->dav.creationdate = sqlite3_column_int(stmt, 1); @@ -287,18 +327,31 @@ static int read_cb(sqlite3_stmt *stmt, void *rock) cdata->comp_type = sqlite3_column_int(stmt, 9); _num_to_comp_flags(&cdata->comp_flags, sqlite3_column_int(stmt, 14)); + cdata->dav.mailbox = (const char *) sqlite3_column_text(stmt, 2); + cdata->dav.resource = (const char *) sqlite3_column_text(stmt, 3); + cdata->dav.lock_token = (const char *) sqlite3_column_text(stmt, 5); + cdata->dav.lock_owner = (const char *) sqlite3_column_text(stmt, 6); + cdata->dav.lock_ownerid = (const char *) sqlite3_column_text(stmt, 7); + cdata->ical_uid = (const char *) sqlite3_column_text(stmt, 10); + cdata->organizer = (const char *) sqlite3_column_text(stmt, 11); + cdata->dtstart = (const char *) sqlite3_column_text(stmt, 12); + cdata->dtend = (const char *) sqlite3_column_text(stmt, 13); + cdata->sched_tag = (const char *) sqlite3_column_text(stmt, 15); +} + +static int read_cb(sqlite3_stmt *stmt, void *rock) +{ + struct read_rock *rrock = (struct read_rock *) rock; + struct caldav_db *db = rrock->db; + struct caldav_data *cdata = rrock->cdata; + int r = 0; + + int skip_tombstones = !(rrock->flags & RROCK_FLAG_TOMBSTONES); + read_cdata(stmt, db, skip_tombstones, cdata); + if (skip_tombstones && !cdata->dav.alive) return 0; + if (rrock->cb) { /* We can use the column data directly for the callback */ - cdata->dav.mailbox = (const char *) sqlite3_column_text(stmt, 2); - cdata->dav.resource = (const char *) sqlite3_column_text(stmt, 3); - cdata->dav.lock_token = (const char *) sqlite3_column_text(stmt, 5); - cdata->dav.lock_owner = (const char *) sqlite3_column_text(stmt, 6); - cdata->dav.lock_ownerid = (const char *) sqlite3_column_text(stmt, 7); - cdata->ical_uid = (const char *) sqlite3_column_text(stmt, 10); - cdata->organizer = (const char *) sqlite3_column_text(stmt, 11); - cdata->dtstart = (const char *) sqlite3_column_text(stmt, 12); - cdata->dtend = (const char *) sqlite3_column_text(stmt, 13); - cdata->sched_tag = (const char *) sqlite3_column_text(stmt, 15); r = rrock->cb(rrock->rock, cdata); } else { @@ -306,35 +359,25 @@ static int read_cb(sqlite3_stmt *stmt, void *rock) * we need to make a copy of the column data before * it gets flushed by sqlite3_step() or sqlite3_reset() */ cdata->dav.mailbox = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 2), - &db->mailbox); + text_to_buf(cdata->dav.mailbox, &db->mailbox); cdata->dav.resource = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 3), - &db->resource); + text_to_buf(cdata->dav.resource, &db->resource); cdata->dav.lock_token = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 5), - &db->lock_token); + text_to_buf(cdata->dav.lock_token, &db->lock_token); cdata->dav.lock_owner = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 6), - &db->lock_owner); + text_to_buf(cdata->dav.lock_owner, &db->lock_owner); cdata->dav.lock_ownerid = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 7), - &db->lock_ownerid); + text_to_buf(cdata->dav.lock_ownerid, &db->lock_ownerid); cdata->ical_uid = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 10), - &db->ical_uid); + text_to_buf(cdata->ical_uid, &db->ical_uid); cdata->organizer = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 11), - &db->organizer); + text_to_buf(cdata->organizer, &db->organizer); cdata->dtstart = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 12), - &db->dtstart); + text_to_buf(cdata->dtstart, &db->dtstart); cdata->dtend = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 13), - &db->dtend); + text_to_buf(cdata->dtend, &db->dtend); cdata->sched_tag = - column_text_to_buf((const char *) sqlite3_column_text(stmt, 15), - &db->sched_tag); + text_to_buf(cdata->sched_tag, &db->sched_tag); } return r; @@ -345,10 +388,12 @@ static int read_cb(sqlite3_stmt *stmt, void *rock) " WHERE mailbox = :mailbox AND resource = :resource;" EXPORTED int caldav_lookup_resource(struct caldav_db *caldavdb, - const char *mailbox, const char *resource, + const mbentry_t *mbentry, const char *resource, struct caldav_data **result, int tombstones) { + const char *mailbox = (caldavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; struct sqldb_bindval bval[] = { { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { ":resource", SQLITE_TEXT, { .s = resource } }, @@ -364,6 +409,7 @@ EXPORTED int caldav_lookup_resource(struct caldav_db *caldavdb, /* always add the mailbox and resource, so error responses don't * crash out */ + cdata.dav.mailbox_byname = (caldavdb->db->version < DB_MBOXID_VERSION); cdata.dav.mailbox = mailbox; cdata.dav.resource = resource; @@ -374,10 +420,12 @@ EXPORTED int caldav_lookup_resource(struct caldav_db *caldavdb, " WHERE mailbox = :mailbox AND imap_uid = :imap_uid;" EXPORTED int caldav_lookup_imapuid(struct caldav_db *caldavdb, - const char *mailbox, int imap_uid, + const mbentry_t *mbentry, int imap_uid, struct caldav_data **result, int tombstones) { + const char *mailbox = (caldavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; struct sqldb_bindval bval[] = { { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { ":imap_uid", SQLITE_INTEGER, { .i = imap_uid } }, @@ -429,9 +477,12 @@ EXPORTED int caldav_lookup_uid(struct caldav_db *caldavdb, const char *ical_uid, #define CMD_SELALIVE CMD_READFIELDS \ " WHERE alive = 1;" -EXPORTED int caldav_foreach(struct caldav_db *caldavdb, const char *mailbox, +EXPORTED int caldav_foreach(struct caldav_db *caldavdb, const mbentry_t *mbentry, caldav_cb_t *cb, void *rock) { + const char *mailbox = !mbentry ? NULL : + ((caldavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name); struct sqldb_bindval bval[] = { { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { NULL, SQLITE_NULL, { .s = NULL } } }; @@ -447,49 +498,6 @@ EXPORTED int caldav_foreach(struct caldav_db *caldavdb, const char *mailbox, } } -#define CMD_SELRANGE_MBOX CMD_READFIELDS \ - " WHERE dtend > :after AND dtstart < :before " \ - " AND mailbox = :mailbox AND alive = 1;" - -#define CMD_SELRANGE CMD_READFIELDS \ - " WHERE dtend > :after AND dtstart < :before " \ - " AND alive = 1;" - -EXPORTED int caldav_foreach_timerange(struct caldav_db *caldavdb, - const char *mailbox, - time_t after, time_t before, - caldav_cb_t *cb, void *rock) -{ - struct sqldb_bindval bval[] = { - { ":after", SQLITE_TEXT, { .s = NULL } }, - { ":before", SQLITE_TEXT, { .s = NULL } }, - { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, - { NULL, SQLITE_NULL, { .s = NULL } } }; - struct caldav_data cdata; - struct read_rock rrock = { caldavdb, &cdata, 0, cb, rock }; - icaltimetype dtafter, dtbefore; - icaltimezone *utc = icaltimezone_get_utc_timezone(); - - dtafter = icaltime_from_timet_with_zone(after, 0, utc); - dtbefore= icaltime_from_timet_with_zone(before, 0, utc); - - bval[0].val.s = icaltime_as_ical_string(dtafter); - bval[1].val.s = icaltime_as_ical_string(dtbefore); - - /* XXX - if 'before' defines the zero second of a day, a full-day - * event starting on that day matches. That's not entirely correct, - * since 'before' is defined to be exclusive. */ - - /* XXX - tombstones */ - - if (mailbox) { - return sqldb_exec(caldavdb->db, CMD_SELRANGE_MBOX, bval, &read_cb, &rrock); - } else { - return sqldb_exec(caldavdb->db, CMD_SELRANGE, bval, &read_cb, &rrock); - } -} - - #define CMD_INSERT \ "INSERT INTO ical_objs (" \ " alive, mailbox, resource, creationdate, imap_uid, modseq," \ @@ -510,7 +518,7 @@ EXPORTED int caldav_foreach_timerange(struct caldav_db *caldavdb, " creationdate = :creationdate," \ " imap_uid = :imap_uid," \ " modseq = :modseq," \ - " createdmodseq = :createdmoseq," \ + " createdmodseq = :createdmodseq," \ " lock_token = :lock_token," \ " lock_owner = :lock_owner," \ " lock_ownerid = :lock_ownerid," \ @@ -524,6 +532,16 @@ EXPORTED int caldav_foreach_timerange(struct caldav_db *caldavdb, " sched_tag = :sched_tag" \ " WHERE rowid = :rowid;" +#define CMD_DELETE_JSCALCACHE "DELETE FROM jscal_cache WHERE rowid = :rowid" + +#define CMD_UPDATE_JSCAL_TOMBSTONES \ + "UPDATE jscal_objs SET" \ + " alive = 0," \ + " modseq = :modseq" \ + " WHERE jscal_objs.rowid = :rowid" \ + " AND alive > 0" \ + " AND jscal_objs.modseq <= :modseq" + EXPORTED int caldav_write(struct caldav_db *caldavdb, struct caldav_data *cdata) { unsigned comp_flags = _comp_flags_to_num(&cdata->comp_flags); @@ -550,8 +568,20 @@ EXPORTED int caldav_write(struct caldav_db *caldavdb, struct caldav_data *cdata) { NULL, SQLITE_NULL, { .s = NULL } } }; if (cdata->dav.rowid) { - int r = sqldb_exec(caldavdb->db, CMD_UPDATE, bval, NULL, NULL); + int r = sqldb_exec(caldavdb->db, CMD_DELETE_JSCALCACHE, bval, NULL, NULL); + if (r) return r; + r = sqldb_exec(caldavdb->db, CMD_UPDATE, bval, NULL, NULL); if (r) return r; + + if (!cdata->dav.alive) { + struct sqldb_bindval bvaljs[] = { + { ":rowid", SQLITE_INTEGER, { .i = cdata->dav.rowid } }, + { ":modseq", SQLITE_INTEGER, { .i = cdata->dav.modseq } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; + r = sqldb_exec(caldavdb->db, CMD_UPDATE_JSCAL_TOMBSTONES, + bvaljs, NULL, NULL); + if (r) return r; + } } else { int r = sqldb_exec(caldavdb->db, CMD_INSERT, bval, NULL, NULL); @@ -580,8 +610,10 @@ EXPORTED int caldav_delete(struct caldav_db *caldavdb, unsigned rowid) #define CMD_DELMBOX "DELETE FROM ical_objs WHERE mailbox = :mailbox;" -EXPORTED int caldav_delmbox(struct caldav_db *caldavdb, const char *mailbox) +EXPORTED int caldav_delmbox(struct caldav_db *caldavdb, const mbentry_t *mbentry) { + const char *mailbox = (caldavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; struct sqldb_bindval bval[] = { { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { NULL, SQLITE_NULL, { .s = NULL } } }; @@ -593,13 +625,16 @@ EXPORTED int caldav_delmbox(struct caldav_db *caldavdb, const char *mailbox) } EXPORTED int caldav_get_updates(struct caldav_db *caldavdb, - modseq_t oldmodseq, const char *mboxname, + modseq_t oldmodseq, const mbentry_t *mbentry, int kind, int limit, int (*cb)(void *rock, struct caldav_data *cdata), void *rock) { + const char *mailbox = !mbentry ? NULL : + ((caldavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name); struct sqldb_bindval bval[] = { - { ":mailbox", SQLITE_TEXT, { .s = mboxname } }, + { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { ":modseq", SQLITE_INTEGER, { .i = oldmodseq } }, { ":comp_type", SQLITE_INTEGER, { .i = kind } }, /* SQLite interprets a negative limit as unbounded. */ @@ -613,7 +648,7 @@ EXPORTED int caldav_get_updates(struct caldav_db *caldavdb, int r; buf_setcstr(&sqlbuf, CMD_READFIELDS " WHERE"); - if (mboxname) buf_appendcstr(&sqlbuf, " mailbox = :mailbox AND"); + if (mailbox) buf_appendcstr(&sqlbuf, " mailbox = :mailbox AND"); if (kind >= 0) { /* Use a negative value to signal that we accept ALL components types */ buf_appendcstr(&sqlbuf, " comp_type = :comp_type AND"); @@ -648,8 +683,637 @@ static void check_mattach_cb(icalcomponent *comp, void *rock) } } -EXPORTED int caldav_writeentry(struct caldav_db *caldavdb, struct caldav_data *cdata, - icalcomponent *ical) + +#define CMD_UPSERT_JSCALOBJS \ + "INSERT INTO jscal_objs (" \ + " rowid, ical_recurid, alive, modseq, createdmodseq," \ + " dtstart, dtend, ical_guid )" \ + " VALUES (" \ + " :rowid, :ical_recurid, :alive, :modseq, :createdmodseq," \ + " :dtstart, :dtend, :ical_guid )" \ + " ON CONFLICT (rowid, ical_recurid) DO" \ + " UPDATE SET" \ + " alive = :alive," \ + " modseq = :modseq," \ + " createdmodseq = :createdmodseq," \ + " dtstart = :dtstart," \ + " dtend = :dtend," \ + " ical_guid = :ical_guid;" \ + +static int caldav_upsert_jscal(struct caldav_db *caldavdb, + struct caldav_jscal *jscal) +{ + struct sqldb_bindval bvalinst[] = { + { ":rowid", SQLITE_INTEGER, { .i = jscal->cdata.dav.rowid } }, + { ":ical_recurid", SQLITE_TEXT, { .s = jscal->ical_recurid } }, + { ":alive", SQLITE_INTEGER, { .i = jscal->alive } }, + { ":modseq", SQLITE_INTEGER, { .i = jscal->modseq } }, + { ":createdmodseq", SQLITE_INTEGER, { .i = jscal->createdmodseq } }, + { ":dtstart", SQLITE_TEXT, { .s = jscal->dtstart } }, + { ":dtend", SQLITE_TEXT, { .s = jscal->dtend } }, + { ":ical_guid", SQLITE_TEXT, { .s = jscal->ical_guid } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; + + return sqldb_exec(caldavdb->db, CMD_UPSERT_JSCALOBJS, bvalinst, NULL, NULL); +} + +#define CMD_SELJSCALOBJS \ + "SELECT rowid, ical_recurid, alive, modseq, createdmodseq," \ + " dtstart, dtend, ical_guid " \ + "FROM jscal_objs " \ + "WHERE rowid = :rowid ORDER BY ical_recurid ASC;" + +struct read_jscals_rock { + dynarray_t *jscalobjs; + strarray_t *strpool; +}; + +static int read_jscals_cb(sqlite3_stmt *stmt, void *vrock) +{ + struct read_jscals_rock *rock = (struct read_jscals_rock *) vrock; + struct caldav_jscal jscal = { 0 }; + + jscal.cdata.dav.rowid = sqlite3_column_int(stmt, 0); + strarray_append(rock->strpool, (const char *) sqlite3_column_text(stmt, 1)); + jscal.ical_recurid = strarray_nth(rock->strpool, -1); + jscal.alive = sqlite3_column_int(stmt, 2); + jscal.modseq = sqlite3_column_int64(stmt, 3); + jscal.createdmodseq = sqlite3_column_int64(stmt, 4); + strarray_append(rock->strpool, (const char *) sqlite3_column_text(stmt, 5)); + jscal.dtstart = strarray_nth(rock->strpool, -1); + strarray_append(rock->strpool, (const char *) sqlite3_column_text(stmt, 6)); + jscal.dtend = strarray_nth(rock->strpool, -1); + strarray_append(rock->strpool, (const char *) sqlite3_column_text(stmt, 7)); + jscal.ical_guid = strarray_nth(rock->strpool, -1); + + dynarray_append(rock->jscalobjs, &jscal); + return 0; +} + +#define JSCALOBJS_FIELDS \ + " jscal_objs.ical_recurid," \ + " jscal_objs.alive," \ + " jscal_objs.modseq," \ + " jscal_objs.createdmodseq," \ + " jscal_objs.dtstart," \ + " jscal_objs.dtend," \ + " jscal_objs.ical_guid," + +#define JSCALCACHE_FIELDS \ + " jscal_cache.version," \ + " jscal_cache.data," + +#define CMD_READFIELDS_JSCALOBJS \ + "SELECT " \ + ICALOBJS_FIELDS JSCALOBJS_FIELDS JSCALCACHE_FIELDS \ + "NULL" \ + " FROM ical_objs " \ + " LEFT JOIN jscal_objs" \ + " ON (ical_objs.rowid = jscal_objs.rowid)" \ + " LEFT JOIN jscal_cache" \ + " ON (jscal_objs.rowid = jscal_cache.rowid " \ + " AND jscal_objs.ical_recurid = jscal_cache.ical_recurid" \ + " AND jscal_cache.userid = :cache_userid_1)" + +#define CMD_READFIELDS_JSCALOBJS_NOCACHE \ + "SELECT " \ + ICALOBJS_FIELDS JSCALOBJS_FIELDS "NULL, NULL," \ + "NULL" \ + " FROM ical_objs " \ + " LEFT JOIN jscal_objs" \ + " ON (ical_objs.rowid = jscal_objs.rowid)" + +struct read_jscal_rock { + struct caldav_db *db; + struct caldav_jscal jscal; + caldav_jscal_cb_t *cb; + void *rock; +}; + +static int read_jscal_cb(sqlite3_stmt *stmt, void *rock) +{ + struct read_jscal_rock *rrock = (struct read_jscal_rock *) rock; + struct caldav_db *db = rrock->db; + struct caldav_jscal *jscal = &rrock->jscal; + + memset(jscal, 0, sizeof(struct caldav_jscal)); + + read_cdata(stmt, db, 0, &jscal->cdata); + + jscal->ical_recurid = (const char *) sqlite3_column_text(stmt, 19); + jscal->alive = sqlite3_column_int(stmt, 20); + jscal->modseq = sqlite3_column_int64(stmt, 21); + jscal->createdmodseq = sqlite3_column_int64(stmt, 22); + jscal->dtstart = (const char *) sqlite3_column_text(stmt, 23); + jscal->dtend = (const char *) sqlite3_column_text(stmt, 24); + jscal->ical_guid = (const char *) sqlite3_column_text(stmt, 25); + jscal->cacheversion = sqlite3_column_int(stmt, 26); + jscal->cachedata = (const char *) sqlite3_column_text(stmt, 27); + + return rrock->cb(rrock->rock, jscal); +} + +struct bindvals { + dynarray_t vals; + struct buf buf; + strarray_t strpool; +}; + +static void bindvals_init(struct bindvals *bvals) +{ + memset(bvals, 0, sizeof(struct bindvals)); + dynarray_init(&bvals->vals, sizeof(struct sqldb_bindval)); +} + +static void bindvals_fini(struct bindvals *bvals) +{ + dynarray_fini(&bvals->vals); + strarray_fini(&bvals->strpool); + buf_free(&bvals->buf); +} + +static struct sqldb_bindval *bindvals_vals(struct bindvals *bvals) +{ + struct sqldb_bindval *val = dynarray_nth(&bvals->vals, -1); + + if (!val || val->type != SQLITE_NULL) { + // finish parameter list + dynarray_append(&bvals->vals, &(struct sqldb_bindval){ + NULL, SQLITE_NULL, { .s = NULL }, + }); + } + + return bvals->vals.data; +} + +static const char *bval(const char *name, + int sqltype, + union sqldb_sqlval sqlval, + struct bindvals *bvals) +{ + buf_reset(&bvals->buf); + buf_printf(&bvals->buf, ":%s_%d", name, dynarray_size(&bvals->vals) + 1); + strarray_append(&bvals->strpool, buf_cstring(&bvals->buf)); + struct sqldb_bindval val = { + strarray_nth(&bvals->strpool, -1), sqltype, sqlval + }; + dynarray_append(&bvals->vals, &val); + return val.name; +} + +static void jscal_filter_to_stmt(struct caldav_db *caldavdb, + struct caldav_jscal_filter *filter, + struct buf *stmt, + struct bindvals *bvals) +{ + int nargs = 0; + + if (filter->op == CALDAV_JSCAL_FALSE) { + buf_appendcstr(stmt, " FALSE"); + return; + } + + if (ptrarray_size(&filter->mbentries)) { + if (nargs++) buf_appendcstr(stmt, " AND"); + + if (ptrarray_size(&filter->mbentries) > 1) + buf_appendcstr(stmt, " ("); + + for (int i = 0; i < ptrarray_size(&filter->mbentries); i++) { + mbentry_t *mbentry = ptrarray_nth(&filter->mbentries, i); + + const char *mbarg = caldavdb->db->version >= DB_MBOXID_VERSION ? + mbentry->uniqueid : mbentry->name; + + if (i) buf_appendcstr(stmt, " OR"); + buf_printf(stmt, " mailbox = %s", bval("mailbox", + SQLITE_TEXT, (union sqldb_sqlval){ .s = mbarg }, bvals)); + } + + if (ptrarray_size(&filter->mbentries) > 1) + buf_appendcstr(stmt, " )"); + } + + if (dynarray_size(&filter->jscal_ids)) { + if (nargs++) buf_appendcstr(stmt, " AND"); + + int have_recurid = 0; + for (int i = 0; i < dynarray_size(&filter->jscal_ids); i++) { + struct caldav_jscal_id *id = dynarray_nth(&filter->jscal_ids, i); + if (id->ical_recurid) { + have_recurid = 1; + break; + } + } + + if (!have_recurid && dynarray_size(&filter->jscal_ids) > 1) { + // optimize for OR(uid, uid, ...) queries + buf_printf(stmt, " ical_uid IN ("); + for (int i = 0; i < dynarray_size(&filter->jscal_ids); i++) { + struct caldav_jscal_id *id = dynarray_nth(&filter->jscal_ids, i); + assert(id->ical_uid); + assert(!id->ical_recurid); + buf_printf(stmt, " %s%s", i ? ", " : "", bval("ical_uid", + SQLITE_TEXT, (union sqldb_sqlval){ .s = id->ical_uid }, bvals)); + } + buf_appendcstr(stmt, " )"); + } + else { + if (dynarray_size(&filter->jscal_ids) > 1) + buf_appendcstr(stmt, " ("); + + for (int i = 0; i < dynarray_size(&filter->jscal_ids); i++) { + struct caldav_jscal_id *id = dynarray_nth(&filter->jscal_ids, i); + assert(id->ical_uid); + + if (i) + buf_appendcstr(stmt, " OR"); + + if (id->ical_recurid) + buf_appendcstr(stmt, " ("); + + buf_printf(stmt, " ical_uid = %s", bval("ical_uid", + SQLITE_TEXT, (union sqldb_sqlval){ .s = id->ical_uid }, bvals)); + + if (id->ical_recurid) { + buf_appendcstr(stmt, " AND"); + + buf_printf(stmt, " ical_recurid = %s", bval("ical_recurid", + SQLITE_TEXT, (union sqldb_sqlval){ .s = id->ical_recurid }, bvals)); + + buf_appendcstr(stmt, " )"); + } + } + + if (dynarray_size(&filter->jscal_ids) > 1) + buf_appendcstr(stmt, " )"); + } + } + + if (filter->imap_uid) { + if (nargs++) buf_appendcstr(stmt, " AND"); + buf_printf(stmt, " imap_uid = %s", bval("imap_uid", + SQLITE_INTEGER, (union sqldb_sqlval){ .i = filter->imap_uid }, bvals)); + } + + if (filter->after || filter->before) { + icaltimezone *utc = icaltimezone_get_utc_timezone(); + icaltimetype dt; + + if (filter->after) { + dt = icaltime_from_timet_with_zone(filter->after, 0, utc); + strarray_append(&bvals->strpool, icaltime_as_ical_string(dt)); + const char *arg = strarray_nth(&bvals->strpool, -1); + + if (nargs++) buf_appendcstr(stmt, " AND"); + buf_printf(stmt, " jscal_objs.dtend > %s", bval("after", + SQLITE_TEXT, (union sqldb_sqlval){ .s = arg }, bvals)); + } + + if (filter->before) { + dt = icaltime_from_timet_with_zone(filter->before, 0, utc); + strarray_append(&bvals->strpool, icaltime_as_ical_string(dt)); + const char *arg = strarray_nth(&bvals->strpool, -1); + + if (nargs++) buf_appendcstr(stmt, " AND"); + buf_printf(stmt, " jscal_objs.dtstart < %s", bval("before", + SQLITE_TEXT, (union sqldb_sqlval){ .s = arg }, bvals)); + } + } + + if (filter->op && ptrarray_size(&filter->subfilters)) { + if (nargs++) buf_appendcstr(stmt, " AND"); + + if (ptrarray_size(&filter->subfilters) > 1) + buf_appendcstr(stmt, " ("); + + for (int i = 0; i < ptrarray_size(&filter->subfilters); i++) { + struct caldav_jscal_filter *sub = ptrarray_nth(&filter->subfilters, i); + switch (filter->op) { + case CALDAV_JSCAL_AND: + if (i) buf_appendcstr(stmt, " AND"); + break; + case CALDAV_JSCAL_OR: + if (i) buf_appendcstr(stmt, " OR"); + break; + case CALDAV_JSCAL_NOT: + if (i) buf_appendcstr(stmt, " AND"); + buf_appendcstr(stmt, " NOT"); + break; + default: + assert(0); + } + buf_appendcstr(stmt, " ("); + jscal_filter_to_stmt(caldavdb, sub, stmt, bvals); + buf_appendcstr(stmt, " )"); + } + + if (ptrarray_size(&filter->subfilters) > 1) + buf_appendcstr(stmt, " )"); + } + + if (!nargs) buf_appendcstr(stmt, " TRUE"); +} + +static int filters_sched_inbox(struct caldav_db *caldavdb, struct caldav_jscal_filter *f) +{ + if (!f) return 0; + + int i; + for (i = 0; i < ptrarray_size(&f->mbentries); i++) { + mbentry_t *mbentry = ptrarray_nth(&f->mbentries, i); + const char *mbarg = caldavdb->db->version >= DB_MBOXID_VERSION ? + mbentry->uniqueid : mbentry->name; + + if (!strcmpsafe(mbarg, caldavdb->sched_inbox)) + return 1; + } + + for (i = 0; i < ptrarray_size(&f->subfilters); i++) { + if (filters_sched_inbox(caldavdb, ptrarray_nth(&f->subfilters, i))) + return 1; + } + + return 0; +} + +EXPORTED int caldav_foreach_jscal(struct caldav_db *caldavdb, + const char *cache_userid, + struct caldav_jscal_filter *filter, + struct caldav_jscal_window *window, + enum caldav_sort* sort, size_t nsort, + caldav_jscal_cb_t *cb, void *rock) +{ + struct buf stmt = BUF_INITIALIZER; + struct bindvals bvals = { 0 }; + bindvals_init(&bvals); + + if (cache_userid) { + buf_setcstr(&stmt, CMD_READFIELDS_JSCALOBJS); + // CMD_READFIELDS_JSCALOBJS hard-codes parameter id to ":cache_userid_1" + bval("cache_userid", SQLITE_TEXT, + (union sqldb_sqlval){ .s = cache_userid }, &bvals); + } + else { + buf_setcstr(&stmt, CMD_READFIELDS_JSCALOBJS_NOCACHE); + } + + buf_appendcstr(&stmt, " WHERE"); + + // ignore sched_inbox, if filter does not explicitly filter by it + if (!filters_sched_inbox(caldavdb, filter)) { + buf_printf(&stmt, " mailbox != %s", bval("sched_inbox", + SQLITE_TEXT, (union sqldb_sqlval){ .s = caldavdb->sched_inbox }, &bvals)); + } + else { + buf_appendcstr(&stmt, " TRUE"); + } + + if (filter) { + buf_appendcstr(&stmt, " AND"); + jscal_filter_to_stmt(caldavdb, filter, &stmt, &bvals); + } + + if (window && window->aftermodseq) { + buf_printf(&stmt, " AND jscal_objs.modseq > %s", bval("aftermodseq", + SQLITE_INTEGER, (union sqldb_sqlval){ .i = window->aftermodseq }, &bvals)); + } + + if (!window || !window->tombstones) { + buf_appendcstr(&stmt, " AND jscal_objs.alive = 1"); + } + + if (nsort) { + buf_appendcstr(&stmt, " ORDER BY "); + size_t i; + for (i = 0; i < nsort; i++) { + if (i) buf_appendcstr(&stmt, ", "); + switch (sort[i] & ~CAL_SORT_DESC) { + case CAL_SORT_ICAL_UID: + buf_appendcstr(&stmt, "ical_uid"); + break; + case CAL_SORT_START: + buf_appendcstr(&stmt, "jscal_objs.dtstart"); + break; + case CAL_SORT_MAILBOX: + buf_appendcstr(&stmt, "mailbox"); + break; + case CAL_SORT_IMAP_UID: + buf_appendcstr(&stmt, "imap_uid"); + break; + case CAL_SORT_MODSEQ: + buf_appendcstr(&stmt, "jscal_objs.modseq"); + break; + default: + continue; + } + buf_appendcstr(&stmt, sort[i] & CAL_SORT_DESC ? " DESC" : " ASC"); + } + } + + if (window && window->maxcount) { + buf_printf(&stmt, " LIMIT %s", bval("maxcount", + SQLITE_INTEGER, (union sqldb_sqlval){ .i = window->maxcount }, &bvals)); + } + + buf_putc(&stmt, ';'); + + struct read_jscal_rock rrock = { + .db = caldavdb, + .cb = cb, + .rock = rock + }; + int r = sqldb_exec(caldavdb->db, buf_cstring(&stmt), + bindvals_vals(&bvals), &read_jscal_cb, &rrock); + + bindvals_fini(&bvals); + buf_free(&stmt); + return r; +} + +static int jscal_cmp_ical_recurid(const void *va, const void *vb) +{ + return strcmpsafe(((struct caldav_jscal*)va)->ical_recurid, + ((struct caldav_jscal*)vb)->ical_recurid); +} + +EXPORTED int caldav_writeical_jmap(struct caldav_db *caldavdb, + struct caldav_data *cdata, + icalcomponent *ical) +{ + if (cdata->comp_type != CAL_COMP_VEVENT) return 0; + assert(cdata->dav.rowid); + + dynarray_t old_jscals; + dynarray_t new_jscals; + dynarray_init(&old_jscals, sizeof(struct caldav_jscal)); + dynarray_init(&new_jscals, sizeof(struct caldav_jscal)); + + ptrarray_t upsert = PTRARRAY_INITIALIZER; + strarray_t strpool = STRARRAY_INITIALIZER; + icalcomponent *comp; + icaltimezone *utc = NULL; + int r = 0; + + /* Determine current JMAP objects, ordered ascending by recurid */ + struct sqldb_bindval bval[] = { + { ":rowid", SQLITE_INTEGER, { .i = cdata->dav.rowid } }, + { NULL, SQLITE_NULL, { .s = NULL } } + }; + struct read_jscals_rock rock = { &old_jscals, &strpool }; + r = sqldb_exec(caldavdb->db, CMD_SELJSCALOBJS, bval, + read_jscals_cb, &rock); + if (r) goto done; + + /* Determine new JMAP objects, ordered ascending by recurid */ + struct hashset *seen_recurids = hashset_new(sizeof(struct icaltimetype)); + icalcomponent_kind kind; + for (comp = icalcomponent_get_first_real_component(ical); + comp; + comp = icalcomponent_get_next_component(ical, kind)) { + + kind = icalcomponent_isa(comp); + + + icalproperty *recurid_prop = + icalcomponent_get_first_property(comp, ICAL_RECURRENCEID_PROPERTY); + + const char *icalstr = icalcomponent_as_ical_string(recurid_prop ? comp: ical); + struct message_guid guid = MESSAGE_GUID_INITIALIZER; + message_guid_generate(&guid, icalstr, strlen(icalstr)); + strarray_append(&strpool, message_guid_encode(&guid)); + const char *ical_guid = strarray_nth(&strpool, -1); + + if (!recurid_prop) { + /* Found main component */ + dynarray_truncate(&new_jscals, 0); + struct caldav_jscal jscal = { + .cdata.dav.rowid = cdata->dav.rowid, + .ical_recurid = "", + .dtstart = cdata->dtstart, + .dtend = cdata->dtend, + .alive = cdata->dav.alive, + .modseq = cdata->dav.modseq, + .createdmodseq = cdata->dav.createdmodseq, + .ical_guid = ical_guid, + }; + dynarray_append(&new_jscals, &jscal); + break; + } + + /* Found recurrence instance */ + icaltimetype recurid = icalproperty_get_recurrenceid(recurid_prop); + + /* Resolve duplicate recurrence ids by picking the first */ + icaltimetype hash_recurid = recurid; + hash_recurid.zone = NULL; // don't hash timezone pointers + if (!hashset_add(seen_recurids, &hash_recurid)) { + continue; + } + + if (!utc) utc = icaltimezone_get_utc_timezone(); + icaltimetype dtstart = icalcomponent_get_dtstart(comp); + dtstart = icaltime_convert_to_zone(dtstart, utc); + icaltimetype dtend = icalcomponent_get_dtend(comp); + dtend = icaltime_convert_to_zone(dtend, utc); + + struct caldav_jscal jscal = { + .cdata.dav.rowid = cdata->dav.rowid, + .alive = cdata->dav.alive, + .modseq = cdata->dav.modseq, + .createdmodseq = cdata->dav.createdmodseq, + .ical_guid = ical_guid, + }; + + strarray_append(&strpool, icaltime_as_ical_string(recurid)); + jscal.ical_recurid = strarray_nth(&strpool, -1); + + strarray_append(&strpool, icaltime_as_ical_string(dtstart)); + jscal.dtstart = strarray_nth(&strpool, -1); + + strarray_append(&strpool, icaltime_as_ical_string(dtend)); + jscal.dtend = strarray_nth(&strpool, -1); + + dynarray_append(&new_jscals, &jscal); + } + hashset_free(&seen_recurids); + qsort(new_jscals.data, new_jscals.count, + sizeof(struct caldav_jscal), jscal_cmp_ical_recurid); + + /* Determine which rows to insert and update. We never delete here. */ + int old_i = 0; + int new_i = 0; + while (old_i < dynarray_size(&old_jscals) && + new_i < dynarray_size(&new_jscals)) { + struct caldav_jscal *old_jscal = dynarray_nth(&old_jscals, old_i); + struct caldav_jscal *new_jscal = dynarray_nth(&new_jscals, new_i); + int cmp = strcmp(old_jscal->ical_recurid, new_jscal->ical_recurid); + + if (!cmp) { + if (strcmp(old_jscal->ical_guid, new_jscal->ical_guid) || + old_jscal->alive != new_jscal->alive) { + // instance or main event got updated + ptrarray_append(&upsert, new_jscal); + } + old_i++; + new_i++; + continue; + } + + if (!new_jscal->ical_recurid[0]) { + // old standalone instances got replaced with main event + ptrarray_append(&upsert, new_jscal); + new_i++; + } + else if (cmp < 0) { + // old instance or main event got removed + if (old_jscal->alive) { + old_jscal->alive = 0; + old_jscal->modseq = cdata->dav.modseq; + ptrarray_append(&upsert, old_jscal); + } + old_i++; + } + else { + // new standalone instance got added + new_jscal->createdmodseq = cdata->dav.modseq; + ptrarray_append(&upsert, new_jscal); + new_i++; + } + } + for ( ; old_i < dynarray_size(&old_jscals); old_i++) { + // any old entry for which no new entry exists is not alive + struct caldav_jscal *old_jscal = dynarray_nth(&old_jscals, old_i); + if (old_jscal->alive) { + old_jscal->alive = 0; + old_jscal->modseq = cdata->dav.modseq; + ptrarray_append(&upsert, old_jscal); + } + } + for ( ; new_i < dynarray_size(&new_jscals); new_i++) { + // any new entry for which no old entry exists it newly created + struct caldav_jscal *new_jscal = dynarray_nth(&new_jscals, new_i); + new_jscal->createdmodseq = cdata->dav.modseq; + ptrarray_append(&upsert, new_jscal); + } + + /* Write changes */ + int i; + for (i = 0; i < ptrarray_size(&upsert); i++) { + r = caldav_upsert_jscal(caldavdb, ptrarray_nth(&upsert, i)); + if (r) goto done; + } + +done: + dynarray_fini(&old_jscals); + dynarray_fini(&new_jscals); + ptrarray_fini(&upsert); + strarray_fini(&strpool); + return r; +} + +EXPORTED int caldav_writeical(struct caldav_db *caldavdb, struct caldav_data *cdata, + icalcomponent *ical) { icalcomponent *comp = icalcomponent_get_first_real_component(ical); icalcomponent_kind kind; @@ -675,9 +1339,7 @@ EXPORTED int caldav_writeentry(struct caldav_db *caldavdb, struct caldav_data *c case ICAL_VJOURNAL_COMPONENT: mykind = CAL_COMP_VJOURNAL; break; case ICAL_VFREEBUSY_COMPONENT: mykind = CAL_COMP_VFREEBUSY; break; case ICAL_VAVAILABILITY_COMPONENT: mykind = CAL_COMP_VAVAILABILITY; break; -#ifdef HAVE_VPOLL case ICAL_VPOLL_COMPONENT: mykind = CAL_COMP_VPOLL; break; -#endif default: break; } cdata->comp_type = mykind; @@ -722,16 +1384,50 @@ EXPORTED int caldav_writeentry(struct caldav_db *caldavdb, struct caldav_data *c } cdata->comp_flags.transp = transp; + /* Determine JSCalendar privacy */ + cdata->comp_flags.privacy = 0; + prop = icalcomponent_get_x_property_by_name(comp, JMAPICAL_XPROP_PRIVACY); + if (prop) { + const char *val = icalproperty_get_value_as_string(prop); + if (val) { + if (!strcasecmp(val, "secret")) + cdata->comp_flags.privacy = CAL_PRIVACY_SECRET; + else if (!strcasecmp(val, "private")) + cdata->comp_flags.privacy = CAL_PRIVACY_PRIVATE; + } + } + /* Get span of component set and check for managed attachments */ - span = icalrecurrenceset_get_utc_timespan(ical, kind, &recurring, + span = icalrecurrenceset_get_utc_timespan(ical, kind, NULL, &recurring, &check_mattach_cb, &mattach); cdata->dtstart = icaltime_as_ical_string(span.start); cdata->dtend = icaltime_as_ical_string(span.end); cdata->comp_flags.recurring = recurring; cdata->comp_flags.mattach = mattach; - - return caldav_write(caldavdb, cdata); + + /* Get default alerts property in main component or override */ + cdata->comp_flags.defaultalerts = 0; + for (comp = icalcomponent_get_first_real_component(ical); + comp && !cdata->comp_flags.defaultalerts; + comp = icalcomponent_get_next_component(ical, kind)) { + + cdata->comp_flags.defaultalerts = + icalcomponent_get_usedefaultalerts(comp); + } + + /* Read JMAP fields mayInviteSelf and mayInviteOthers */ + comp = icalcomponent_get_first_real_component(ical); + prop = icalcomponent_get_x_property_by_name(comp, JMAPICAL_XPROP_MAYINVITESELF); + cdata->comp_flags.mayinviteself = prop && + !strcasecmpsafe(icalproperty_get_value_as_string(prop), "true"); + prop = icalcomponent_get_x_property_by_name(comp, JMAPICAL_XPROP_MAYINVITEOTHERS); + cdata->comp_flags.mayinviteothers = prop && + !strcasecmpsafe(icalproperty_get_value_as_string(prop), "true"); + + int r = caldav_write(caldavdb, cdata); + if (!r) r = caldav_writeical_jmap(caldavdb, cdata, ical); + return r; } @@ -755,36 +1451,402 @@ EXPORTED char *caldav_mboxname(const char *userid, const char *name) return res; } -EXPORTED int caldav_get_events(struct caldav_db *caldavdb, - const char *mailbox, const char *ical_uid, - caldav_cb_t *cb, void *rock) +#define CMD_DELETE_JSCALCACHE_USER \ + "DELETE FROM jscal_cache" \ + " WHERE rowid = :rowid AND userid = :userid" + +#define CMD_INSERT_JSCALCACHE_USER \ + "INSERT INTO jscal_cache (" \ + " rowid, ical_recurid, userid, version, data)" \ + "VALUES (" \ + " :rowid, :ical_recurid, :userid, :version, :data);" + +EXPORTED int caldav_write_jscalcache(struct caldav_db *caldavdb, + int rowid, + const char *ical_recurid, + const char *userid, + int version, + const char *data) { struct sqldb_bindval bval[] = { - { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, - { ":ical_uid", SQLITE_TEXT, { .s = ical_uid } }, - { NULL, SQLITE_NULL, { .s = NULL } } }; - struct caldav_data cdata; - struct read_rock rrock = { caldavdb, &cdata, 0, cb, rock }; - struct buf sqlbuf = BUF_INITIALIZER; + { ":rowid", SQLITE_INTEGER, { .i = rowid } }, + { ":ical_recurid", SQLITE_TEXT, { .s = ical_recurid } }, + { ":userid", SQLITE_TEXT, { .s = userid } }, + { ":version", SQLITE_INTEGER, { .i = version } }, + { ":data", SQLITE_TEXT, { .s = data } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; + int r; - buf_setcstr(&sqlbuf, CMD_READFIELDS); - buf_appendcstr(&sqlbuf, " WHERE alive = 1"); - if (mailbox) - buf_appendcstr(&sqlbuf, " AND mailbox = :mailbox"); - if (ical_uid) - buf_appendcstr(&sqlbuf, " AND ical_uid = :ical_uid"); - buf_appendcstr(&sqlbuf, " ORDER BY mailbox, imap_uid;"); + /* clean up existing records if any */ + r = sqldb_exec(caldavdb->db, CMD_DELETE_JSCALCACHE_USER, bval, NULL, NULL); + if (r) return r; - /* XXX - tombstones */ + /* insert the cache record */ + return sqldb_exec(caldavdb->db, CMD_INSERT_JSCALCACHE_USER, bval, NULL, NULL); +} - int r = sqldb_exec(caldavdb->db, buf_cstring(&sqlbuf), bval, &read_cb, &rrock); - buf_free(&sqlbuf); +EXPORTED struct caldav_jscal_filter *caldav_jscal_filter_new(void) +{ + struct caldav_jscal_filter *f = xmalloc(sizeof(struct caldav_jscal_filter)); + struct caldav_jscal_filter tmpl = CALDAV_JSCAL_FILTER_INITIALIZER; + memcpy(f, &tmpl, sizeof(struct caldav_jscal_filter)); + return f; +} + +EXPORTED void caldav_jscal_filter_by_ical_uid(struct caldav_jscal_filter* f, + const char *ical_uid, + const char *ical_recurid) +{ + assert(ical_uid); + struct caldav_jscal_id id = { + xstrdup(ical_uid), xstrdupnull(ical_recurid) + }; + dynarray_append(&f->jscal_ids, &id); +} + +EXPORTED void caldav_jscal_filter_by_mbentrym(struct caldav_jscal_filter* f, + mbentry_t *mbentry) +{ + ptrarray_append(&f->mbentries, mbentry); +} + +EXPORTED void caldav_jscal_filter_by_mbentry(struct caldav_jscal_filter* f, + const mbentry_t *mbentry) +{ + caldav_jscal_filter_by_mbentrym(f, mboxlist_entry_copy(mbentry)); +} + +EXPORTED void caldav_jscal_filter_by_imap_uid(struct caldav_jscal_filter* f, + uint32_t imap_uid) +{ + f->imap_uid = imap_uid; +} + +EXPORTED void caldav_jscal_filter_by_before(struct caldav_jscal_filter* f, + const time_t *before) +{ + if (before) { + f->before = *before; + f->_have_before = 1; + } + else { + f->before = 0; + f->_have_before = 0; + } +} + +EXPORTED void caldav_jscal_filter_by_after(struct caldav_jscal_filter* f, + const time_t *after) +{ + if (after) { + f->after = *after; + f->_have_after = 1; + } + else { + f->after = 0; + f->_have_after = 0; + } +} + +EXPORTED void caldav_jscal_filter_fini(struct caldav_jscal_filter *f) +{ + if (!f) return; + + mbentry_t *mbentry; + while ((mbentry = ptrarray_pop(&f->mbentries))) + mboxlist_entry_free(&mbentry); + ptrarray_fini(&f->mbentries); + + for (int i = 0; i < dynarray_size(&f->jscal_ids); i++) { + struct caldav_jscal_id *jscal_id = dynarray_nth(&f->jscal_ids, i); + free(jscal_id->ical_uid); + free(jscal_id->ical_recurid); + } + dynarray_fini(&f->jscal_ids); + + f->imap_uid = 0; + f->after = 0; + f->before = 0; + f->op = CALDAV_JSCAL_NOOP; + + struct caldav_jscal_filter *subf; + while ((subf = ptrarray_pop(&f->subfilters))) { + caldav_jscal_filter_free(&subf); + } + ptrarray_fini(&f->subfilters); +} + +EXPORTED void caldav_jscal_filter_free(struct caldav_jscal_filter **fp) +{ + if (!fp || !*fp) return; + + caldav_jscal_filter_fini(*fp); + free(*fp); + *fp = NULL; +} + + +struct shareacls_rock { + const char *userid; + char *principalname; + char *principalacl; + char *newprincipalacl; + char *outboxname; + char *outboxacl; + char *newoutboxacl; + hash_table user_access; +}; + +#define CALSHARE_WANTSCHED 1 +#define CALSHARE_HAVESCHED 2 +#define CALSHARE_WANTPRIN 4 +#define CALSHARE_HAVEPRIN 8 + +static int _add_shareacls(const mbentry_t *mbentry, void *rock) +{ + struct shareacls_rock *share = rock; + + char *acl = xstrdup(mbentry->acl); + + int isprincipal = !strcmp(mbentry->name, share->principalname); + int isoutbox = !strcmp(mbentry->name, share->outboxname); + + if (isprincipal) { + share->principalacl = xstrdup(acl); + share->newprincipalacl = xstrdup(acl); + } + + if (isoutbox) { + share->outboxacl = xstrdup(acl); + share->newoutboxacl = xstrdup(acl); + } + + char *userid; + char *nextid = NULL; + for (userid = acl; userid; userid = nextid) { + char *rightstr; + int access; + + rightstr = strchr(userid, '\t'); + if (!rightstr) break; + *rightstr++ = '\0'; + + nextid = strchr(rightstr, '\t'); + if (!nextid) break; + *nextid++ = '\0'; + + /* skip system users and owner */ + if (is_system_user(userid)) continue; + if (!strcmp(userid, share->userid)) continue; + + cyrus_acl_strtomask(rightstr, &access); + + uintptr_t have = (uintptr_t)hash_lookup(userid, &share->user_access); + uintptr_t set = have; + + // if it's the principal, we have each user with principal read access + if (isprincipal) { + if ((access & DACL_READ) == DACL_READ) + set |= CALSHARE_HAVEPRIN; + } + // if it's the Outbox, we have each user with reply ability + else if (isoutbox) { + if ((access & (DACL_INVITE|DACL_REPLY)) == (DACL_INVITE|DACL_REPLY)) + set |= CALSHARE_HAVESCHED; + } + // and if they can see anything else, then we NEED the above! + else { + if (access & ACL_READ) + set |= CALSHARE_WANTPRIN; + if (access & ACL_INSERT) + set |= CALSHARE_WANTSCHED; + } + + if (set != have) hash_insert(userid, (void *)set, &share->user_access); + } + + free(acl); + return 0; +} + +static void _update_acls(const char *userid, void *data, void *rock) +{ + struct shareacls_rock *share = rock; + uintptr_t aclstatus = (uintptr_t)data; + + if ((aclstatus & CALSHARE_WANTSCHED) && !(aclstatus & CALSHARE_HAVESCHED)) { + cyrus_acl_set(&share->newoutboxacl, userid, ACL_MODE_ADD, (DACL_INVITE|DACL_REPLY), NULL, NULL); + } + + if (!(aclstatus & CALSHARE_WANTSCHED) && (aclstatus & CALSHARE_HAVESCHED)) { + cyrus_acl_set(&share->newoutboxacl, userid, ACL_MODE_REMOVE, (DACL_INVITE|DACL_REPLY), NULL, NULL); + } + + if ((aclstatus & CALSHARE_WANTPRIN) && !(aclstatus & CALSHARE_HAVEPRIN)) { + cyrus_acl_set(&share->newprincipalacl, userid, ACL_MODE_ADD, DACL_READ, NULL, NULL); + } + + if (!(aclstatus & CALSHARE_WANTPRIN) && (aclstatus & CALSHARE_HAVEPRIN)) { + cyrus_acl_set(&share->newprincipalacl, userid, ACL_MODE_REMOVE, DACL_READ, NULL, NULL); + } +} + +/* update the share acls. We do this by: + * 1) iterating all the calendars for this user, looking at all the ACLs and + * tracking for each user mentioned, whether they have or need principal + * access or scheduling access. + * 2) when we see the inbox and outbox, clone the ACLs. + * 3) iterate all seen users, and decide whether we need to change the ACLs + * for either of those mailboxes. + */ +EXPORTED int caldav_update_shareacls(const char *userid) +{ + struct shareacls_rock rock = { + userid, + NULL, NULL, NULL, + NULL, NULL, NULL, + HASH_TABLE_INITIALIZER + }; + construct_hash_table(&rock.user_access, 10, 0); + rock.principalname = caldav_mboxname(userid, NULL); + rock.outboxname = caldav_mboxname(userid, SCHED_OUTBOX); + + // find out what the values should be + int r = mboxlist_mboxtree(rock.principalname, _add_shareacls, &rock, 0); + // did we find the ACLs? If not, bail now! + if (!rock.principalacl || !rock.outboxacl) { + r = IMAP_MAILBOX_NONEXISTENT; + goto done; + } + + // change the ACLs as required + hash_enumerate(&rock.user_access, _update_acls, &rock); + + if (strcmp(rock.principalacl, rock.newprincipalacl)) { + r = mboxlist_updateacl_raw(rock.principalname, rock.newprincipalacl); + if (r) goto done; + } + + if (strcmp(rock.outboxacl, rock.newoutboxacl)) { + r = mboxlist_updateacl_raw(rock.outboxname, rock.newoutboxacl); + if (r) goto done; + } + +done: + free(rock.principalname); + free(rock.principalacl); + free(rock.newprincipalacl); + free(rock.outboxname); + free(rock.outboxacl); + free(rock.newoutboxacl); + free_hash_table(&rock.user_access, NULL); + + return r; +} + + +EXPORTED const char *caldav_comp_type_as_string(unsigned comp_type) +{ + switch (comp_type) { + /* "Real" components */ + case CAL_COMP_VEVENT: + return "VEVENT"; + case CAL_COMP_VTODO: + return "VTODO"; + case CAL_COMP_VJOURNAL: + return "VJOURNAL"; + case CAL_COMP_VFREEBUSY: + return "VFREEBUSY"; + case CAL_COMP_VAVAILABILITY: + return "VAVAILABILITY"; + case CAL_COMP_VPOLL: + return "VPOLL"; + /* Other components */ + case CAL_COMP_VALARM: + return "VALARM"; + case CAL_COMP_VTIMEZONE: + return "VTIMEZONE"; + case CAL_COMP_VCALENDAR: + return "VCALENDAR"; + default: + return NULL; + } +} + +static icaltimezone *_get_calendar_tz(const char *mboxname, const char *userid) +{ + struct buf attrib = BUF_INITIALIZER; + icaltimezone *tz = NULL; + + /* Check for CALDAV:calendar-timezone-id */ + const char *prop_annot = + DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-timezone-id"; + + int r = annotatemore_lookupmask(mboxname, prop_annot, userid, &attrib); + if (!r && buf_len(&attrib)) { + tz = icaltimezone_get_builtin_timezone(buf_cstring(&attrib)); + buf_free(&attrib); + if (tz) return icaltimezone_copy(tz); + } + + /* Check for CALDAV:calendar-timezone */ + prop_annot = DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-timezone"; + + r = annotatemore_lookupmask(mboxname, prop_annot, userid, &attrib); + if (!r && buf_len(&attrib)) { + icalcomponent *ical, *vtz; + + ical = icalparser_parse_string(buf_cstring(&attrib)); + vtz = icalcomponent_get_first_component(ical, ICAL_VTIMEZONE_COMPONENT); + icalcomponent_remove_component(ical, vtz); + icalcomponent_free(ical); + buf_free(&attrib); + + tz = icaltimezone_new(); + icaltimezone_set_component(tz, vtz); + return tz; + } + + return NULL; +} + +EXPORTED icaltimezone *caldav_get_calendar_tz(const char *mboxname, + const char *userid) +{ + icaltimezone *tz = _get_calendar_tz(mboxname, userid); + + if (!tz) { + /* Try principal (calendar-home-set) */ + char *homeset = caldav_mboxname(userid, NULL); + tz = _get_calendar_tz(homeset, userid); + free(homeset); + } + + return tz; +} + +#define CMD_SELFLOATING CMD_READFIELDS \ + " WHERE mailbox = :mailbox AND dtstart NOT LIKE '%T%' ORDER BY imap_uid;" + +EXPORTED int caldav_get_floating_events(struct caldav_db *caldavdb, + const mbentry_t *mbentry, + caldav_cb_t *cb, void *rock) +{ + const char *mailbox = (caldavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; + struct sqldb_bindval bval[] = { + { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, + { NULL, SQLITE_NULL, { .s = NULL } } + }; + struct caldav_data cdata; + struct read_rock rrock = { caldavdb, &cdata, 0, cb, rock }; + int r; + + r = sqldb_exec(caldavdb->db, CMD_SELFLOATING, bval, &read_cb, &rrock); if (r) { syslog(LOG_ERR, "caldav error %s", error_message(r)); - /* XXX - free memory */ } - return r; } diff --git a/imap/caldav_db.h b/imap/caldav_db.h index df080d944b..d9e73bdce5 100644 --- a/imap/caldav_db.h +++ b/imap/caldav_db.h @@ -52,7 +52,9 @@ extern time_t caldav_eternity; #include #include "dav_db.h" +#include "dynarray.h" #include "ical_support.h" +#include "mboxlist.h" /* Bitmask of calendar components */ enum { @@ -71,6 +73,9 @@ enum { CAL_COMP_VCALENDAR = (1<<15) }; +/* Returns NULL for unknown type */ +extern const char *caldav_comp_type_as_string(unsigned comp_type); + #define CAL_COMP_REAL 0xff /* All "real" components */ struct caldav_db; @@ -82,6 +87,10 @@ struct comp_flags { unsigned tzbyref : 1; /* VTIMEZONEs by reference */ unsigned mattach : 1; /* Has managed ATTACHment(s) */ unsigned shared : 1; /* Is shared (per-user-data stripped) */ + unsigned defaultalerts : 1; /* Has default alerts property set */ + unsigned mayinviteself : 1; /* Users may invite themselves */ + unsigned mayinviteothers : 1; /* Attending users may invite others */ + unsigned privacy : 2; /* Privacy of calendar object (see below) */ }; /* Status values */ @@ -92,6 +101,13 @@ enum { CAL_STATUS_UNAVAILABLE }; +/* Privacy values */ +enum { + CAL_PRIVACY_PUBLIC = 0, + CAL_PRIVACY_PRIVATE, + CAL_PRIVACY_SECRET +}; + struct caldav_data { struct dav_data dav; /* MUST be first so we can typecast */ unsigned comp_type; @@ -121,14 +137,14 @@ int caldav_close(struct caldav_db *caldavdb); /* lookup an entry from 'caldavdb' by resource (optionally inside a transaction for updates) */ int caldav_lookup_resource(struct caldav_db *caldavdb, - const char *mailbox, const char *resource, + const mbentry_t *mbentry, const char *resource, struct caldav_data **result, int tombstones); /* lookup an entry from 'caldavdb' by mailbox and IMAP uid (optionally inside a transaction for updates) */ int caldav_lookup_imapuid(struct caldav_db *caldavdb, - const char *mailbox, int uid, + const mbentry_t *mbentry, int uid, struct caldav_data **result, int tombstones); @@ -138,27 +154,29 @@ int caldav_lookup_uid(struct caldav_db *caldavdb, const char *ical_uid, struct caldav_data **result); /* process each entry for 'mailbox' in 'caldavdb' with cb() */ -int caldav_foreach(struct caldav_db *caldavdb, const char *mailbox, +int caldav_foreach(struct caldav_db *caldavdb, const mbentry_t *mbentry, caldav_cb_t *cb, void *rock); -/* process each entry for 'mailbox' in 'caldavdb' with cb() - * which last recurrence ends after 'after' and first - * recurrence starts before 'before'. The largest possible - * timerange spans from caldav_epoch to caldav_eternity. */ -int caldav_foreach_timerange(struct caldav_db *caldavdb, const char *mailbox, - time_t after, time_t before, - caldav_cb_t *cb, void *rock); +enum caldav_sort { + CAL_SORT_NONE = 0, + CAL_SORT_ICAL_UID, + CAL_SORT_START, + CAL_SORT_MAILBOX, + CAL_SORT_IMAP_UID, + CAL_SORT_MODSEQ, + CAL_SORT_DESC = 0x80 /* bit-flag for descending sort */ +}; /* write an entry to 'caldavdb' */ int caldav_write(struct caldav_db *caldavdb, struct caldav_data *cdata); -int caldav_writeentry(struct caldav_db *caldavdb, struct caldav_data *cdata, - icalcomponent *ical); +int caldav_writeical(struct caldav_db *caldavdb, struct caldav_data *cdata, + icalcomponent *ical); /* delete an entry from 'caldavdb' */ int caldav_delete(struct caldav_db *caldavdb, unsigned rowid); /* delete all entries for 'mailbox' from 'caldavdb' */ -int caldav_delmbox(struct caldav_db *caldavdb, const char *mailbox); +int caldav_delmbox(struct caldav_db *caldavdb, const mbentry_t *mbentry); /* begin transaction */ int caldav_begin(struct caldav_db *caldavdb); @@ -171,17 +189,116 @@ int caldav_abort(struct caldav_db *caldavdb); char *caldav_mboxname(const char *userid, const char *name); -int caldav_get_events(struct caldav_db *caldavdb, - const char *mailbox, const char *ical_uid, - caldav_cb_t *cb, void *rock); - /* Process each entry for 'caldavdb' with a modseq higher than oldmodseq, * in ascending order of modseq. * If mailbox is not NULL, only process entries of this mailbox. * If kind is non-negative, only process entries of this kind. * If max_records is positive, only call cb for at most this entries. */ int caldav_get_updates(struct caldav_db *caldavdb, - modseq_t oldmodseq, const char *mailbox, int kind, + modseq_t oldmodseq, const mbentry_t *mbentry, int kind, int max_records, caldav_cb_t *cb, void *rock); +/* Update all the share ACLs */ +int caldav_update_shareacls(const char *userid); + +/* JSCalendar object API */ + +struct caldav_jscal { + struct caldav_data cdata; + const char *ical_recurid; // main events have empty string + const char *dtstart; + const char *dtend; + int alive; + modseq_t modseq; + modseq_t createdmodseq; + const char *ical_guid; + int cacheversion; + const char *cachedata; +}; + +enum caldav_jscal_filterop { + CALDAV_JSCAL_NOOP = 0, + CALDAV_JSCAL_AND, + CALDAV_JSCAL_OR, + CALDAV_JSCAL_NOT, + CALDAV_JSCAL_FALSE // never matches +}; + +struct caldav_jscal_id { + char *ical_uid; + char *ical_recurid; +}; + +struct caldav_jscal_filter { + ptrarray_t mbentries; // any of + dynarray_t jscal_ids; // any of + uint32_t imap_uid; + time_t after; + time_t before; + + enum caldav_jscal_filterop op; + ptrarray_t subfilters; + + int _have_after : 1; + int _have_before: 1; +}; + +#define CALDAV_JSCAL_FILTER_INITIALIZER { \ + .mbentries = PTRARRAY_INITIALIZER, \ + .jscal_ids = { .membsize = sizeof(struct caldav_jscal_id)}, \ + .subfilters = PTRARRAY_INITIALIZER \ +} + +extern struct caldav_jscal_filter *caldav_jscal_filter_new(void); + +extern void caldav_jscal_filter_by_ical_uid(struct caldav_jscal_filter*, + const char *ical_uid, const char *ical_recurid); + +extern void caldav_jscal_filter_by_mbentry(struct caldav_jscal_filter*, + const mbentry_t *mbentry); + +extern void caldav_jscal_filter_by_mbentrym(struct caldav_jscal_filter*, + mbentry_t *mbentry); + +extern void caldav_jscal_filter_by_imap_uid(struct caldav_jscal_filter*, + uint32_t imap_uid); + +extern void caldav_jscal_filter_by_before(struct caldav_jscal_filter*, + const time_t *before); + +extern void caldav_jscal_filter_by_after(struct caldav_jscal_filter*, + const time_t *after); + +extern void caldav_jscal_filter_fini(struct caldav_jscal_filter *); + +extern void caldav_jscal_filter_free(struct caldav_jscal_filter **); + +struct caldav_jscal_window { + modseq_t aftermodseq; + int tombstones; + size_t maxcount; +}; + +typedef int caldav_jscal_cb_t(void *rock, struct caldav_jscal *jscal); + +int caldav_foreach_jscal(struct caldav_db *caldavdb, + const char *cache_userid, + struct caldav_jscal_filter *filter, + struct caldav_jscal_window *window, + enum caldav_sort* sort, size_t nsort, + caldav_jscal_cb_t *cb, void *rock); + +int caldav_write_jscalcache(struct caldav_db *caldavdb, int rowid, + const char *recurid, const char *userid, + int version, const char *data); + +/* fetch time zone using for floating time events from calendar or principal */ +extern icaltimezone *caldav_get_calendar_tz(const char *mboxname, + const char *userid); + +/* fetch IMAP UIDs of all floating time events in calendar */ +int caldav_get_floating_events(struct caldav_db *caldavdb, + const mbentry_t *mbentry, + caldav_cb_t *cb, void *rock); + #endif /* CALDAV_DB_H */ diff --git a/imap/caldav_util.c b/imap/caldav_util.c new file mode 100644 index 0000000000..d30733d076 --- /dev/null +++ b/imap/caldav_util.c @@ -0,0 +1,1868 @@ +/* caldav_util.c -- utility functions for dealing with CALDAV database + * + * Copyright (c) 1994-2021 Carnegie Mellon University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include + +#include +#include + +#include "acl.h" +#include "caldav_alarm.h" +#include "caldav_db.h" +#include "caldav_util.h" +#include "defaultalarms.h" +#include "http_dav.h" +#include "itip_support.h" +#include "jmap_ical.h" +#include "mailbox.h" +#include "proxy.h" +#include "strarray.h" +#include "strhash.h" +#include "syslog.h" +#include "times.h" +#include "util.h" +#include "zoneinfo_db.h" + +/* generated headers are not necessarily in current directory */ +#include "imap/http_err.h" +#include "imap/imap_err.h" + + +static icaltimezone *utc_zone = NULL; + +/* Replace TZID aliases with the actual TZIDs */ +EXPORTED void replace_tzid_aliases(icalcomponent *ical, + struct hash_table *tzid_table) +{ + icalproperty *prop; + for (prop = icalcomponent_get_first_property(ical, ICAL_ANY_PROPERTY); + prop; + prop = icalcomponent_get_next_property(ical, ICAL_ANY_PROPERTY)) { + icalparameter *param = + icalproperty_get_first_parameter(prop, ICAL_TZID_PARAMETER); + if (!param) continue; + + const char *tzid = + hash_lookup(icalparameter_get_tzid(param), tzid_table); + if (tzid) icalparameter_set_tzid(param, tzid); + } + + icalcomponent *comp; + for (comp = icalcomponent_get_first_component(ical, ICAL_ANY_COMPONENT); + comp; + comp = icalcomponent_get_next_component(ical, ICAL_ANY_COMPONENT)) { + replace_tzid_aliases(comp, tzid_table); + } +} + + +/* Strip all VTIMEZONE components for known TZIDs */ +EXPORTED void strip_vtimezones(icalcomponent *ical) +{ + struct hash_table tzid_table; + icalcomponent *vtz, *next; + + /* Create hash table for TZID aliases */ + construct_hash_table(&tzid_table, 10, 1); + + for (vtz = icalcomponent_get_first_component(ical, ICAL_VTIMEZONE_COMPONENT); + vtz; vtz = next) { + + next = icalcomponent_get_next_component(ical, ICAL_VTIMEZONE_COMPONENT); + + icalproperty *prop = + icalcomponent_get_first_property(vtz, ICAL_TZID_PROPERTY); + const char *tzid = icalproperty_get_tzid(prop); + struct zoneinfo zi; + + if (tzid && !zoneinfo_lookup(tzid, &zi)) { + if (zi.type == ZI_LINK) { + /* Add this alias to our table */ + hash_insert(tzid, xstrdup(zi.data->s), &tzid_table); + } + freestrlist(zi.data); + + icalcomponent_remove_component(ical, vtz); + icalcomponent_free(vtz); + } + } + + if (hash_numrecords(&tzid_table)) { + /* Replace all TZID aliases with actual TZIDs. + Note: This NEEDS to be done, otherwise looking up the + builtin timezone will fail on a TZID mismatch. */ + replace_tzid_aliases(ical, &tzid_table); + } + free_hash_table(&tzid_table, free); +} + +static void add_defaultalarm_etagdata(const char *mboxname, + const char *userid, + struct buf *etagdata) +{ + struct defaultalarms defalarms = DEFAULTALARMS_INITIALIZER; + if (!defaultalarms_load(mboxname, userid, &defalarms)) { + if (!message_guid_isnull(&defalarms.with_time.guid)) { + buf_appendcstr(etagdata, + message_guid_encode(&defalarms.with_time.guid)); + } + if (!message_guid_isnull(&defalarms.with_date.guid)) { + buf_appendcstr(etagdata, + message_guid_encode(&defalarms.with_date.guid)); + } + defaultalarms_fini(&defalarms); + } +} + +EXPORTED int caldav_get_validators(struct mailbox *mailbox, void *data, + const char *userid, struct index_record *record, + const char **etag, time_t *lastmod) +{ + + const struct caldav_data *cdata = (const struct caldav_data *) data; + struct buf userdata = BUF_INITIALIZER; + struct buf etagdata = BUF_INITIALIZER; + + int r = dav_get_validators(mailbox, data, userid, record, etag, lastmod); + if (r) return r; + + if ((namespace_calendar.allow & ALLOW_USERDATA) && + cdata->dav.imap_uid && cdata->comp_flags.shared && + caldav_is_personalized(mailbox, cdata, userid, &userdata)) { + struct dlist *dl; + + /* Parse the userdata and fetch the validators */ + dlist_parsemap(&dl, 1, 0, buf_base(&userdata), buf_len(&userdata)); + + if (etag) { + struct message_guid *userdata_guid; + + dlist_getguid(dl, "GUID", &userdata_guid); + + /* Per-user ETag is hash of both on-disk and per-user GUID */ + buf_appendcstr(&etagdata, message_guid_encode(&record->guid)); + if (userdata_guid) + buf_appendcstr(&etagdata, message_guid_encode(userdata_guid)); + + /* Mix in default alarm data, if any */ + icalcomponent *ical = NULL; + if (caldav_get_usedefaultalerts(dl, mailbox, record, &ical)) { + add_defaultalarm_etagdata(mailbox_name(mailbox), userid, &etagdata); + } + } + if (lastmod) { + time_t user_lastmod; + + dlist_getdate(dl, "LASTMOD", &user_lastmod); + + /* Per-user Last-Modified is latest mod time */ + *lastmod = MAX(record->internaldate, user_lastmod); + } + + dlist_free(&dl); + buf_free(&userdata); + } + else if (cdata->comp_flags.defaultalerts) { + if (etag) { + buf_appendcstr(&etagdata, message_guid_encode(&record->guid)); + add_defaultalarm_etagdata(mailbox_name(mailbox), userid, &etagdata); + } + if (lastmod) { + /* XXX What, if anything do we do here? */ + } + } + + if (etag && buf_len(&etagdata)) { + struct message_guid etag_guid; + message_guid_generate(&etag_guid, buf_base(&etagdata), buf_len(&etagdata)); + *etag = message_guid_encode(&etag_guid); + } + + buf_free(&etagdata); + return 0; +} + + +EXPORTED int caldav_is_personalized(struct mailbox *mailbox, + const struct caldav_data *cdata, + const char *userid, + struct buf *userdata) +{ + if (caldav_is_secretarymode(mailbox_name(mailbox))) return 0; + + if (cdata->comp_flags.shared) { + /* Lookup per-user calendar data */ + int r = mailbox_get_annotate_state(mailbox, cdata->dav.imap_uid, NULL); + + if (!r) { + mbname_t *mbname = NULL; + + if (mailbox->i.options & OPT_IMAP_SHAREDSEEN) { + /* No longer using per-user-data - use owner data */ + mbname = mbname_from_intname(mailbox_name(mailbox)); + userid = mbname_userid(mbname); + } + + r = mailbox_annotation_lookup(mailbox, cdata->dav.imap_uid, + PER_USER_CAL_DATA, userid, userdata); + mbname_free(&mbname); + } + + if (!r && buf_len(userdata)) return 1; + buf_free(userdata); + } + else if (!(mailbox->i.options & OPT_IMAP_SHAREDSEEN) && + !mboxname_userownsmailbox(userid, mailbox_name(mailbox))) { + buf_init_ro_cstr(userdata, ICAL_PERSONAL_DATA_INITIALIZER); + return 1; + } + + return 0; +} + +EXPORTED icalcomponent *caldav_record_to_ical(struct mailbox *mailbox, + const struct caldav_data *cdata, + const char *userid, + strarray_t *schedule_addresses) +{ + icalcomponent *ical = NULL; + struct index_record record; + + /* Fetch index record for the cal resource */ + if (mailbox_find_index_record(mailbox, cdata->dav.imap_uid, &record)) { + return NULL; + } + + ical = record_to_ical(mailbox, &record, schedule_addresses); + + if (userid && (namespace_calendar.allow & ALLOW_USERDATA)) { + struct buf userdata = BUF_INITIALIZER; + + if (caldav_is_personalized(mailbox, cdata, userid, &userdata)) { + icalcomponent_add_personal_data(ical, &userdata); + } + + buf_free(&userdata); + } + + return ical; +} + +static int compare_properties(icalproperty *propa, icalproperty *propb) +{ + int cmp = 0; + + if (strcmp(icalproperty_as_ical_string(propa), + icalproperty_as_ical_string(propb))) { + icalproperty *mypropa = icalproperty_clone(propa); + icalproperty *mypropb = icalproperty_clone(propb); + + icalproperty_remove_parameter_by_name(mypropa, "X-JMAP-ID"); + icalproperty_remove_parameter_by_name(mypropb, "X-JMAP-ID"); + cmp = strcmp(icalproperty_as_ical_string(mypropa), + icalproperty_as_ical_string(mypropb)); + + icalproperty_free(mypropa); + icalproperty_free(mypropb); + } + + return cmp; +} + +enum propupdate { + propupdate_shared = (1 << 0), + propupdate_private = (1 << 1), + propupdate_rsvp = (1 << 2), + propupdate_inviteself = (1 << 3), + propupdate_inviteothers = (1 << 4) +}; + +static enum propupdate propupdate_all = ~0; + +static int validate_mayinvite(icalproperty *prop, + enum propupdate allow_propupdates, + const strarray_t *sched_addrs) +{ + if (allow_propupdates == propupdate_all) + return 0; + + const char *uri = icalproperty_get_attendee(prop); + if (uri &&!strncasecmp(uri, "mailto:", 7) && sched_addrs && + strarray_contains(sched_addrs, uri + 7)) { + /* User adds their own ATTENDEE */ + if (!(allow_propupdates & propupdate_inviteself)) + return HTTP_FORBIDDEN; + } + else if (!(allow_propupdates & propupdate_inviteothers)) { + /* User adds another ATTENDEE */ + return HTTP_FORBIDDEN; + } + + // Assert no ROLE is set + if (icalproperty_get_first_parameter(prop, ICAL_ROLE_PARAMETER)) + return HTTP_FORBIDDEN; + + // Assert JSCalendar role only contains 'attendee' + icalparameter *param; + for (param = icalproperty_get_first_parameter(prop, ICAL_X_PARAMETER); + param; + param = icalproperty_get_next_parameter(prop, ICAL_X_PARAMETER)) { + + if (!strcmpsafe(icalparameter_get_xname(param), JMAPICAL_XPARAM_ROLE)) { + const char *val = icalparameter_get_value_as_string(param); + + if (strcasecmpsafe(val, "attendee")) + return HTTP_FORBIDDEN; + } + } + + return 0; +} + + +/* + * Compare two components and extract per-user data (alarms, transparency). + * + * NOTE: This function assumes that both components has been normalized + */ +static int validate_propupdates(icalcomponent *ical, icalcomponent *oldical, + int personalize, + icalcomponent *vpatch, struct buf *path, + enum propupdate allow_propupdates, + const strarray_t *sched_addrs, + unsigned *num_changes) +{ + icalcomponent *comp, *nextcomp, *oldcomp = NULL, *patch = NULL; + icalproperty *prop, *nextprop, *oldprop = NULL; + int r; + + /* Add this component to path */ + size_t path_len = buf_len(path); + buf_printf(path, "/%s", + icalcomponent_kind_to_string(icalcomponent_isa(ical))); + + prop = icalcomponent_get_first_property(ical, ICAL_UID_PROPERTY); + if (prop) { + buf_printf(path, "[UID=%s]", icalproperty_get_uid(prop)); + prop = icalcomponent_get_first_property(ical, + ICAL_RECURRENCEID_PROPERTY); + buf_printf(path, "[RID=%s]", + prop ? icalproperty_get_value_as_string(prop) : "M"); + } + + if (oldical) { + oldprop = icalcomponent_get_first_property(oldical, ICAL_ANY_PROPERTY); + oldcomp = icalcomponent_get_first_component(oldical, ICAL_ANY_COMPONENT); + } + + for (prop = icalcomponent_get_first_property(ical, ICAL_ANY_PROPERTY); + prop; prop = nextprop) { + const char *xname = NULL, *oldxname; + icalproperty_kind kind = icalproperty_isa(prop); + icalproperty_kind oldkind = + oldprop ? icalproperty_isa(oldprop) : ICAL_NO_PROPERTY; + + nextprop = icalcomponent_get_next_property(ical, ICAL_ANY_PROPERTY); + + if (oldkind == ICAL_NO_PROPERTY) { + /* No more components in old component */ + r = -1; + } + else if (kind == oldkind) { + if (kind == ICAL_X_PROPERTY) { + /* Compare property names alphabetically */ + xname = icalproperty_get_x_name(prop); + oldxname = icalproperty_get_x_name(oldprop); + r = strcmp(xname, oldxname); + } + else if (kind == ICAL_ATTENDEE_PROPERTY) { + /* Compare ATTENDEE by calendar address */ + r = strcmpnull(icalproperty_get_attendee(prop), + icalproperty_get_attendee(oldprop)); + } + else r = 0; + } + else { + /* Compare property names alphabetically */ + r = strcmp(icalproperty_kind_to_string(kind), + icalproperty_kind_to_string(oldkind)); + } + + if (r == 0) { + switch (kind) { + case ICAL_CALSCALE_PROPERTY: + case ICAL_PRODID_PROPERTY: + case ICAL_DTSTAMP_PROPERTY: + case ICAL_LASTMODIFIED_PROPERTY: + /* Ok to modify these - ignore */ + break; + + case ICAL_ATTENDEE_PROPERTY: + { + const char *uri = icalproperty_get_attendee(prop); + if (uri && !strncasecmp(uri, "mailto:", 7) && sched_addrs && + strarray_contains(sched_addrs, uri + 7)) { + /* User updates their own ATTENDEE */ + if (!(allow_propupdates & propupdate_rsvp)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + } + else if (compare_properties(prop, oldprop)) { + if (!(allow_propupdates & propupdate_shared)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + } + break; + } + + case ICAL_SEQUENCE_PROPERTY: + if (!(allow_propupdates & ~propupdate_private)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + break; + + case ICAL_X_PROPERTY: + if (!strcmpsafe(xname, "X-MOZ-GENERATION")) { + /* Ok to modify these - ignore */ + break; + } + + GCC_FALLTHROUGH + + default: + { + if (compare_properties(prop, oldprop)) { + /* Property has been updated in ical */ + if (!(allow_propupdates & propupdate_shared)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + } + break; + } + } + } + else if (r < 0) { + /* Property has been added to ical */ + switch (kind) { + case ICAL_CALSCALE_PROPERTY: + case ICAL_PRODID_PROPERTY: + case ICAL_DTSTAMP_PROPERTY: + case ICAL_LASTMODIFIED_PROPERTY: + /* Ok to add these - ignore */ + break; + + case ICAL_X_PROPERTY: + xname = icalproperty_get_x_name(prop); + if (strcmp(xname, "X-JMAP-USEDEFAULTALERTS") && + strcmp(xname, "X-APPLE-DEFAULT-ALARM") && // required for legacy data + (strncmp(xname, "X-MOZ-", 6) || + (strcmp(xname+6, "LASTACK") && + strcmp(xname+6, "SNOOZE-TIME")))) { + if (!(allow_propupdates & propupdate_shared)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + break; + + } + + GCC_FALLTHROUGH + + case ICAL_TRANSP_PROPERTY: + case ICAL_COLOR_PROPERTY: + case ICAL_CATEGORIES_PROPERTY: + if (vpatch) { + /* Add per-user property to VPATCH */ + if (!patch) { + patch = icalcomponent_vanew(ICAL_XPATCH_COMPONENT, + icalproperty_new_patchtarget( + buf_cstring(path)), + NULL); + icalcomponent_add_component(vpatch, patch); + } + + icalcomponent_remove_property(ical, prop); + icalcomponent_add_property(patch, prop); + } + break; + + case ICAL_ATTENDEE_PROPERTY: + r = validate_mayinvite(prop, allow_propupdates, sched_addrs); + if (r) return r; + + if (num_changes) (*num_changes)++; + break; + + case ICAL_SEQUENCE_PROPERTY: + if (!(allow_propupdates & ~propupdate_private)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + break; + + default: + if (!(allow_propupdates & propupdate_shared)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + break; + } + + continue; /* Do NOT increment to next old property */ + } + else { + /* Property has been removed from ical */ + switch (oldkind) { + case ICAL_CALSCALE_PROPERTY: + case ICAL_PRODID_PROPERTY: + case ICAL_DTSTAMP_PROPERTY: + case ICAL_LASTMODIFIED_PROPERTY: + /* Ok to remove these - ignore */ + break; + + default: + if (!(allow_propupdates & propupdate_shared)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + break; + } + } + + oldprop = icalcomponent_get_next_property(oldical, ICAL_ANY_PROPERTY); + } + + for (comp = icalcomponent_get_first_component(ical, ICAL_ANY_COMPONENT); + comp; comp = nextcomp) { + icalcomponent_kind kind = icalcomponent_isa(comp); + icalcomponent_kind oldkind = + oldcomp ? icalcomponent_isa(oldcomp) : ICAL_NO_COMPONENT; + + nextcomp = icalcomponent_get_next_component(ical, ICAL_ANY_COMPONENT); + + if (oldkind == ICAL_NO_COMPONENT) { + /* No more components in old component */ + r = -1; + } + else if (kind == oldkind) { + if (kind == ICAL_X_COMPONENT) { + /* Compare component names alphabetically */ + + /* XXX Need a new libical function */ + r = 0; + } + else r = 0; + } + else { + /* Compare component names alphabetically */ + r = strcmp(icalcomponent_kind_to_string(kind), + icalcomponent_kind_to_string(oldkind)); + } + + if (r == 0) { + r = validate_propupdates(comp, oldcomp, + personalize, vpatch, + path, allow_propupdates, + sched_addrs, num_changes); + if (r) return r; + } + else if (r < 0) { + /* Component has been added to ical */ + switch (kind) { + case ICAL_VALARM_COMPONENT: + if (vpatch) { + /* Add per-user component to VPATCH */ + if (!patch) { + patch = icalcomponent_vanew(ICAL_XPATCH_COMPONENT, + icalproperty_new_patchtarget( + buf_cstring(path)), + NULL); + icalcomponent_add_component(vpatch, patch); + } + + icalcomponent_remove_component(ical, comp); + icalcomponent_add_component(patch, comp); + } + break; + + default: + if (!(allow_propupdates & propupdate_shared)) + return HTTP_FORBIDDEN; + if (num_changes) (*num_changes)++; + + r = validate_propupdates(comp, oldcomp, + personalize, vpatch, + path, allow_propupdates, + sched_addrs, num_changes); + if (r) return r; + break; + } + + continue; /* Do NOT increment to next old component */ + } + else if (!(allow_propupdates & propupdate_shared)) { + return HTTP_FORBIDDEN; + } + else { + /* Component has been removed from ical */ + if (num_changes) (*num_changes)++; + } + + oldcomp = icalcomponent_get_next_component(oldical, ICAL_ANY_COMPONENT); + } + + /* Trim this component from path */ + buf_truncate(path, path_len); + + return 0; +} + +static int write_personal_data(const char *userid, + struct mailbox *mailbox, + uint32_t uid, + modseq_t modseq, + int usedefaultalerts, + icalcomponent *vpatch) +{ + struct buf value = BUF_INITIALIZER; + + struct icalsupport_personal_data data = { + .lastmod = time(NULL), + .modseq = modseq, + .vpatch = vpatch, + .usedefaultalerts = usedefaultalerts + }; + + icalsupport_encode_personal_data(&value, &data); + + int ret = mailbox_annotation_write(mailbox, uid, + PER_USER_CAL_DATA, userid, &value); + + buf_free(&value); + return ret; +} + +static int includes_attendee(icalcomponent *ical, const strarray_t *sched_addrs) +{ + if (!ical || !sched_addrs) return 0; + + icalcomponent *comp; + for (comp = icalcomponent_get_first_real_component(ical); + comp; + comp = icalcomponent_get_next_component(ical, icalcomponent_isa(comp))) { + + icalproperty *prop; + for (prop = icalcomponent_get_first_property(comp, ICAL_ATTENDEE_PROPERTY); + prop; + prop = icalcomponent_get_next_property(comp, ICAL_ATTENDEE_PROPERTY)) { + + const char *uri = icalproperty_get_attendee(prop); + if (uri && !strncasecmp(uri, "mailto:", 7) && + strarray_contains(sched_addrs, uri + 7)) + return 1; + } + } + + return 0; +} + +/* + * Enforce per-property permissions in iCalendar data and optionally + * handle stripping per-user data from existing and/or new shared resource. + * + * Logic is as follows: + * + * Owner R/W Exists Shared EO SO EU SU PD + * ------------------------------------------------ + * 0 0 Y + * 0 0 1 0 Y Y Y + * 0 0 1 1 Y + * 0 1 0 Y Y + * 0 1 1 0 Y Y ? + * 0 1 1 1 Y ? + * 1 0 1 Y + * 1 1 0 Y + * 1 1 1 0 Y + * 1 1 1 1 Y ? + * + * EO = Extract Owner Data + * SO = Store Owner Resource + * EU = Extract User Data + * SU = Store User Resource + * PD = Permission Denied + */ +static int caldav_store_preprocess(struct transaction_t *txn, + struct mailbox *mailbox, + icalcomponent *ical, + struct caldav_data *cdata, + const char *userid, + icalcomponent **store_me, + int personalize, + icalcomponent **userdata, + const strarray_t *schedule_addresses) + +{ + int is_owner, rights, ret = 0; + mbname_t *mbname; + const char *owner; + icalcomponent *oldical = NULL; + unsigned num_changes = 0; + struct auth_state *authstate = auth_newstate(userid); + char *resource = xstrdupnull(cdata->dav.resource); + enum propupdate allow_propupdates = 0; + + *store_me = ical; + + if (!utc_zone) utc_zone = icaltimezone_get_utc_timezone(); + + /* Check ownership and ACL for current user */ + mbname = mbname_from_intname(mailbox_name(mailbox)); + owner = mbname_userid(mbname); + is_owner = !strcmpsafe(owner, userid); + + rights = cyrus_acl_myrights(authstate, mailbox_acl(mailbox)); + auth_freestate(authstate); + + if (rights & DACL_WRITECONT) { + /* User has read-write access */ + allow_propupdates = propupdate_all; + } + else if (cdata->dav.imap_uid && (rights & DACL_WRITEOWNRSRC) && + (!cdata->organizer || + (schedule_addresses && + strarray_contains(schedule_addresses, cdata->organizer)))) { + /* User may update resource whey they are organizer */ + allow_propupdates = propupdate_all; + } + else { + if (rights & DACL_UPDATEPRIVATE) { + /* User may only update their per-user properties */ + allow_propupdates |= propupdate_private; + } + + if (rights & DACL_REPLY) { + /* User may update their own ATTENDEE */ + allow_propupdates |= propupdate_rsvp; + + if (cdata->dav.imap_uid && cdata->comp_flags.mayinviteself) { + /* User may add their own ATTENDEE */ + allow_propupdates |= propupdate_inviteself; + } + + if (cdata->dav.imap_uid && cdata->comp_flags.mayinviteothers && + includes_attendee(ical, schedule_addresses)) { + /* User may add add other ATTENDEEs */ + allow_propupdates |= propupdate_inviteothers; + } + } + + if (cdata->dav.imap_uid && + !(mailbox->i.options & OPT_IMAP_SHAREDSEEN)) { + /* User has read-only access to existing resource */ + allow_propupdates |= propupdate_private; + } + } + + if (!allow_propupdates) { + /* DAV:need-privileges */ + txn->error.precond = DAV_NEED_PRIVS; + txn->error.resource = txn->req_tgt.path; + txn->error.rights = DACL_WRITECONT; + ret = HTTP_NO_PRIVS; + goto done; + } + + if (cdata->dav.imap_uid && + (!is_owner || allow_propupdates != propupdate_all || cdata->comp_flags.shared)) { + syslog(LOG_NOTICE, "LOADING ICAL %u", cdata->dav.imap_uid); + + /* Load message containing the existing resource and parse iCal data */ + oldical = caldav_record_to_ical(mailbox, cdata, NULL, NULL); + if (!oldical) { + txn->error.desc = "Failed to read record"; + ret = HTTP_SERVER_ERROR; + goto done; + } + + if (!personalize) { + // just enforce per-property permissions + ret = validate_propupdates(ical, oldical, 0, NULL, + &txn->buf /* path */, allow_propupdates, + schedule_addresses, NULL); + goto done; + } + } + + if (personalize && cdata->dav.imap_uid && + !is_owner && !cdata->comp_flags.shared) { + /* Split owner's personal data from resource */ + + /* Create UID for owner VPATCH */ + assert(!buf_len(&txn->buf)); + buf_printf(&txn->buf, "%x-%x-%x", strhash(mailbox_name(mailbox)), + strhash(resource), strhash(owner)); + + *userdata = + icalcomponent_vanew(ICAL_VPATCH_COMPONENT, + icalproperty_new_version("1"), + icalproperty_new_dtstamp( + icaltime_from_timet_with_zone(time(0), + 0, + utc_zone)), + icalproperty_new_uid(buf_cstring(&txn->buf)), + NULL); + buf_reset(&txn->buf); + + /* Extract personal info from owner's resource and create vpatch */ + int usedefaultalerts = icalcomponent_get_usedefaultalerts(oldical); + ret = validate_propupdates(oldical, NULL, personalize, *userdata, + &txn->buf /* path */, propupdate_all, + schedule_addresses, &num_changes); + buf_reset(&txn->buf); + + if (!ret) ret = write_personal_data(owner, mailbox, cdata->dav.imap_uid, + cdata->dav.modseq, usedefaultalerts, + *userdata); + + if (ret) goto done; + + if (allow_propupdates == propupdate_private) { + /* Resource to store is the existing resource just stripped */ + *store_me = oldical; + } + + icalcomponent_free(*userdata); + *userdata = NULL; + } + + if (personalize && (!is_owner || allow_propupdates != propupdate_all || + (cdata->dav.imap_uid && cdata->comp_flags.shared))) { + /* Extract personal info from user's resource and create vpatch */ + if (oldical) { + /* Normalize existing resource for comparison */ + icalcomponent_normalize_x(oldical); + + /* Normalize new resource for comparison */ + icalcomponent_normalize_x(ical); + } + + /* Create UID for sharee VPATCH */ + assert(!buf_len(&txn->buf)); + buf_printf(&txn->buf, "%x-%x-%x", strhash(mailbox_name(mailbox)), + strhash(resource), strhash(userid)); + + *userdata = + icalcomponent_vanew(ICAL_VPATCH_COMPONENT, + icalproperty_new_version("1"), + icalproperty_new_dtstamp( + icaltime_from_timet_with_zone(time(0), + 0, + utc_zone)), + icalproperty_new_uid(buf_cstring(&txn->buf)), + NULL); + buf_reset(&txn->buf); + + /* Extract personal info from new resource and add to vpatch */ + /* XXX DO NOT reinitialize num_changes. We need the changes + from rewriting owner resource to force storage of that resource */ + ret = validate_propupdates(ical, oldical, personalize, *userdata, + &txn->buf /* path */, allow_propupdates, + schedule_addresses, &num_changes); + buf_reset(&txn->buf); + + if (ret) goto done; + + if (cdata->dav.imap_uid) { + if (!num_changes) { + /* No resource to store (per-user data change only) */ + ret = HTTP_NO_CONTENT; + *store_me = NULL; + goto done; + } + } + + cdata->comp_flags.shared = 1; + } + + done: + if (oldical && (*store_me != oldical)) icalcomponent_free(oldical); + mbname_free(&mbname); + free(resource); + + return ret; +} + +HIDDEN int caldav_is_secretarymode(const char *mboxname) +{ + mbname_t *mbname = mbname_from_intname(mboxname); + int is_secretarymode = 0; + + const strarray_t *boxes = mbname_boxes(mbname); + const char *prefix = config_getstring(IMAPOPT_CALENDARPREFIX); + if (strarray_size(boxes) && !strcmpsafe(prefix, strarray_nth(boxes, 0))) { + mbname_truncate_boxes(mbname, 1); + static const char *annot = + DAV_ANNOT_NS "<" XML_NS_JMAPCAL ">sharees-act-as"; + struct buf val = BUF_INITIALIZER; + annotatemore_lookup(mbname_intname(mbname), annot, "", &val); + is_secretarymode = !strcmp(buf_cstring(&val), "secretary"); + buf_free(&val); + } + + mbname_free(&mbname); + return is_secretarymode; +} + +static void strip_schedule_params(icalcomponent *ical) +{ + icalcomponent *comp = icalcomponent_get_first_real_component(ical); + if (!comp) return; + + /* Only remove SCHEDULE-FORCE-SEND */ + + icalcomponent_kind kind = icalcomponent_isa(comp); + for (comp = icalcomponent_get_first_component(ical, kind); + comp; + comp = icalcomponent_get_next_component(ical, kind)) { + + /* Grab the organizer */ + icalproperty *prop = icalcomponent_get_first_property(comp, ICAL_ORGANIZER_PROPERTY); + + /* Remove CalDAV Scheduling parameters from organizer */ + icalproperty_remove_parameter_by_name(prop, "SCHEDULE-FORCE-SEND"); + + /* Remove CalDAV Scheduling parameters from attendees */ + for (prop = icalcomponent_get_first_invitee(comp); + prop; + prop = icalcomponent_get_next_invitee(comp)) { + icalproperty_remove_parameter_by_name(prop, "SCHEDULE-FORCE-SEND"); + } + } +} + +static void preserve_jmap_permissions(icalcomponent *ical, + icalcomponent_kind kind, + struct caldav_data *cdata) +{ + icalcomponent *comp; + + /* Remove any JMAP permissions */ + for (comp = icalcomponent_get_first_component(ical, kind); + comp; + comp = icalcomponent_get_next_component(ical, kind)) { + + icalproperty *prop, *nextprop; + + for (prop = icalcomponent_get_first_property(comp, ICAL_X_PROPERTY); + prop; prop = nextprop) { + + nextprop = icalcomponent_get_next_property(comp, ICAL_X_PROPERTY); + + if (!strcmp(icalproperty_get_x_name(prop), JMAPICAL_XPROP_MAYINVITESELF) || + !strcmp(icalproperty_get_x_name(prop), JMAPICAL_XPROP_MAYINVITEOTHERS)) { + icalcomponent_remove_property(comp, prop); + icalproperty_free(prop); + } + } + } + + /* Insert existing JMAP permissions */ + for (comp = icalcomponent_get_first_component(ical, kind); + comp; + comp = icalcomponent_get_next_component(ical, kind)) { + + if (cdata->comp_flags.mayinviteself) { + icalproperty *prop = icalproperty_new(ICAL_X_PROPERTY); + icalproperty_set_x_name(prop, JMAPICAL_XPROP_MAYINVITESELF); + icalproperty_set_value(prop, icalvalue_new_boolean(1)); + icalcomponent_add_property(comp, prop); + } + + if (cdata->comp_flags.mayinviteothers) { + icalproperty *prop = icalproperty_new(ICAL_X_PROPERTY); + icalproperty_set_x_name(prop, JMAPICAL_XPROP_MAYINVITEOTHERS); + icalproperty_set_value(prop, icalvalue_new_boolean(1)); + icalcomponent_add_property(comp, prop); + } + } +} + +/* Store the iCal data in the specified calendar/resource */ +EXPORTED int caldav_store_resource(struct transaction_t *txn, icalcomponent *ical, + struct mailbox *mailbox, const char *resource, + modseq_t createdmodseq, + struct caldav_db *caldavdb, + unsigned flags, const char *userid, + const strarray_t *add_imapflags, + const strarray_t *del_imapflags, + const strarray_t *schedule_addresses) +{ + int ret; + icalcomponent *comp, *userdata = NULL, *store_ical = ical; + icalcomponent_kind kind; + icalproperty_method meth; + icalproperty *prop; + unsigned mykind = 0, tzbyref = 0; + const char *organizer = NULL; + const char *prop_annot = + DAV_ANNOT_NS "<" XML_NS_CALDAV ">supported-calendar-component-set"; + struct buf attrib = BUF_INITIALIZER; + struct caldav_data *cdata; + const char *uid; + struct index_record *oldrecord = NULL, record; + char datestr[80], *mimehdr; + const char *sched_tag; + uint32_t newuid = 0; + strarray_t myimapflags = STRARRAY_INITIALIZER; + int usedefaultalerts = 0; // for per-user data + int is_secretarymode = caldav_is_secretarymode(mailbox_name(mailbox)); + int personalize = 0; + + errno = 0; + + if (!utc_zone) utc_zone = icaltimezone_get_utc_timezone(); + + /* Check for supported component type */ + comp = icalcomponent_get_first_real_component(ical); + uid = icalcomponent_get_uid(comp); + kind = icalcomponent_isa(comp); + switch (kind) { + case ICAL_VEVENT_COMPONENT: mykind = CAL_COMP_VEVENT; break; + case ICAL_VTODO_COMPONENT: mykind = CAL_COMP_VTODO; break; + case ICAL_VJOURNAL_COMPONENT: mykind = CAL_COMP_VJOURNAL; break; + case ICAL_VFREEBUSY_COMPONENT: mykind = CAL_COMP_VFREEBUSY; break; + case ICAL_VAVAILABILITY_COMPONENT: mykind = CAL_COMP_VAVAILABILITY; break; + case ICAL_VPOLL_COMPONENT: mykind = CAL_COMP_VPOLL; break; + default: + txn->error.precond = CALDAV_SUPP_COMP; + return HTTP_FORBIDDEN; + } + + if (!annotatemore_lookupmask_mbox(mailbox, prop_annot, txn->userid, &attrib) + && attrib.len) { + unsigned long supp_comp = strtoul(buf_cstring(&attrib), NULL, 10); + + buf_free(&attrib); + + if (!(mykind & supp_comp)) { + txn->error.precond = CALDAV_SUPP_COMP; + return HTTP_FORBIDDEN; + } + } + + /* Find message UID for the resource, if exists */ + /* XXX We can't assume that txn->req_tgt.mbentry is our target, + XXX because we may have been called as part of a COPY/MOVE */ + const mbentry_t mbentry = { .name = (char *)mailbox_name(mailbox), + .uniqueid = (char *)mailbox_uniqueid(mailbox) }; + caldav_lookup_resource(caldavdb, &mbentry, resource, &cdata, 0); + + /* does it already exist? */ + if (cdata->dav.imap_uid) { + newuid = cdata->dav.imap_uid; + /* Check for change of iCalendar UID */ + if (strcmp(cdata->ical_uid, uid)) { + /* CALDAV:no-uid-conflict */ + txn->error.precond = CALDAV_UID_CONFLICT; + return HTTP_FORBIDDEN; + } + /* Fetch index record for the resource */ + int r = mailbox_find_index_record(mailbox, cdata->dav.imap_uid, &record); + if (!r) { + oldrecord = &record; + } + else { + xsyslog(LOG_ERR, + "Couldn't find index record corresponding to CalDAV DB record", + "mailbox=<%s> record=<%u> error=<%s>", + mailbox_name(mailbox), cdata->dav.imap_uid, error_message(r)); + } + } + + if (cdata->dav.imap_uid && (flags & PERMS_NOKEEP) == 0) { + preserve_jmap_permissions(ical, kind, cdata); + } + + /* Copy add_imapflags, we might need to add some flags */ + if (add_imapflags) strarray_cat(&myimapflags, add_imapflags); + + /* Remove all X-LIC-ERROR properties */ + icalcomponent_strip_errors(ical); + + /* Remove all VTIMEZONE components for known TZIDs */ + if (namespace_calendar.allow & ALLOW_CAL_NOTZ) { + strip_vtimezones(ical); + tzbyref = 1; + } + + /* Remove schedule parameters */ + strip_schedule_params(ical); + + /* Set Schedule-Tag, if any */ + if (flags & NEW_STAG) { + if (oldrecord) sched_tag = message_guid_encode(&oldrecord->guid); + else sched_tag = NULL_ETAG; + } + else if (organizer) sched_tag = cdata->sched_tag; + else sched_tag = cdata->sched_tag = NULL; + + /* If we are just stripping VTIMEZONEs from resource, flag it */ + if (flags & TZ_STRIP) strarray_append(&myimapflags, DFLAG_UNCHANGED); + else if (mailbox->i.options & OPT_IMAP_SHAREDSEEN) { + cdata->comp_flags.shared = 0; + } + else if (userid && (namespace_calendar.allow & ALLOW_USERDATA) && !is_secretarymode) { + usedefaultalerts = icalcomponent_get_usedefaultalerts(ical); + personalize = 1; + } + + if (userid) { + ret = caldav_store_preprocess(txn, mailbox, ical, cdata, userid, + &store_ical, personalize, &userdata, schedule_addresses); + if (ret) goto done; + + if (store_ical != ical) { + comp = icalcomponent_get_first_real_component(store_ical); + uid = icalcomponent_get_uid(comp); + kind = icalcomponent_isa(comp); + } + } + + /* Create and cache RFC 5322 header fields for resource */ + prop = icalcomponent_get_first_property(comp, ICAL_ORGANIZER_PROPERTY); + if (prop) { + organizer = icalproperty_get_organizer(prop); + if (organizer) { + if (!strncasecmp(organizer, "mailto:", 7)) organizer += 7; + assert(!buf_len(&txn->buf)); + buf_printf(&txn->buf, "<%s>", organizer); + mimehdr = charset_encode_mimeheader(buf_cstring(&txn->buf), + buf_len(&txn->buf), 0); + spool_replace_header(xstrdup("From"), mimehdr, txn->req_hdrs); + buf_reset(&txn->buf); + } + } + + const char *summary = icalcomponent_get_summary(comp); + if (summary) { + int force = !!strchr(summary, '\n'); // force encoding if embedded LF + + mimehdr = charset_encode_mimeheader(summary, 0, force); + + /* trim trailing WS (LF will break our MIME message header) */ + char *end = mimehdr + strlen(mimehdr) - 1; + while (end >= mimehdr && isspace(*end)) *end-- = '\0'; + + spool_replace_header(xstrdup("Subject"), mimehdr, txn->req_hdrs); + } + else spool_replace_header(xstrdup("Subject"), + xstrdup(icalcomponent_kind_to_string(kind)), + txn->req_hdrs); + + if (strarray_size(schedule_addresses)) { + char *value = strarray_join(schedule_addresses, ","); + mimehdr = charset_encode_mimeheader(value, 0, 0); + spool_replace_header(xstrdup("X-Schedule-User-Address"), + mimehdr, txn->req_hdrs); + free(value); + } + + time_to_rfc5322(icaltime_as_timet_with_zone(icalcomponent_get_dtstamp(comp), + utc_zone), + datestr, sizeof(datestr)); + spool_replace_header(xstrdup("Date"), xstrdup(datestr), txn->req_hdrs); + + /* Use SHA1(uid)@servername as Message-ID */ + struct message_guid uuid; + message_guid_generate(&uuid, uid, strlen(uid)); + buf_printf(&txn->buf, "<%s@%s>", + message_guid_encode(&uuid), config_servername); + spool_replace_header(xstrdup("Message-ID"), + buf_release(&txn->buf), txn->req_hdrs); + + buf_setcstr(&txn->buf, ICALENDAR_CONTENT_TYPE); + if ((meth = icalcomponent_get_method(store_ical)) != ICAL_METHOD_NONE) { + buf_printf(&txn->buf, "; method=%s", + icalproperty_method_to_string(meth)); + } + buf_printf(&txn->buf, "; component=%s", icalcomponent_kind_to_string(kind)); + spool_replace_header(xstrdup("Content-Type"), + buf_release(&txn->buf), txn->req_hdrs); + + /* Since we use the iCalendar UID in the resource name, + this param may be long and needs to get properly split per RFC 2231 */ + buf_setcstr(&txn->buf, "attachment"); + charset_append_mime_param(&txn->buf, + CHARSET_PARAM_XENCODE | CHARSET_PARAM_NEWLINE, + "filename", + resource); + + if (sched_tag) buf_printf(&txn->buf, ";\r\n\tschedule-tag=%s", sched_tag); + if (tzbyref) buf_printf(&txn->buf, ";\r\n\ttz-by-ref=true"); + if (cdata->comp_flags.shared) { + buf_printf(&txn->buf, ";\r\n\tper-user-data=true"); + } + spool_replace_header(xstrdup("Content-Disposition"), + buf_release(&txn->buf), txn->req_hdrs); + + spool_remove_header(xstrdup("Content-Description"), txn->req_hdrs); + + /* Store the resource */ + ret = dav_store_resource(txn, icalcomponent_as_ical_string(store_ical), 0, + mailbox, oldrecord, createdmodseq, &myimapflags, + del_imapflags); + strarray_fini(&myimapflags); + + newuid = mailbox->i.last_uid; + + done: + switch (ret) { + case HTTP_CREATED: + case HTTP_NO_CONTENT: + if ((namespace_calendar.allow & ALLOW_USERDATA) && + cdata->comp_flags.shared) { + + /* either the UID created by dav_store_resource, + * or if nothing but per-user data was changed, + * the UID of the existing record */ + assert(newuid); + + /* Ensure we have an astate connected to the mailbox, + * so that the annotation txn will be committed + * when we close the mailbox */ + annotate_state_t *astate = NULL; + + if (oldrecord && (newuid != oldrecord->uid) && + !mailbox_get_annotate_state(mailbox, newuid, &astate)) { + /* Copy across all per-message annotations. + + XXX Hack until we fix annotation copying in + append_fromstage() to preserve userid of private annots. */ + annotate_msg_copy(mailbox, oldrecord->uid, + mailbox, newuid, NULL); + } + + if (!is_secretarymode) { + int r = write_personal_data(userid, mailbox, newuid, + mailbox->i.highestmodseq+1, + usedefaultalerts, userdata); + if (r) { + /* XXX We have already written the stripped resource + so we're pretty screwed. All message annotations + need to be handled (properly) in append_fromstage() + so storing resource and annotations is atomic. + */ + txn->error.desc = error_message(r); + ret = HTTP_SERVER_ERROR; + goto done; + } + } + + if (store_ical) { + /* Write shared modseq for resource */ + buf_printf(&txn->buf, MODSEQ_FMT, mailbox->i.highestmodseq); + mailbox_get_annotate_state(mailbox, newuid, NULL); + mailbox_annotation_write(mailbox, newuid, SHARED_MODSEQ, + /* shared */ "", &txn->buf); + buf_reset(&txn->buf); + } + + if (!cdata->organizer || (flags & PREFER_REP)) { + /* Read index record for new message (always the last one) */ + struct index_record newrecord; + + cdata->dav.alive = 1; + cdata->dav.imap_uid = newuid; + + caldav_get_validators(mailbox, cdata, userid, &newrecord, + &txn->resp_body.etag, + &txn->resp_body.lastmod); + + if (flags & PREFER_REP) { + /* Re-insert per-user data */ + icalcomponent_apply_vpatch(ical, userdata, NULL, NULL); + } + } + } + + if (cdata->organizer) { + if (flags & NEW_STAG) txn->resp_body.stag = sched_tag; + + if (!(flags & PREFER_REP)) { + /* iCal data has been rewritten - don't return validators */ + txn->resp_body.lastmod = 0; + txn->resp_body.etag = NULL; + } + } + break; + } + + if (userdata) icalcomponent_free(userdata); + if (store_ical && (store_ical != ical)) icalcomponent_free(store_ical); + + return ret; +} + +static int _create_mailbox(const char *userid, const char *mailboxname, + int type, unsigned long comp_types, + int useracl, int anyoneacl, const char *displayname, + const struct namespace *namespace, + const struct auth_state *authstate, + int is_jmapcalendar, + struct mboxlock **namespacelockp) +{ + char rights[100]; + struct mailbox *mailbox = NULL; + + int r = mboxlist_lookup(mailboxname, NULL, NULL); + if (r != IMAP_MAILBOX_NONEXISTENT) return r; + + if (!*namespacelockp) { + *namespacelockp = mboxname_usernamespacelock(mailboxname); + // maybe we lost the race on this one + r = mboxlist_lookup(mailboxname, NULL, NULL); + if (r != IMAP_MAILBOX_NONEXISTENT) return r; + } + + /* Create locally */ + mbentry_t mbentry = MBENTRY_INITIALIZER; + mbentry.name = (char *) mailboxname; + mbentry.mbtype = type; + r = mboxlist_createmailbox(&mbentry, 0/*options*/, 0/*highestmodseq*/, + 0/*isadmin*/, userid, authstate, + 0/*flags*/, displayname ? &mailbox : NULL); + if (!r && displayname) { + annotate_state_t *astate = NULL; + + r = mailbox_get_annotate_state(mailbox, 0, &astate); + if (!r) { + const char *disp_annot = DAV_ANNOT_NS "<" XML_NS_DAV ">displayname"; + const char *comp_annot = + DAV_ANNOT_NS "<" XML_NS_CALDAV ">supported-calendar-component-set"; + struct buf value = BUF_INITIALIZER; + + buf_init_ro_cstr(&value, displayname); + r = annotate_state_writemask(astate, disp_annot, userid, &value); + if (!r && comp_types) { + buf_reset(&value); + buf_printf(&value, "%lu", comp_types); + r = annotate_state_writemask(astate, comp_annot, userid, &value); + } + buf_free(&value); + + if (is_jmapcalendar) { +#ifdef WITH_JMAP + caldav_init_jmapcalendar(userid, mailbox); +#endif + } + } + + mailbox_close(&mailbox); + } + if (!r && useracl) { + cyrus_acl_masktostr(useracl, rights); + r = mboxlist_setacl(namespace, mailboxname, userid, rights, + 1, userid, authstate); + } + if (!r && anyoneacl) { + cyrus_acl_masktostr(anyoneacl, rights); + r = mboxlist_setacl(namespace, mailboxname, "anyone", rights, + 1, userid, authstate); + } + + if (r) syslog(LOG_ERR, "IOERROR: failed to create %s (%s)", + mailboxname, error_message(r)); + return r; +} + +EXPORTED unsigned long config_types_to_caldav_types(void) +{ + unsigned long config_types = + config_getbitfield(IMAPOPT_CALENDAR_COMPONENT_SET); + unsigned long types = 0; + + if (config_types & IMAP_ENUM_CALENDAR_COMPONENT_SET_VEVENT) + types |= CAL_COMP_VEVENT; + if (config_types & IMAP_ENUM_CALENDAR_COMPONENT_SET_VTODO) + types |= CAL_COMP_VTODO; + if (config_types & IMAP_ENUM_CALENDAR_COMPONENT_SET_VJOURNAL) + types |= CAL_COMP_VJOURNAL; + if (config_types & IMAP_ENUM_CALENDAR_COMPONENT_SET_VFREEBUSY) + types |= CAL_COMP_VFREEBUSY; + if (config_types & IMAP_ENUM_CALENDAR_COMPONENT_SET_VAVAILABILITY) + types |= CAL_COMP_VAVAILABILITY; +#ifdef VPOLL + if (config_types & IMAP_ENUM_CALENDAR_COMPONENT_SET_VPOLL) + types |= CAL_COMP_VPOLL; +#endif + + return types; +} + +EXPORTED int caldav_create_defaultcalendars(const char *userid, + const struct namespace *namespace, + const struct auth_state *authstate, + mbentry_t **mbentryp) +{ + int r; + char *mailboxname; + struct mboxlock *namespacelock = NULL; + + /* calendar-home-set */ + mailboxname = caldav_mboxname(userid, NULL); + r = mboxlist_lookup(mailboxname, NULL, NULL); + if (r == IMAP_MAILBOX_NONEXISTENT) { + /* Find location of INBOX */ + char *inboxname = mboxname_user_mbox(userid, NULL); + mbentry_t *mbentry = NULL; + + r = proxy_mlookup(inboxname, &mbentry, NULL, NULL); + free(inboxname); + + if (!r) { + if (mbentry->server) { + r = IMAP_MAILBOX_NONEXISTENT; + + if (mbentryp) { + *mbentryp = mbentry; + mbentry = NULL; + } + } + else { + r = _create_mailbox(userid, mailboxname, MBTYPE_CALENDAR, 0, + ACL_ALL | DACL_READFB, DACL_READFB, NULL, + namespace, authstate, 0, &namespacelock); + } + } + else if (r == IMAP_MAILBOX_NONEXISTENT) { + r = IMAP_INVALID_USER; + } + + mboxlist_entry_free(&mbentry); + } + + free(mailboxname); + if (r) goto done; + + if (config_getswitch(IMAPOPT_CALDAV_CREATE_DEFAULT)) { + /* Default calendar */ + unsigned long comp_types = config_types_to_caldav_types(); + + mailboxname = caldav_mboxname(userid, SCHED_DEFAULT); + r = _create_mailbox(userid, mailboxname, MBTYPE_CALENDAR, comp_types, + ACL_ALL | DACL_READFB, DACL_READFB, + config_getstring(IMAPOPT_CALENDAR_DEFAULT_DISPLAYNAME), + namespace, authstate, 1, &namespacelock); + free(mailboxname); + if (r) goto done; + } + + if (config_getswitch(IMAPOPT_CALDAV_CREATE_SCHED) && + namespace_calendar.allow & ALLOW_CAL_SCHED) { + /* Scheduling Inbox */ + mailboxname = caldav_mboxname(userid, SCHED_INBOX); + r = _create_mailbox(userid, mailboxname, MBTYPE_CALENDAR, 0, + ACL_ALL | DACL_SCHED, DACL_SCHED, NULL, + namespace, authstate, 0, &namespacelock); + free(mailboxname); + if (r) goto done; + + /* Scheduling Outbox */ + mailboxname = caldav_mboxname(userid, SCHED_OUTBOX); + r = _create_mailbox(userid, mailboxname, MBTYPE_CALENDAR, 0, + ACL_ALL | DACL_SCHED, 0, NULL, + namespace, authstate, 0, &namespacelock); + free(mailboxname); + if (r) goto done; + } + + if (config_getswitch(IMAPOPT_CALDAV_CREATE_ATTACH) && + namespace_calendar.allow & ALLOW_CAL_ATTACH) { + /* Managed Attachment Collection */ + mailboxname = caldav_mboxname(userid, MANAGED_ATTACH); + r = _create_mailbox(userid, mailboxname, MBTYPE_COLLECTION, 0, + ACL_ALL, ACL_READ, NULL, + namespace, authstate, 0, &namespacelock); + free(mailboxname); + if (r) goto done; + } + + done: + if (namespacelock) mboxname_release(&namespacelock); + return r; +} + +struct bumpdefaultalarms_data { + bitvector_t bump; + bitvector_t shared; +}; + +static int bumpdefaultalarms_cb(void *rock, struct caldav_data *cdata) +{ + struct bumpdefaultalarms_data *data = rock; + + if (cdata->comp_flags.defaultalerts) { + /* Definitely bump this record */ + bv_set(&data->bump, cdata->dav.imap_uid); + } + if (cdata->comp_flags.shared) { + /* Inspect per-user data later */ + bv_set(&data->shared, cdata->dav.imap_uid); + } + + return 0; +} + +static int caldav_bump_defaultalarms_mailbox(struct mailbox *mailbox) +{ + struct mailbox_iter *iter = NULL; + struct caldav_db *db = NULL; + int r = 0; + mbentry_t *mbentry = NULL; + + struct bumpdefaultalarms_data data = { BV_INITIALIZER, BV_INITIALIZER }; + + /* Gather record uids of events with default alerts */ + db = caldav_open_mailbox(mailbox); + if (!db) { + syslog(LOG_ERR, "%s: can't open caldav.db for %s", + __func__, mailbox_name(mailbox)); + r = HTTP_SERVER_ERROR; + goto done; + } + + r = mboxlist_lookup_by_uniqueid(mailbox_uniqueid(mailbox), &mbentry, NULL); + if (r) { + syslog(LOG_ERR, "%s: failed to lookup mbentry %s: %s", + __func__, mailbox_uniqueid(mailbox), error_message(r)); + r = HTTP_SERVER_ERROR; + goto done; + } + r = caldav_foreach(db, mbentry, bumpdefaultalarms_cb, &data); + if (r) { + syslog(LOG_ERR, "%s: failed to iterate caldav.db %s: %s", + __func__, mailbox_name(mailbox), error_message(r)); + r = HTTP_SERVER_ERROR; + goto done; + } + if ((bv_first_set(&data.bump) < 0) && (bv_first_set(&data.shared) < 0)) { + goto done; + } + + /* Bump modseqs of calendar event records */ + iter = mailbox_iter_init(mailbox, 0, ITER_SKIP_EXPUNGED); + if (!iter) { + syslog(LOG_ERR, "%s: can't open mailbox iterator for %s", + __func__, mailbox_name(mailbox)); + r = HTTP_SERVER_ERROR; + goto done; + } + + /* Need to switch to message scope */ + if (mailbox->annot_state) { + int annot_scope = annotate_state_scope(mailbox->annot_state); + if (annot_scope != ANNOTATION_SCOPE_MESSAGE) { + annotate_state_commit(&mailbox->annot_state); + } + } + + /* Iterate records */ + const message_t *msg; + struct buf userdata = BUF_INITIALIZER; + struct dlist *dl = NULL; // parsed user data + + while ((msg = mailbox_iter_step(iter))) { + const struct index_record *record = msg_record(msg); + struct index_record copyrecord = *record; + buf_reset(&userdata); + dlist_free(&dl); + + if (!bv_isset(&data.bump, record->uid) && bv_isset(&data.shared, record->uid)) { + /* Check per-user data */ + mailbox_annotation_lookup(mailbox, record->uid, PER_USER_CAL_DATA, + httpd_userid, &userdata); + if (buf_len(&userdata)) { + /* Parse the userdata and fetch the validators */ + dlist_parsemap(&dl, 1, 0, buf_base(&userdata), buf_len(&userdata)); + if (caldav_get_usedefaultalerts(dl, mailbox, record, NULL)) { + bv_set(&data.bump, record->uid); + } + } + } + + if (!bv_isset(&data.bump, record->uid)) { + continue; + } + + /* Bump record */ + r = mailbox_rewrite_index_record(mailbox, ©record); + if (r) { + syslog(LOG_ERR, "%s: rewrite index record %s:%d: %s", + __func__, mailbox_name(mailbox), record->uid, error_message(r)); + continue; + } + if (dl) { + /* Update modseq in the per-user data */ + dlist_updatedate(dl, "LASTMOD", copyrecord.last_updated); + dlist_updatenum64(dl, "MODSEQ", copyrecord.modseq); + buf_reset(&userdata); + dlist_printbuf(dl, 1, &userdata); + r = annotate_state_write(mailbox->annot_state, PER_USER_CAL_DATA, + httpd_userid, &userdata); + if (r) { + syslog(LOG_ERR, "%s: can't update per-user modseq for record %s:%d: %s", + __func__, mailbox_name(mailbox), record->uid, error_message(r)); + continue; + } + } + r = caldav_alarm_touch_record(mailbox, record, /*force*/1); + if (r) { + syslog(LOG_ERR, "%s: touch alarms for index record %s:%d: %s", + __func__, mailbox_name(mailbox), record->uid, error_message(r)); + continue; + } + } + buf_free(&userdata); + dlist_free(&dl); + +done: + bv_fini(&data.shared); + bv_fini(&data.bump); + mailbox_iter_done(&iter); + mboxlist_entry_free(&mbentry); + caldav_close(db); + return r; +} + +static int caldav_bump_defaultalarms_calhome_cb(const mbentry_t *mbentry, + void *rock __attribute__((unused))) +{ + struct mailbox *mailbox = NULL; + int r = mailbox_open_iwl(mbentry->name, &mailbox); + + if (r) { + xsyslog(LOG_ERR, "mailbox_open_iwl", + "mboxname=<%s> uniqueid=<%s> err=<%s>", + mbentry->name, mbentry->uniqueid, error_message(r)); + return r; + } + + // TODO could avoid bumping the modseq for these calendars, + // where both the default alarms with and without time are + // overriden for this particular calendar mailbox + + r = caldav_bump_defaultalarms_mailbox(mailbox); + mailbox_close(&mailbox); + return r; +} + +EXPORTED int caldav_bump_defaultalarms(struct mailbox *mailbox) +{ + mbname_t *mbname = mbname_from_intname(mailbox_name(mailbox)); + const strarray_t *boxes = mbname_boxes(mbname); + const char *prefix = config_getstring(IMAPOPT_CALENDARPREFIX); + int r = 0; + + if (strarray_size(boxes) == 1 && + !strcmpsafe(prefix, strarray_nth(boxes, 0))) { + // Bump alerts in all calendars. + r = mboxlist_mboxtree(mailbox_name(mailbox), + caldav_bump_defaultalarms_calhome_cb, + NULL, MBOXTREE_SKIP_ROOT); + } + else { + // Bump alerts in this calendar. + r = caldav_bump_defaultalarms_mailbox(mailbox); + } + + mbname_free(&mbname); + return r; +} + +EXPORTED int caldav_get_usedefaultalerts(struct dlist *dl, + struct mailbox *mailbox, + const struct index_record *record, + icalcomponent **icalp) +{ + /* Read from annotation */ + if (dl) { + const char *val = NULL; + if (dlist_getatom(dl, "USEDEFAULTALERTS", &val)) { + return !strcasecmp(val, "YES"); + } + if (dlist_getatom(dl, "VPATCH", &val)) { + icalcomponent *vpatch = icalparser_parse_string(val); + if (vpatch) { + int ret = icalcomponent_get_usedefaultalerts(vpatch); + icalcomponent_free(vpatch); + if (ret >= 0) return ret; + } + } + } + + /* Read from client-supplied iCalendar data */ + if (icalp && *icalp) { + int ret = icalcomponent_get_usedefaultalerts(*icalp); + if (ret >= 0) return ret; + } + + /* Read from record */ + if (!mailbox || !record) return 0; + + icalcomponent *myical = record_to_ical(mailbox, record, NULL); + int ret = 0; + + if (dl) icalcomponent_add_personal_data_from_dl(myical, dl); + ret = icalcomponent_get_usedefaultalerts(myical); + if (icalp) { + *icalp = myical; + } + else icalcomponent_free(myical); + + return ret >= 0 ? ret : 0; +} + + +HIDDEN void caldav_attachment_url(struct buf *buf, + const char *userid, + const char *baseurl, + const char *managedid) +{ + buf_setcstr(buf, baseurl); + if (!buf->len) return; + + if (buf->s[buf->len-1] == '/') + buf_truncate(buf, -1); + + buf_printf(buf, "%s/%s/%s/%s%s", + namespace_calendar.prefix, + USER_COLLECTION_PREFIX, + userid, MANAGED_ATTACH, managedid); +} + +#ifdef WITH_JMAP + +struct copy_defaultalarms_rock { + struct defaultalarms defalarms; + const char *userid; +}; + +static int copy_defaultalarms_cb(const mbentry_t *mbentry, void *vrock) +{ + struct copy_defaultalarms_rock *rock = vrock; + + if (mbtype_isa(mbentry->mbtype) != MBTYPE_CALENDAR) + return 0; + + mbname_t *mbname = mbname_from_intname(mbentry->name); + const char *collname = strarray_nth(mbname_boxes(mbname), 0); + + if (!strncmp(collname, SCHED_INBOX, strlen(SCHED_INBOX)-1) || + !strncmp(collname, SCHED_OUTBOX, strlen(SCHED_OUTBOX)-1) || + !strncmp(collname, MANAGED_ATTACH, strlen(MANAGED_ATTACH)-1)) { + mbname_free(&mbname); + return 0; + } + mbname_free(&mbname); + + defaultalarms_load(mbentry->name, rock->userid, &rock->defalarms); + + return (rock->defalarms.with_time.ical || rock->defalarms.with_date.ical) ? + CYRUSDB_DONE : 0; +} + +HIDDEN int caldav_init_jmapcalendar(const char *userid, struct mailbox *mailbox) +{ + struct copy_defaultalarms_rock rock = { + DEFAULTALARMS_INITIALIZER, userid + }; + + // Attempt to copy default alerts from scheduling default + int r = 0; + char *defaultcoll = caldav_scheddefault(userid, 1); + if (defaultcoll) { + char *mboxname = caldav_mboxname(userid, defaultcoll); + mbentry_t *mbentry; + if (!mboxlist_lookup(mboxname, &mbentry, NULL)) { + r = copy_defaultalarms_cb(mbentry, &rock); + mboxlist_entry_free(&mbentry); + } + free(mboxname); + free(defaultcoll); + } + + if (r != CYRUSDB_DONE) { + // Copy from any calendar that has default alerts + char *calhomename = caldav_mboxname(userid, NULL); + r = mboxlist_mboxtree(calhomename, copy_defaultalarms_cb, + &rock, MBOXTREE_SKIP_ROOT); + free(calhomename); + } + + // Always write default alerts, even if there are none + r = defaultalarms_save(mailbox, userid, + rock.defalarms.with_time.ical, + rock.defalarms.with_date.ical); + if (r) { + xsyslog(LOG_WARNING, "failed to write default alarms", + "mboxname=<%s> err=<%s>", + mailbox_name(mailbox), cyrusdb_strerror(r)); + r = 0; + } + + defaultalarms_fini(&rock.defalarms); + + return r; +} + +#endif /* WITH_JMAP */ + +EXPORTED icaltimetype caldav_get_historical_cutoff() +{ + int age = config_getduration(IMAPOPT_CALDAV_HISTORICAL_AGE, 'd'); + icaltimetype cutoff; + + if (age < 0) return icaltime_null_time(); + + /* Set cutoff to current time -age days */ + cutoff = icaltime_current_time_with_zone(icaltimezone_get_utc_timezone()); + icaltime_adjust(&cutoff, 0, 0, 0, -age); + + return cutoff; +} diff --git a/imap/caldav_util.h b/imap/caldav_util.h new file mode 100644 index 0000000000..d95cf1e0f0 --- /dev/null +++ b/imap/caldav_util.h @@ -0,0 +1,155 @@ +/* caldav_util.h -- utility functions for dealing with CALDAV database + * + * Copyright (c) 1994-2021 Carnegie Mellon University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#ifndef CALDAV_UTIL_H +#define CALDAV_UTIL_H + +#include + +#include "caldav_db.h" +#include "hash.h" +#include "mailbox.h" +#include "strarray.h" + + +#define NEW_STAG (1<<8) /* Make sure we skip over PREFER bits */ +#define TZ_STRIP (1<<9) +#define PERMS_NOKEEP (1<<10) /* Do not keep JMAP permissions during event updates */ + +#define SHARED_MODSEQ \ + DAV_ANNOT_NS "<" XML_NS_CYRUS ">shared-modseq" + + +extern void replace_tzid_aliases(icalcomponent *ical, + struct hash_table *tzid_table); + +extern void strip_vtimezones(icalcomponent *ical); + +extern int caldav_is_personalized(struct mailbox *mailbox, + const struct caldav_data *cdata, + const char *userid, + struct buf *userdata); + +extern icalcomponent *caldav_record_to_ical(struct mailbox *mailbox, + const struct caldav_data *cdata, + const char *userid, + strarray_t *schedule_addresses); + +extern int caldav_get_validators(struct mailbox *mailbox, void *data, + const char *userid, struct index_record *record, + const char **etag, time_t *lastmod); + +typedef struct transaction_t txn_t; // defined in httpd.h +extern int caldav_store_resource(struct transaction_t *txn, icalcomponent *ical, + struct mailbox *mailbox, const char *resource, + modseq_t createdmodseq, struct caldav_db *caldavdb, + unsigned flags, const char *userid, + const strarray_t *add_imapflags, + const strarray_t *del_imapflags, + const strarray_t *schedule_addresses); + +/* Create the calendar home, default calendars and scheduling + * boxes for userid, if they don't already exist. */ +extern unsigned long config_types_to_caldav_types(void); +extern int caldav_create_defaultcalendars(const char *userid, + const struct namespace *namespace, + const struct auth_state *authstate, + mbentry_t **mbentryp); + +extern int caldav_is_secretarymode(const char *mboxname); + +extern void caldav_attachment_url(struct buf *buf, const char *userid, + const char *baseurl, const char *managedid); + +/* Update refcounts for managed attachments owned by userid. + * For updated events, both ical and oldical must be non-null. + * for deleted events, ical must be null. + * Returns HTTP_NOT_FOUND for any invalid managed id, or some + * other HTTP error on internal error. */ +extern int caldav_manage_attachments(const char *userid, + icalcomponent *ical, + icalcomponent *oldical); + +enum caldav_rewrite_attachments_mode { + caldav_attachments_to_binary, + caldav_attachments_to_url +}; + +// implemented in http_caldav_sched.c +extern void caldav_rewrite_attachments(const char *userid, + enum caldav_rewrite_attachments_mode mode, + icalcomponent *oldical, + icalcomponent *newical, + icalcomponent **myoldicalp, + icalcomponent **mynewicalp); + +#define CALDAV_REWRITE_ATTACHPROP_TO_URL_NBUFS 2 +extern void caldav_rewrite_attachprop_to_url(struct webdav_db *webdavdb, + icalproperty *prop, + struct buf *baseurl, + struct buf *bufs); + +/* Bump the modseq of all records in mailbox that contain iCalendar + * components with enabled default alarms. Also forces calalarmd to + * recalculate the alarms for these records. + * + * Side-effect warning: if the mailbox has an open annotation state + * that isn't scoped to SCOPE_MESSAGE, then the state is committed + * and rescoped to messages. */ +extern int caldav_bump_defaultalarms(struct mailbox *mailbox); + +extern int caldav_get_usedefaultalerts(struct dlist *dl, + struct mailbox *mailbox, + const struct index_record *record, + icalcomponent **icalp); + + +extern int caldav_is_secretarymode(const char *mboxname); + +#ifdef WITH_JMAP +extern int caldav_init_jmapcalendar(const char *userid, struct mailbox *mailbox); +#endif + +extern icaltimetype caldav_get_historical_cutoff(); + +#endif /* HTTP_CALDAV_H */ diff --git a/imap/calsched_support.c b/imap/calsched_support.c new file mode 100644 index 0000000000..5b733595f3 --- /dev/null +++ b/imap/calsched_support.c @@ -0,0 +1,185 @@ +/* calsched_support.c -- utility functions for dealing with calendar scheduling + * + * Copyright (c) 1994-2022 Carnegie Mellon University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include + +#include +#include + +#include "calsched_support.h" +#include "http_dav.h" +#include "mailbox.h" +#include "strarray.h" +#include "syslog.h" +#include "util.h" + +/* generated headers are not necessarily in current directory */ +#include "imap/http_err.h" +#include "imap/imap_err.h" + + +EXPORTED int caldav_caluseraddr_read(const char *mboxname, + const char *userid, + struct caldav_caluseraddr *addr) +{ + static const char *annot = + DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-user-address-set"; + + struct buf buf = BUF_INITIALIZER; + + int r = annotatemore_lookupmask(mboxname, annot, userid, &buf); + if (r || !buf.len) { + buf_free(&buf); + return r ? r : IMAP_NOTFOUND; + } + + size_t len = buf_len(&buf); + char *val = buf_release(&buf); + char *sep = val; + long lpref = strtol(val, &sep, 10); + if (sep != val && *sep == ';') { + // splitm frees the buffer, so make the string + // value start without the 'pref' field + size_t i, j; + for (i = sep + 1 - val, j = 0; i < len; i++, j++) { + val[j] = val[i]; + } + val[j] = '\0'; + } + else lpref = INT_MAX; + + strarray_fini(&addr->uris); + strarray_splitm(&addr->uris, val, ",", STRARRAY_TRIM); + + if (lpref < 0 || lpref > strarray_size(&addr->uris)) + addr->pref = strarray_size(&addr->uris); + else + addr->pref = (int)lpref; + + return 0; +} + +EXPORTED int caldav_caluseraddr_write(struct mailbox *mbox, + const char *userid, + const struct caldav_caluseraddr *addr) +{ + static const char *annot = + DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-user-address-set"; + + annotate_state_t *astate = NULL; + struct buf buf = BUF_INITIALIZER; + + int r = mailbox_get_annotate_state(mbox, 0, &astate); + if (r) goto done; + + if (strarray_size(&addr->uris)) { + // format: (";")?addrs[0](","addrs[1..n-1])* + buf_printf(&buf, "%d;", addr->pref); + int i; + for (i = 0; i < strarray_size(&addr->uris); i++) { + if (i) buf_putc(&buf, ','); + buf_appendcstr(&buf, strarray_nth(&addr->uris, i)); + } + } + + r = annotate_state_writemask(astate, annot, userid, &buf); + +done: + buf_free(&buf); + return r; +} + +EXPORTED void caldav_caluseraddr_fini(struct caldav_caluseraddr *addr) +{ + if (addr) { + strarray_fini(&addr->uris); + addr->pref = 0; + } +} + +EXPORTED void get_schedule_addresses(const char *mboxname, + const char *userid, strarray_t *addresses) +{ + struct buf buf = BUF_INITIALIZER; + + /* find schedule address based on the destination calendar's user */ + struct caldav_caluseraddr caluseraddr = CALDAV_CALUSERADDR_INITIALIZER; + + /* check calendar-user-address-set for target user's mailbox */ + int r = caldav_caluseraddr_read(mboxname, userid, &caluseraddr); + if (r) { + char *calhome = caldav_mboxname(userid, NULL); + r = caldav_caluseraddr_read(calhome, userid, &caluseraddr); + free(calhome); + } + + if (!r && strarray_size(&caluseraddr.uris)) { + int i; + for (i = 0; i < strarray_size(&caluseraddr.uris); i++) { + const char *item = strarray_nth(&caluseraddr.uris, i); + if (!strncasecmp(item, "mailto:", 7)) item += 7; + strarray_add(addresses, item); + } + } + else if (strchr(userid, '@')) { + /* userid corresponding to target */ + strarray_add(addresses, userid); + } + else { + /* append fully qualified userids */ + int i; + + for (i = 0; i < strarray_size(&config_cua_domains); i++) { + const char *domain = strarray_nth(&config_cua_domains, i); + + buf_reset(&buf); + buf_printf(&buf, "%s@%s", userid, domain); + + strarray_add(addresses, buf_cstring(&buf)); + } + } + + caldav_caluseraddr_fini(&caluseraddr); + + buf_free(&buf); +} diff --git a/imap/http_caldav.h b/imap/calsched_support.h similarity index 63% rename from imap/http_caldav.h rename to imap/calsched_support.h index 3389d48ef9..23603903d7 100644 --- a/imap/http_caldav.h +++ b/imap/calsched_support.h @@ -1,6 +1,6 @@ -/* http_caldav.h -- Routines for dealing with CALDAV in httpd +/* calsched_support.h -- utility functions for dealing with calendar scheduling * - * Copyright (c) 1994-2015 Carnegie Mellon University. All rights reserved. + * Copyright (c) 1994-2022 Carnegie Mellon University. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions @@ -41,22 +41,32 @@ * */ -#ifndef HTTP_CALDAV_H -#define HTTP_CALDAV_H +#ifndef SCHED_UTIL_H +#define SCHED_UTIL_H -/* Create the calendar home, default calendars and scheduling - * boxes for userid, if they don't already exist. */ -extern int caldav_create_defaultcalendars(const char *userid); +#include -extern int caldav_store_resource(struct transaction_t *txn, icalcomponent *ical, - struct mailbox *mailbox, const char *resource, - modseq_t createdmodseq, - struct caldav_db *caldavdb, unsigned flags, - const char *userid, const char *schedule_userid); +#include "mailbox.h" +#include "strarray.h" -extern icalcomponent *caldav_record_to_ical(struct mailbox *mailbox, - const struct caldav_data *cdata, - const char *userid, - char **schedule_userid); +#define CALDAV_CALUSERADDR_INITIALIZER { STRARRAY_INITIALIZER, 0 } -#endif /* HTTP_CALDAV_H */ +struct caldav_caluseraddr { + strarray_t uris; + int pref; +}; + +extern int caldav_caluseraddr_read(const char *mboxname, + const char *userid, + struct caldav_caluseraddr *addrs); + +extern int caldav_caluseraddr_write(struct mailbox *mbox, + const char *userid, + const struct caldav_caluseraddr *addrs); + +extern void caldav_caluseraddr_fini(struct caldav_caluseraddr *addr); + +extern void get_schedule_addresses(const char *mboxname, + const char *userid, strarray_t *addresses); + +#endif /* SCHED_UTIL_H */ diff --git a/imap/carddav_db.c b/imap/carddav_db.c index aee54ffd63..78dd5d6d3f 100644 --- a/imap/carddav_db.c +++ b/imap/carddav_db.c @@ -58,6 +58,10 @@ #include "xstrlcat.h" #include "xmalloc.h" +/* generated headers are not necessarily in current directory */ +#include "imap/http_err.h" +#include "imap/imap_err.h" + #define NUM_BUFS 10 @@ -114,7 +118,7 @@ EXPORTED struct carddav_db *carddav_open_userid(const char *userid) EXPORTED struct carddav_db *carddav_open_mailbox(struct mailbox *mailbox) { struct carddav_db *carddavdb = NULL; - char *userid = mboxname_to_userid(mailbox->name); + char *userid = mboxname_to_userid(mailbox_name(mailbox)); init_internal(); @@ -133,6 +137,12 @@ EXPORTED struct carddav_db *carddav_open_mailbox(struct mailbox *mailbox) return carddavdb; } +EXPORTED int carddav_set_otheruser(struct carddav_db *carddavdb, const char *userid) +{ + sqldb_detach(carddavdb->db); // remove any current + return dav_attach_userid(carddavdb->db, userid); +} + /* Close DAV DB */ EXPORTED int carddav_close(struct carddav_db *carddavdb) @@ -145,7 +155,7 @@ EXPORTED int carddav_close(struct carddav_db *carddavdb) buf_free(&carddavdb->bufs[i]); } - r = sqldb_close(&carddavdb->db); + r = dav_close(&carddavdb->db); free(carddavdb->userid); free(carddavdb); @@ -190,9 +200,17 @@ static const char *column_text_to_buf(const char *text, struct buf *buf) "SELECT rowid, creationdate, mailbox, resource, imap_uid," \ " lock_token, lock_owner, lock_ownerid, lock_expire," \ " version, vcard_uid, kind, fullname, name, nickname, alive," \ - " modseq, createdmodseq" \ + " modseq, createdmodseq, NULL, NULL" \ " FROM vcard_objs" +#define CMD_GETFIELDS_JSCARD \ + "SELECT vcard_objs.rowid, creationdate, mailbox, resource, imap_uid," \ + " lock_token, lock_owner, lock_ownerid, lock_expire," \ + " version, vcard_uid, kind, fullname, name, nickname, alive," \ + " modseq, createdmodseq, jmapversion, jmapdata" \ + " FROM vcard_objs LEFT JOIN jscard_cache" \ + " ON (vcard_objs.rowid = jscard_cache.rowid AND jscard_cache.userid = :userid)" + static int read_cb(sqlite3_stmt *stmt, void *rock) { struct read_rock *rrock = (struct read_rock *) rock; @@ -202,6 +220,7 @@ static int read_cb(sqlite3_stmt *stmt, void *rock) memset(cdata, 0, sizeof(struct carddav_data)); + cdata->dav.mailbox_byname = (db->db->version < DB_MBOXID_VERSION); cdata->dav.alive = sqlite3_column_int(stmt, 15); cdata->dav.modseq = sqlite3_column_int64(stmt, 16); cdata->dav.createdmodseq = sqlite3_column_int64(stmt, 17); @@ -212,8 +231,14 @@ static int read_cb(sqlite3_stmt *stmt, void *rock) cdata->dav.creationdate = sqlite3_column_int(stmt, 1); cdata->dav.imap_uid = sqlite3_column_int(stmt, 4); cdata->dav.lock_expire = sqlite3_column_int(stmt, 8); - cdata->version = sqlite3_column_int(stmt, 9); cdata->kind = sqlite3_column_int(stmt, 11); + cdata->jmapversion = sqlite3_column_int(stmt, 18); + + cdata->version = sqlite3_column_int(stmt, 9); + if (cdata->version == 0) { + /* Default to v3 for records stored before we actually set the version */ + cdata->version = 3; + } if (rrock->cb) { /* We can use the column data directly for the callback */ @@ -226,6 +251,7 @@ static int read_cb(sqlite3_stmt *stmt, void *rock) cdata->fullname = (const char *) sqlite3_column_text(stmt, 12); cdata->name = (const char *) sqlite3_column_text(stmt, 13); cdata->nickname = (const char *) sqlite3_column_text(stmt, 14); + cdata->jmapdata = (const char *) sqlite3_column_text(stmt, 19); r = rrock->cb(rrock->rock, cdata); } else { @@ -259,6 +285,9 @@ static int read_cb(sqlite3_stmt *stmt, void *rock) cdata->nickname = column_text_to_buf((const char *) sqlite3_column_text(stmt, 14), &db->bufs[8]); + cdata->jmapdata = + column_text_to_buf((const char *) sqlite3_column_text(stmt, 19), + &db->bufs[9]); } return r; @@ -268,10 +297,12 @@ static int read_cb(sqlite3_stmt *stmt, void *rock) " WHERE mailbox = :mailbox AND resource = :resource;" EXPORTED int carddav_lookup_resource(struct carddav_db *carddavdb, - const char *mailbox, const char *resource, + const mbentry_t *mbentry, const char *resource, struct carddav_data **result, int tombstones) { + const char *mailbox = (carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; struct sqldb_bindval bval[] = { { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { ":resource", SQLITE_TEXT, { .s = resource } }, @@ -286,6 +317,7 @@ EXPORTED int carddav_lookup_resource(struct carddav_db *carddavdb, if (!r && !cdata.dav.rowid) r = CYRUSDB_NOTFOUND; /* always mailbox and resource so error paths don't fail */ + cdata.dav.mailbox_byname = (carddavdb->db->version < DB_MBOXID_VERSION); cdata.dav.mailbox = mailbox; cdata.dav.resource = resource; @@ -297,10 +329,12 @@ EXPORTED int carddav_lookup_resource(struct carddav_db *carddavdb, " WHERE mailbox = :mailbox AND imap_uid = :imap_uid;" EXPORTED int carddav_lookup_imapuid(struct carddav_db *carddavdb, - const char *mailbox, int imap_uid, + const mbentry_t *mbentry, int imap_uid, struct carddav_data **result, int tombstones) { + const char *mailbox = (carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; struct sqldb_bindval bval[] = { { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { ":imap_uid", SQLITE_INTEGER, { .i = imap_uid } }, @@ -344,26 +378,75 @@ EXPORTED int carddav_lookup_uid(struct carddav_db *carddavdb, const char *vcard_ #define CMD_SELMBOX CMD_GETFIELDS \ - " WHERE mailbox = :mailbox AND alive = 1 ORDER BY modseq DESC;" + " WHERE mailbox = :mailbox AND alive = 1" #define CMD_SELALIVE CMD_GETFIELDS \ - " WHERE alive = 1 ORDER BY modseq DESC;" + " WHERE alive = 1" + +#define CMD_DEFAULT_ORDER " ORDER BY modseq DESC;" -EXPORTED int carddav_foreach(struct carddav_db *carddavdb, const char *mailbox, - int (*cb)(void *rock, struct carddav_data *data), - void *rock) +EXPORTED int carddav_foreach(struct carddav_db *carddavdb, + const mbentry_t *mbentry, + int (*cb)(void *rock, struct carddav_data *data), + void *rock) { + return carddav_foreach_sort(carddavdb, mbentry, NULL, 0, cb, rock); +} + +EXPORTED int carddav_foreach_sort(struct carddav_db *carddavdb, + const mbentry_t *mbentry, + enum carddav_sort* sort, size_t nsort, + int (*cb)(void *rock, struct carddav_data *data), + void *rock) +{ + const char *mailbox = !mbentry ? NULL : + ((carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name); struct sqldb_bindval bval[] = { { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { NULL, SQLITE_NULL, { .s = NULL } } }; struct carddav_data cdata; struct read_rock rrock = { carddavdb, &cdata, 0, cb, rock }; - if (mailbox) { - return sqldb_exec(carddavdb->db, CMD_SELMBOX, bval, &read_cb, &rrock); - } else { - return sqldb_exec(carddavdb->db, CMD_SELALIVE, bval, &read_cb, &rrock); + if (!nsort) { + if (mailbox) { + return sqldb_exec(carddavdb->db, CMD_SELMBOX CMD_DEFAULT_ORDER, + bval, &read_cb, &rrock); + } else { + return sqldb_exec(carddavdb->db, CMD_SELALIVE CMD_DEFAULT_ORDER, + bval, &read_cb, &rrock); + } + } + + struct buf stmt = BUF_INITIALIZER; + buf_setcstr(&stmt, mailbox ? CMD_SELMBOX : CMD_SELALIVE); + buf_appendcstr(&stmt, " ORDER BY"); + size_t i; + for (i = 0; i < nsort; i++) { + const char *column = NULL; + switch (sort[i] & ~CARD_SORT_DESC) { + case CARD_SORT_MODSEQ: + column = "modseq"; + break; + case CARD_SORT_UID: + column = "vcard_uid"; + break; + case CARD_SORT_FULLNAME: + column = "fullname"; + break; + default: + continue; + } + if (i) buf_putc(&stmt, ','); + buf_putc(&stmt, ' '); + buf_appendcstr(&stmt, column); + buf_appendcstr(&stmt, sort[i] & CARD_SORT_DESC ? " DESC" : " ASC"); } + buf_putc(&stmt, ';'); + + int r = sqldb_exec(carddavdb->db, buf_cstring(&stmt), bval, &read_cb, &rrock); + buf_free(&stmt); + return r; } #define CMD_GETUID_GROUPS \ @@ -489,13 +572,17 @@ static int details_cb(sqlite3_stmt *stmt, void *rock) return 0; } -EXPORTED strarray_t *carddav_getemail2details(struct carddav_db *carddavdb, const char *email, - const char *mboxname, int *ispinned) +EXPORTED strarray_t *carddav_getemail2details(struct carddav_db *carddavdb, + const char *email, + const mbentry_t *mbentry, + int *ispinned) { + const char *mailbox = (carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; struct sqldb_bindval bval[] = { - { ":email", SQLITE_TEXT, { .s = email } }, - { ":mailbox", SQLITE_TEXT, { .s = mboxname } }, - { NULL, SQLITE_NULL, { .s = NULL } } + { ":email", SQLITE_TEXT, { .s = email } }, + { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; struct detailsdata data = { strarray_new(), 0 }; @@ -508,14 +595,18 @@ EXPORTED strarray_t *carddav_getemail2details(struct carddav_db *carddavdb, cons return data.uids; } -EXPORTED strarray_t *carddav_getuid2groups(struct carddav_db *carddavdb, const char *member_uid, - const char *mboxname, const char *otheruser) +EXPORTED strarray_t *carddav_getuid2groups(struct carddav_db *carddavdb, + const char *member_uid, + const mbentry_t *mbentry, + const char *otheruser) { + const char *mailbox = (carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; struct sqldb_bindval bval[] = { { ":member_uid", SQLITE_TEXT, { .s = member_uid } }, - { ":mailbox", SQLITE_TEXT, { .s = mboxname } }, - { ":otheruser", SQLITE_TEXT, { .s = otheruser } }, - { NULL, SQLITE_NULL, { .s = NULL } } + { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, + { ":otheruser", SQLITE_TEXT, { .s = otheruser } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; strarray_t *groups = strarray_new(); @@ -524,18 +615,67 @@ EXPORTED strarray_t *carddav_getuid2groups(struct carddav_db *carddavdb, const c return groups; } +/* FUNCTIONS FOR LOOKING UP headerAllContacts */ + +#define GETALL_LOCAL \ + "SELECT DISTINCT E.email FROM vcard_emails E" \ + " JOIN vcard_objs CO" \ + " WHERE E.objid = CO.rowid AND CO.alive = 1" \ + " AND (:mailbox IS NULL OR CO.mailbox = :mailbox)" -#define CMD_GETGROUP_EXISTS \ +#define GETALL_MEMBERS GETALL_LOCAL ";" + +#define GETALL_OTHERMEMBERS \ + GETALL_LOCAL " UNION " \ + "SELECT DISTINCT E.email FROM other.vcard_emails E" \ + " JOIN other.vcard_objs CO" \ + " WHERE E.objid = CO.rowid AND CO.alive = 1" \ + " AND CO.mailbox = :othermailbox;" + +/* FUNCTIONS FOR LOOKING UP headerContactGroupId on own groups */ + +#define GETGROUP_EXISTS \ "SELECT rowid " \ " FROM vcard_objs" \ - " WHERE mailbox = :mailbox AND kind = :kind AND vcard_uid = :group AND alive = 1;" + " WHERE kind = :kind AND vcard_uid = :group AND alive = 1" \ + " AND (:mailbox IS NULL OR mailbox = :mailbox);" -#define CMD_GETGROUP_MEMBERS \ - "SELECT E.email FROM vcard_emails E" \ +#define GETGROUP_LOCAL \ + "SELECT DISTINCT E.email FROM vcard_emails E" \ " JOIN vcard_objs CO JOIN vcard_groups G JOIN vcard_objs GO" \ " WHERE E.objid = CO.rowid AND CO.vcard_uid = G.member_uid AND G.objid = GO.rowid" \ - " AND E.pos = 0 AND GO.mailbox = :mailbox AND GO.vcard_uid = :group" \ - " AND GO.alive = 1 AND CO.alive = 1;" + " AND G.otheruser = '' AND GO.vcard_uid = :group AND GO.alive = 1 AND CO.alive = 1" \ + " AND (:mailbox IS NULL OR GO.mailbox = :mailbox)" + +#define GETGROUP_MEMBERS GETGROUP_LOCAL ";" + +#define GETGROUP_OTHERMEMBERS \ + GETGROUP_LOCAL " UNION " \ + "SELECT DISTINCT E.email FROM other.vcard_emails E" \ + " JOIN other.vcard_objs CO JOIN vcard_groups G JOIN vcard_objs GO" \ + " WHERE E.objid = CO.rowid AND CO.vcard_uid = G.member_uid AND G.objid = GO.rowid" \ + " AND G.otheruser = :otheruser AND GO.vcard_uid = :group AND GO.alive = 1 AND CO.alive = 1" \ + " AND (:mailbox IS NULL OR GO.mailbox = :mailbox) AND CO.mailbox = :othermailbox;" + +/* FUNCTIONS FOR LOOKING UPO headerContactGroupId on shared groups: + * this is different than GROUP_OTHERMEMBERS in the following way, + * GROUP_OTHERMEMBERS: the group is in the user, but contains a member in the masteruser + * OTHERGROUP_MEMBERS: the group is in the masteruser, containing members in the masteruser + */ + +#define GETOTHERGROUP_EXISTS \ + "SELECT rowid " \ + " FROM other.vcard_objs" \ + " WHERE kind = :kind AND vcard_uid = :group AND alive = 1" \ + " AND mailbox = :othermailbox;" + +// need to make sure all the contacts are only in 'Shared' as well! +#define GETOTHERGROUP_MEMBERS \ + "SELECT DISTINCT E.email FROM other.vcard_emails E" \ + " JOIN other.vcard_objs CO JOIN other.vcard_groups G JOIN other.vcard_objs GO" \ + " WHERE E.objid = CO.rowid AND CO.vcard_uid = G.member_uid AND G.objid = GO.rowid" \ + " AND G.otheruser = '' AND GO.vcard_uid = :group AND GO.alive = 1 AND CO.alive = 1" \ + " AND GO.mailbox = :othermailbox AND CO.mailbox = :othermailbox;" static int groupexists_cb(sqlite3_stmt *stmt, void *rock) { @@ -548,39 +688,81 @@ static int groupexists_cb(sqlite3_stmt *stmt, void *rock) static int groupmembers_cb(sqlite3_stmt *stmt, void *rock) { strarray_t *array = (strarray_t *)rock; - strarray_add(array, (const char *)sqlite3_column_text(stmt, 0)); + strarray_append(array, (const char *)sqlite3_column_text(stmt, 0)); return 0; } -EXPORTED strarray_t *carddav_getgroup(struct carddav_db *carddavdb, const char *mailbox, const char *group) +EXPORTED strarray_t *carddav_getgroup(struct carddav_db *carddavdb, + const mbentry_t *mbentry, const char *group, + const mbentry_t *othermb) { + int r = 0; + int isshared = 0; + if (!strncmpsafe(group, "shared/", 7)) { + assert(!mbentry); // no mailbox filter on shared groups + if (!carddavdb->db->attached) return NULL; + if (!*group) return NULL; // can't just be "shared/" + isshared = 1; + group += 7; + } + + const char *mailbox = !mbentry ? NULL : + (carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; + + const char *othermailbox = !othermb ? NULL : + (carddavdb->db->version >= DB_MBOXID_VERSION) ? + othermb->uniqueid : othermb->name; + + const char *otheruser = NULL; + mbname_t *othermbname = NULL; + if (othermb) { + othermbname = mbname_from_intname(othermb->name); + otheruser = mbname_userid(othermbname); + } + struct sqldb_bindval bval[] = { - { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, - { ":group", SQLITE_TEXT, { .s = group } }, - { ":kind", SQLITE_INTEGER, { .i = CARDDAV_KIND_GROUP } }, - { NULL, SQLITE_NULL, { .s = NULL } } + { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, + { ":group", SQLITE_TEXT, { .s = group } }, + { ":kind", SQLITE_INTEGER, { .i = CARDDAV_KIND_GROUP } }, + { ":otheruser", SQLITE_TEXT, { .s = otheruser } }, + { ":othermailbox", SQLITE_TEXT, { .s = othermailbox } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; - int exists = 0; - strarray_t *members; - int r; + strarray_t *members = NULL; - r = sqldb_exec(carddavdb->db, CMD_GETGROUP_EXISTS, bval, &groupexists_cb, &exists); - if (r) { - /* XXX syslog */ - return NULL; + if (*group) { + // first check that the group exists + + const char *existsql = isshared ? GETOTHERGROUP_EXISTS : GETGROUP_EXISTS; + int exists = 0; + r = sqldb_exec(carddavdb->db, existsql, bval, &groupexists_cb, &exists); + if (r) { + /* XXX syslog */ + goto done; + } + + if (!exists) goto done; } - if (!exists) - return NULL; + // pick which filter to use! + const char *membersql = GETGROUP_MEMBERS; + if (carddavdb->db->attached) { + if (isshared) membersql = GETOTHERGROUP_MEMBERS; + else if (!*group) membersql = GETALL_OTHERMEMBERS; + else membersql = GETGROUP_OTHERMEMBERS; + } + else if (!*group) membersql = GETALL_MEMBERS; members = strarray_new(); - - r = sqldb_exec(carddavdb->db, CMD_GETGROUP_MEMBERS, bval, &groupmembers_cb, members); + r = sqldb_exec(carddavdb->db, membersql, bval, &groupmembers_cb, members); if (r) { /* XXX syslog */ } +done: + mbname_free(&othermbname); return members; } @@ -708,6 +890,7 @@ static int carddav_write_groups(struct carddav_db *carddavdb, int rowid, const s " nickname = :nickname" \ " WHERE rowid = :rowid;" +#define CMD_DELETE_JSCARDCACHE "DELETE FROM jscard_cache WHERE rowid = :rowid" EXPORTED int carddav_write(struct carddav_db *carddavdb, struct carddav_data *cdata) { @@ -746,6 +929,25 @@ EXPORTED int carddav_write(struct carddav_db *carddavdb, struct carddav_data *cd } +#define CMD_UPDATE_EMAILS \ + "UPDATE vcard_emails SET ispinned = :ispinned WHERE objid = :objid;" + +EXPORTED int carddav_update(struct carddav_db *carddavdb, + struct carddav_data *cdata, int ispinned) +{ + struct sqldb_bindval bval[] = { + { ":objid", SQLITE_INTEGER, { .i = cdata->dav.rowid } }, + { ":ispinned", SQLITE_INTEGER, { .i = ispinned } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; + + int r = carddav_write(carddavdb, cdata); + if (r) return r; + + return sqldb_exec(carddavdb->db, CMD_UPDATE_EMAILS, bval, NULL, NULL); +} + + + #define CMD_DELETE "DELETE FROM vcard_objs WHERE rowid = :rowid;" EXPORTED int carddav_delete(struct carddav_db *carddavdb, unsigned rowid) @@ -766,8 +968,11 @@ EXPORTED int carddav_delete(struct carddav_db *carddavdb, unsigned rowid) #define CMD_DELMBOX "DELETE FROM vcard_objs WHERE mailbox = :mailbox;" -EXPORTED int carddav_delmbox(struct carddav_db *carddavdb, const char *mailbox) +EXPORTED int carddav_delmbox(struct carddav_db *carddavdb, + const mbentry_t *mbentry) { + const char *mailbox = (carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name; struct sqldb_bindval bval[] = { { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { NULL, SQLITE_NULL, { .s = NULL } } }; @@ -779,16 +984,50 @@ EXPORTED int carddav_delmbox(struct carddav_db *carddavdb, const char *mailbox) return 0; } +#define CMD_DELETE_JSCARDCACHE_USER \ + "DELETE FROM jscard_cache" \ + " WHERE rowid = :rowid AND userid = :userid" + +#define CMD_INSERT_JSCARDCACHE_USER \ + "INSERT INTO jscard_cache ( rowid, userid, jmapversion, jmapdata )" \ + " VALUES ( :rowid, :userid, :jmapversion, :jmapdata );" + +EXPORTED int carddav_write_jscardcache(struct carddav_db *carddavdb, + int rowid, const char *userid, + int version, const char *data) +{ + struct sqldb_bindval bval[] = { + { ":rowid", SQLITE_INTEGER, { .i = rowid } }, + { ":userid", SQLITE_TEXT, { .s = userid } }, + { ":jmapversion", SQLITE_INTEGER, { .i = version } }, + { ":jmapdata", SQLITE_TEXT, { .s = data } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; + int r; + + /* clean up existing records if any */ + r = sqldb_exec(carddavdb->db, CMD_DELETE_JSCARDCACHE_USER, bval, NULL, NULL); + if (r) return r; + + if (!data) return 0; + + /* insert the cache record */ + return sqldb_exec(carddavdb->db, CMD_INSERT_JSCARDCACHE_USER, bval, NULL, NULL); +} + EXPORTED int carddav_get_cards(struct carddav_db *carddavdb, - const char *abookname, - const char *vcard_uid, int kind, + const mbentry_t *mbentry, + const char *userid, const char *vcard_uid, int kind, int (*cb)(void *rock, struct carddav_data *cdata), void *rock) { + const char *mailbox = !mbentry ? NULL : + ((carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name); struct sqldb_bindval bval[] = { + { ":userid", SQLITE_TEXT, { .s = userid } }, { ":kind", SQLITE_INTEGER, { .i = kind } }, - { ":mailbox", SQLITE_TEXT, { .s = abookname } }, + { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { ":vcard_uid", SQLITE_TEXT, { .s = vcard_uid } }, { NULL, SQLITE_NULL, { .s = NULL } } }; @@ -796,9 +1035,11 @@ EXPORTED int carddav_get_cards(struct carddav_db *carddavdb, struct read_rock rrock = { carddavdb, &cdata, 0, cb, rock }; struct buf sqlbuf = BUF_INITIALIZER; - buf_setcstr(&sqlbuf, CMD_GETFIELDS); - buf_appendcstr(&sqlbuf, " WHERE alive = 1 AND kind = :kind"); - if (abookname) + buf_setcstr(&sqlbuf, CMD_GETFIELDS_JSCARD); + buf_appendcstr(&sqlbuf, " WHERE alive = 1"); + if (kind != CARDDAV_KIND_ANY) + buf_appendcstr(&sqlbuf, " AND kind = :kind"); + if (mailbox) buf_appendcstr(&sqlbuf, " AND mailbox = :mailbox"); if (vcard_uid) buf_appendcstr(&sqlbuf, " AND vcard_uid = :vcard_uid"); @@ -822,14 +1063,17 @@ EXPORTED int carddav_get_cards(struct carddav_db *carddavdb, #define BYMODSEQ " modseq > :modseq;" EXPORTED int carddav_get_updates(struct carddav_db *carddavdb, - modseq_t oldmodseq, const char *mboxname, + modseq_t oldmodseq, const mbentry_t *mbentry, int kind, int limit, int (*cb)(void *rock, struct carddav_data *cdata), void *rock) { + const char *mailbox = !mbentry ? NULL : + ((carddavdb->db->version >= DB_MBOXID_VERSION) ? + mbentry->uniqueid : mbentry->name); struct sqldb_bindval bval[] = { - { ":mailbox", SQLITE_TEXT, { .s = mboxname } }, + { ":mailbox", SQLITE_TEXT, { .s = mailbox } }, { ":modseq", SQLITE_INTEGER, { .i = oldmodseq } }, { ":kind", SQLITE_INTEGER, { .i = kind } }, /* SQLite interprets a negative limit as unbounded. */ @@ -842,9 +1086,8 @@ EXPORTED int carddav_get_updates(struct carddav_db *carddavdb, int r; buf_setcstr(&sqlbuf, CMD_GETFIELDS " WHERE"); - if (mboxname) buf_appendcstr(&sqlbuf, " mailbox = :mailbox AND"); - if (kind >= 0) { - /* Use a negative value to signal that we accept ALL card types */ + if (mailbox) buf_appendcstr(&sqlbuf, " mailbox = :mailbox AND"); + if (kind >= 0 && kind != CARDDAV_KIND_ANY) { buf_appendcstr(&sqlbuf, " kind = :kind AND"); } if (!oldmodseq) buf_appendcstr(&sqlbuf, " alive = 1 AND"); @@ -868,27 +1111,37 @@ EXPORTED int carddav_writecard(struct carddav_db *carddavdb, { struct vparse_entry *ventry; + struct buf nicknames = BUF_INITIALIZER; + strarray_t values = STRARRAY_INITIALIZER; strarray_t emails = STRARRAY_INITIALIZER; strarray_t member_uids = STRARRAY_INITIALIZER; for (ventry = vcard->properties; ventry; ventry = ventry->next) { const char *name = ventry->name; - const char *propval = ventry->v.value; - if (!name) continue; + + char *propval = vparse_get_value(ventry); if (!propval) continue; if (!strcasecmp(name, "uid")) { cdata->vcard_uid = propval; + strarray_appendm(&values, propval); + propval = NULL; } else if (!strcasecmp(name, "n")) { cdata->name = propval; + strarray_appendm(&values, propval); + propval = NULL; } else if (!strcasecmp(name, "fn")) { cdata->fullname = propval; + strarray_appendm(&values, propval); + propval = NULL; } else if (!strcasecmp(name, "nickname")) { - cdata->nickname = propval; + if (buf_len(&nicknames)) buf_putc(&nicknames, ','); + buf_appendcstr(&nicknames, propval); + cdata->nickname = buf_cstring(&nicknames); } else if (!strcasecmp(name, "email")) { /* XXX - insert if primary */ @@ -899,21 +1152,25 @@ EXPORTED int carddav_writecard(struct carddav_db *carddavdb, !strcasecmp(param->value, "pref")) ispref = 1; } - strarray_append(&emails, propval); + strarray_appendm(&emails, propval); strarray_append(&emails, ispref ? "1" : ""); + propval = NULL; } else if (!strcasecmp(name, "member") || !strcasecmp(name, "x-addressbookserver-member")) { - if (strncmp(propval, "urn:uuid:", 9)) continue; - strarray_append(&member_uids, propval+9); - strarray_append(&member_uids, ""); + if (!strncmp(propval, "urn:uuid:", 9)) { + strarray_append(&member_uids, propval+9); + strarray_append(&member_uids, ""); + } } else if (!strcasecmp(name, "x-fm-otheraccount-member")) { - if (strncmp(propval, "urn:uuid:", 9)) continue; - struct vparse_param *param = vparse_get_param(ventry, "userid"); - if (!param) continue; - strarray_append(&member_uids, propval+9); - strarray_append(&member_uids, param->value); + if (!strncmp(propval, "urn:uuid:", 9)) { + struct vparse_param *param = vparse_get_param(ventry, "userid"); + if (param) { + strarray_append(&member_uids, propval+9); + strarray_append(&member_uids, param->value); + } + } } else if (!strcasecmp(name, "kind") || !strcasecmp(name, "x-addressbookserver-kind")) { @@ -921,53 +1178,70 @@ EXPORTED int carddav_writecard(struct carddav_db *carddavdb, cdata->kind = CARDDAV_KIND_GROUP; /* default case is CARDDAV_KIND_CONTACT */ } + else if (!strcasecmp(name, "version")) { + cdata->version = propval[0] - '0'; + } + + free(propval); } int r = carddav_write(carddavdb, cdata); if (!r) r = carddav_write_emails(carddavdb, cdata->dav.rowid, &emails, ispinned); if (!r) r = carddav_write_groups(carddavdb, cdata->dav.rowid, &member_uids); + buf_free(&nicknames); + strarray_fini(&values); strarray_fini(&emails); strarray_fini(&member_uids); return r; } -EXPORTED int carddav_store(struct mailbox *mailbox, struct vparse_card *vcard, - const char *resource, modseq_t createdmodseq, - strarray_t *flags, struct entryattlist *annots, - const char *userid, struct auth_state *authstate, - int ignorequota) +static int _carddav_store(struct mailbox *mailbox, struct buf *vcard, + const char *uid, const char *fullname, + const char *resource, modseq_t createdmodseq, + strarray_t *flags, struct entryattlist **annots, + const char *userid, struct auth_state *authstate, + int ignorequota, uint32_t oldsize) { int r = 0; FILE *f = NULL; - struct stagemsg *stage; + struct stagemsg *stage = NULL; char *header; quota_t qdiffs[QUOTA_NUMRESOURCES] = QUOTA_DIFFS_DONTCARE_INITIALIZER; struct appendstate as; time_t now = time(0); char *freeme = NULL; char datestr[80]; + static int64_t vcard_max_size = -1; + char *mbuserid = NULL; + + if (vcard_max_size < 0) { + vcard_max_size = config_getbytesize(IMAPOPT_VCARD_MAX_SIZE, 'B'); + if (vcard_max_size <= 0) vcard_max_size = BYTESIZE_UNLIMITED; + } init_internal(); /* Prepare to stage the message */ - if (!(f = append_newstage(mailbox->name, now, 0, &stage))) { - syslog(LOG_ERR, "append_newstage(%s) failed", mailbox->name); + if (!(f = append_newstage(mailbox_name(mailbox), now, 0, &stage))) { + syslog(LOG_ERR, "append_newstage(%s) failed", mailbox_name(mailbox)); return -1; } - /* set the REVision time */ - time_to_iso8601(now, datestr, sizeof(datestr), 0); - vparse_replace_entry(vcard, NULL, "REV", datestr); + /* Check size of vCard (allow existing oversized cards to be updated) */ + size_t max_size = vcard_max_size; + if (oldsize > max_size) { + max_size += CARDDAV_UPDATE_OVERAGE; + } + if (buf_len(vcard) > max_size) { + r = IMAP_MESSAGE_TOO_LARGE; + goto done; + } /* Create header for resource */ - const char *uid = vparse_stringval(vcard, "uid"); - const char *fullname = vparse_stringval(vcard, "fn"); if (!resource) resource = freeme = strconcat(uid, ".vcf", (char *)NULL); - struct buf buf = BUF_INITIALIZER; - vparse_tobuf(vcard, &buf); - char *mbuserid = mboxname_to_userid(mailbox->name); + mbuserid = mboxname_to_userid(mailbox_name(mailbox)); time_to_rfc5322(now, datestr, sizeof(datestr)); @@ -982,15 +1256,25 @@ EXPORTED int carddav_store(struct mailbox *mailbox, struct vparse_card *vcard, fprintf(f, "Date: %s\r\n", datestr); - if (strchr(uid, '@')) - fprintf(f, "Message-ID: <%s>\r\n", uid); - else - fprintf(f, "Message-ID: <%s@%s>\r\n", uid, config_servername); + /* Use SHA1(uid)@servername as Message-ID */ + struct message_guid uuid; + message_guid_generate(&uuid, uid, strlen(uid)); + fprintf(f, "Message-ID: <%s@%s>\r\n", + message_guid_encode(&uuid), config_servername); fprintf(f, "Content-Type: text/vcard; charset=utf-8\r\n"); - fprintf(f, "Content-Length: %u\r\n", (unsigned)buf_len(&buf)); - fprintf(f, "Content-Disposition: inline; filename=\"%s\"\r\n", resource); + fprintf(f, "Content-Length: %u\r\n", (unsigned)buf_len(vcard)); + + /* Since we use the vCard UID in the resource name, + this param may be long and needs to get properly split per RFC 2231 */ + struct buf value = BUF_INITIALIZER; + charset_append_mime_param(&value, + CHARSET_PARAM_XENCODE | CHARSET_PARAM_NEWLINE, + "filename", + resource); + fprintf(f, "Content-Disposition: inline%s\r\n", buf_cstring(&value)); + buf_free(&value); /* XXX Check domain of data and use appropriate CTE */ @@ -998,8 +1282,7 @@ EXPORTED int carddav_store(struct mailbox *mailbox, struct vparse_card *vcard, fprintf(f, "\r\n"); /* Write the vCard data to the file */ - fprintf(f, "%s", buf_cstring(&buf)); - buf_free(&buf); + fprintf(f, "%s", buf_cstring(vcard)); qdiffs[QUOTA_STORAGE] = ftell(f); qdiffs[QUOTA_MESSAGE] = 1; @@ -1010,7 +1293,7 @@ EXPORTED int carddav_store(struct mailbox *mailbox, struct vparse_card *vcard, ignorequota ? NULL : qdiffs, 0, 0, EVENT_MESSAGE_NEW|EVENT_CALENDAR))) { syslog(LOG_ERR, "append_setup(%s) failed: %s", - mailbox->name, error_message(r)); + mailbox_name(mailbox), error_message(r)); goto done; } @@ -1028,7 +1311,7 @@ EXPORTED int carddav_store(struct mailbox *mailbox, struct vparse_card *vcard, goto done; } - /* Commit the append to the calendar mailbox */ + /* Commit the append to the addressbook mailbox */ r = append_commit(&as); if (r) { syslog(LOG_ERR, "append_commit() failed"); @@ -1042,6 +1325,36 @@ EXPORTED int carddav_store(struct mailbox *mailbox, struct vparse_card *vcard, return r; } +EXPORTED int carddav_store(struct mailbox *mailbox, struct vparse_card *vcard, + const char *resource, modseq_t createdmodseq, + strarray_t *flags, struct entryattlist **annots, + const char *userid, struct auth_state *authstate, + int ignorequota, uint32_t oldsize) +{ + time_t now = time(0); + char datestr[80]; + + /* set the REVision time */ + time_to_iso8601(now, datestr, sizeof(datestr), 0); + vparse_replace_entry(vcard, NULL, "REV", datestr); + + /* get important properties */ + const char *uid = vparse_stringval(vcard, "uid"); + const char *fullname = vparse_stringval(vcard, "fn"); + + /* serialize the card */ + struct buf buf = BUF_INITIALIZER; + vparse_tobuf(vcard, &buf); + + int r = _carddav_store(mailbox, &buf, uid, fullname, + resource, createdmodseq, flags, annots, + userid, authstate, ignorequota, oldsize); + + buf_free(&buf); + + return r; +} + EXPORTED int carddav_remove(struct mailbox *mailbox, uint32_t olduid, int isreplace, const char *userid) { @@ -1064,13 +1377,13 @@ EXPORTED int carddav_remove(struct mailbox *mailbox, mboxevent_extract_record(mboxevent, mailbox, &oldrecord); mboxevent_extract_mailbox(mboxevent, mailbox); mboxevent_set_numunseen(mboxevent, mailbox, -1); - mboxevent_set_access(mboxevent, NULL, NULL, userid, mailbox->name, 0); + mboxevent_set_access(mboxevent, NULL, NULL, userid, mailbox_name(mailbox), 0); mboxevent_notify(&mboxevent); mboxevent_free(&mboxevent); } if (r) { syslog(LOG_ERR, "expunging record (%s) failed: %s", - mailbox->name, error_message(r)); + mailbox_name(mailbox), error_message(r)); } return r; } @@ -1097,3 +1410,166 @@ EXPORTED char *carddav_mboxname(const char *userid, const char *name) return res; } +#ifdef HAVE_LIBICALVCARD + +#include "vcard_support.h" + +EXPORTED int carddav_writecard_x(struct carddav_db *carddavdb, + struct carddav_data *cdata, + vcardcomponent *vcard, + int ispinned) +{ + struct buf nicknames = BUF_INITIALIZER; + strarray_t values = STRARRAY_INITIALIZER; + strarray_t emails = STRARRAY_INITIALIZER; + strarray_t member_uids = STRARRAY_INITIALIZER; + vcardproperty *prop; + char *propval = NULL; + + for (prop = vcardcomponent_get_first_property(vcard, VCARD_ANY_PROPERTY); + prop; + prop = vcardcomponent_get_next_property(vcard, VCARD_ANY_PROPERTY)) { + /* The libical BUFFER_RING_SIZE used by *_get_value_as_string() + * is 2500 entries. + * A vCard with more than 2500 properties (E.g. a large group card) + * will cause some of our value pointers to be freed out from under us. + * So, we use vcardproperty_get_value_as_string_r() here instead + * and manage the memory ourselves. + */ + free(propval); + propval = vcardproperty_get_value_as_string_r(prop); + const char *userid = ""; + + if (!propval) continue; + + switch (vcardproperty_isa(prop)) { + case VCARD_UID_PROPERTY: + cdata->vcard_uid = propval; + strarray_appendm(&values, propval); + propval = NULL; + break; + + case VCARD_N_PROPERTY: + cdata->name = propval; + strarray_appendm(&values, propval); + propval = NULL; + break; + + case VCARD_FN_PROPERTY: + cdata->fullname = propval; + strarray_appendm(&values, propval); + propval = NULL; + break; + + case VCARD_NICKNAME_PROPERTY: + if (buf_len(&nicknames)) buf_putc(&nicknames, ','); + buf_appendcstr(&nicknames, propval); + cdata->nickname = buf_cstring(&nicknames); + break; + + case VCARD_EMAIL_PROPERTY: { + /* XXX - insert if primary */ + int ispref = 0; + vcardparameter *param = + vcardproperty_get_first_parameter(prop, VCARD_PREF_PARAMETER); + if (param) { + ispref = (vcardparameter_get_pref(param) == 1); + } + else if ((param = + vcardproperty_get_first_parameter(prop, + VCARD_TYPE_PARAMETER))) { + vcardenumarray *types = vcardparameter_get_type(param); + vcardenumarray_element pref = { .val = VCARD_TYPE_PREF }; + if (vcardenumarray_find(types, &pref) >= 0) ispref = 1; + } + strarray_appendm(&emails, propval); + strarray_append(&emails, ispref ? "1" : ""); + propval = NULL; + break; + } + + kind: + case VCARD_KIND_PROPERTY: + if (!strcasecmp(propval, "group")) + cdata->kind = CARDDAV_KIND_GROUP; + /* default case is CARDDAV_KIND_CONTACT */ + break; + + member: + case VCARD_MEMBER_PROPERTY: + if (strncmp(propval, "urn:uuid:", 9)) continue; + strarray_append(&member_uids, propval+9); + strarray_append(&member_uids, userid); + break; + + case VCARD_VERSION_PROPERTY: + cdata->version = propval[0] - '0'; + break; + + case VCARD_X_PROPERTY: { + const char *name = vcardproperty_get_property_name(prop); + if (!strcasecmp(name, "x-addressbookserver-kind")) + goto kind; + else if (!strcasecmp(name, "x-addressbookserver-member")) + goto member; + else if (!strcasecmp(name, "x-fm-otheraccount-member")) { + userid = vcardproperty_get_xparam_value(prop, "userid"); + if (!userid) continue; + goto member; + } + break; + } + + default: + break; + } + } + + int r; + if (cdata->dav.rowid) { + /* clean up existing cache records */ + struct sqldb_bindval bval[] = { + { ":rowid", SQLITE_INTEGER, { .i = cdata->dav.rowid } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; + + r = sqldb_exec(carddavdb->db, CMD_DELETE_JSCARDCACHE, bval, NULL, NULL); + if (r) goto done; + } + r = carddav_write(carddavdb, cdata); + if (!r) r = carddav_write_emails(carddavdb, + cdata->dav.rowid, &emails, ispinned); + if (!r) r = carddav_write_groups(carddavdb, cdata->dav.rowid, &member_uids); + +done: + buf_free(&nicknames); + strarray_fini(&values); + strarray_fini(&emails); + strarray_fini(&member_uids); + free(propval); + + return r; +} + +EXPORTED int carddav_store_x(struct mailbox *mailbox, vcardcomponent *vcard, + const char *resource, modseq_t createdmodseq, + struct entryattlist **annots, + const char *userid, struct auth_state *authstate, + int ignorequota, uint32_t oldsize) +{ + /* get important properties */ + const char *uid = vcardcomponent_get_uid(vcard); + const char *fullname = vcardcomponent_get_fn(vcard); + + /* serialize the card */ + struct buf *buf = vcard_as_buf_x(vcard); + + int r = _carddav_store(mailbox, buf, uid, fullname, + resource, createdmodseq, NULL, annots, + userid, authstate, ignorequota, oldsize); + + buf_destroy(buf); + + return r; +} + +#endif /* HAVE_LIBICALVCARD */ diff --git a/imap/carddav_db.h b/imap/carddav_db.h index 2c4f6a4895..9307a0f3db 100644 --- a/imap/carddav_db.h +++ b/imap/carddav_db.h @@ -48,14 +48,19 @@ #include "auth.h" #include "dav_db.h" +#include "mboxlist.h" #include "strarray.h" #include "util.h" #include "vparse.h" struct carddav_db; +#define CARDDAV_UPDATE_OVERAGE 2048 + #define CARDDAV_KIND_CONTACT 0 #define CARDDAV_KIND_GROUP 1 +#define CARDDAV_KIND_ANY 255 + struct carddav_data { struct dav_data dav; /* MUST be first so we can typecast */ unsigned version; @@ -64,10 +69,20 @@ struct carddav_data { const char *fullname; const char *name; const char *nickname; + int jmapversion; + const char *jmapdata; strarray_t *emails; strarray_t *member_uids; }; +enum carddav_sort { + CARD_SORT_NONE = 0, + CARD_SORT_MODSEQ, + CARD_SORT_UID, + CARD_SORT_FULLNAME, + CARD_SORT_DESC = 0x80 /* bit-flag for descending sort */ +}; + typedef int carddav_cb_t(void *rock, struct carddav_data *cdata); @@ -81,20 +96,23 @@ int carddav_done(void); struct carddav_db *carddav_open_mailbox(struct mailbox *mailbox); struct carddav_db *carddav_open_userid(const char *userid); +/* add another DB */ +int carddav_set_otheruser(struct carddav_db *db, const char *userid); + /* close this handle */ int carddav_close(struct carddav_db *carddavdb); /* lookup an entry from 'carddavdb' by resource (optionally inside a transaction for updates) */ int carddav_lookup_resource(struct carddav_db *carddavdb, - const char *mailbox, const char *resource, + const mbentry_t *mbentry, const char *resource, struct carddav_data **result, int tombstones); /* lookup an entry from 'carddavdb' by mailbox and IMAP uid (optionally inside a transaction for updates) */ int carddav_lookup_imapuid(struct carddav_db *carddavdb, - const char *mailbox, int uid, + const mbentry_t *mbentry, int uid, struct carddav_data **result, int tombstones); @@ -107,20 +125,23 @@ int carddav_lookup_uid(struct carddav_db *carddavdb, const char *ical_uid, returns the groups its in (if any) */ strarray_t *carddav_getemail(struct carddav_db *carddavdb, const char *key); strarray_t *carddav_getemail2details(struct carddav_db *carddavdb, const char *key, - const char *mboxname, int *ispinned); + const mbentry_t *mbentry, int *ispinned); strarray_t *carddav_getuid2groups(struct carddav_db *carddavdb, const char *key, - const char *mboxname, const char *otheruser); + const mbentry_t *mbentry, const char *otheruser); -/* checks if a group exists (by id). +/* checks if a group exists (by id), optionally filtered by addressbook mailbox. + * Looks up groups across addressbooks if mbentry is NULL. returns emails of its members (if any) */ -strarray_t *carddav_getgroup(struct carddav_db *carddavdb, const char *mailbox, const char *group); +strarray_t *carddav_getgroup(struct carddav_db *carddavdb, + const mbentry_t *mbentry, const char *group, + const mbentry_t *othermb); /* get a list of groups the given uid is a member of */ strarray_t *carddav_getuid_groups(struct carddav_db *carddavdb, const char *uid); /* process each entry of type 'kind' for 'mailbox' in 'carddavdb' with cb() */ -int carddav_get_cards(struct carddav_db *carddavdb, - const char *mailbox, const char *vcard_uid, int kind, +int carddav_get_cards(struct carddav_db *carddavdb, const mbentry_t *mbentry, + const char *userid, const char *vcard_uid, int kind, carddav_cb_t *cb, void *rock); /* Process each entry for 'carddavdb' with a modseq higher than oldmodseq, @@ -129,13 +150,28 @@ int carddav_get_cards(struct carddav_db *carddavdb, * If kind is non-negative, only process entries of this kind. * If max_records is positive, only call cb for at most this entries. */ int carddav_get_updates(struct carddav_db *carddavdb, - modseq_t oldmodseq, const char *mboxname, int kind, + modseq_t oldmodseq, const mbentry_t *mbentry, int kind, int max_records, carddav_cb_t *cb, void *rock); /* process each entry for 'mailbox' in 'carddavdb' with cb() */ -int carddav_foreach(struct carddav_db *carddavdb, const char *mailbox, +int carddav_foreach(struct carddav_db *carddavdb, const mbentry_t *mbentry, carddav_cb_t *cb, void *rock); +/* process each entry for 'mailbox' in 'carddavdb' with cb() + * The callback is called in order of sort, or by descending + * modseq if no sort is specified. */ +int carddav_foreach_sort(struct carddav_db *carddavdb, const mbentry_t *mbentry, + enum carddav_sort* sort, size_t nsort, + carddav_cb_t *cb, void *rock); + +int carddav_write_jscardcache(struct carddav_db *carddavdb, + int rowid, const char *userid, + int version, const char *data); + +/* update an entry in 'carddavdb' */ +int carddav_update(struct carddav_db *carddavdb, + struct carddav_data *cdata, int ispinned); + /* write an entry to 'carddavdb' */ int carddav_write(struct carddav_db *carddavdb, struct carddav_data *cdata); @@ -147,7 +183,7 @@ int carddav_writecard(struct carddav_db *carddavdb, struct carddav_data *cdata, int carddav_delete(struct carddav_db *carddavdb, unsigned rowid); /* delete all entries for 'mailbox' from 'carddavdb' */ -int carddav_delmbox(struct carddav_db *carddavdb, const char *mailbox); +int carddav_delmbox(struct carddav_db *carddavdb, const mbentry_t *mbentry); /* begin transaction */ int carddav_begin(struct carddav_db *carddavdb); @@ -161,9 +197,9 @@ int carddav_abort(struct carddav_db *carddavdb); /* store a vcard to mailbox/resource */ int carddav_store(struct mailbox *mailbox, struct vparse_card *vcard, const char *resource, modseq_t createdmodseq, - strarray_t *flags, struct entryattlist *annots, + strarray_t *flags, struct entryattlist **annots, const char *userid, struct auth_state *authstate, - int ignorequota); + int ignorequota, uint32_t oldsize); /* delete a carddav entry */ int carddav_remove(struct mailbox *mailbox, @@ -173,4 +209,19 @@ int carddav_remove(struct mailbox *mailbox, /* calculate a mailbox name */ char *carddav_mboxname(const char *userid, const char *name); +#ifdef HAVE_LIBICALVCARD + +#include "vcard_support.h" + +int carddav_writecard_x(struct carddav_db *carddavdb, struct carddav_data *cdata, + vcardcomponent *vcard, int ispinned); + +int carddav_store_x(struct mailbox *mailbox, vcardcomponent *vcard, + const char *resource, modseq_t createdmodseq, + struct entryattlist **annots, + const char *userid, struct auth_state *authstate, + int ignorequota, uint32_t oldsize); + +#endif /* HAVE_LIBICALVCARD */ + #endif /* CARDDAV_DB_H */ diff --git a/imap/chk_cyrus.c b/imap/chk_cyrus.c index 3062fb7adb..5e45997043 100644 --- a/imap/chk_cyrus.c +++ b/imap/chk_cyrus.c @@ -42,6 +42,7 @@ #include +#include #include #include #include @@ -74,6 +75,7 @@ static const char *check_part = NULL; /* partition we are checking */ static int chkmbox(struct findall_data *data, void *rock __attribute__((unused))) { if (!data) return 0; + if (!data->is_exactmatch) return 0; int r; mbentry_t *mbentry = NULL; const char *name = mbname_intname(data->mbname); @@ -95,7 +97,7 @@ static int chkmbox(struct findall_data *data, void *rock __attribute__((unused)) fprintf(stderr, "checking: %s\n", name); - mailbox_reconstruct(name, 0); /* no changes allowed */ + mailbox_reconstruct(name, 0, NULL); /* no changes allowed */ mboxlist_entry_free(&mbentry); @@ -107,11 +109,22 @@ int main(int argc, char **argv) char *alt_config = NULL; char pattern[2] = { '*', '\0' }; const char *mailbox = NULL; - - extern char *optarg; int opt; - while ((opt = getopt(argc, argv, "C:P:M:")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:M:P:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "mailbox", required_argument, NULL, 'M' }, + { "partition", required_argument, NULL, 'P' }, + + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; diff --git a/imap/cli_fatal.c b/imap/cli_fatal.c index 2442333a0c..9d655662ad 100644 --- a/imap/cli_fatal.c +++ b/imap/cli_fatal.c @@ -44,6 +44,7 @@ #include #include +#include #include "global.h" #include "xmalloc.h" @@ -60,5 +61,8 @@ EXPORTED void fatal(const char *message, int code) recurse_code = code; fprintf(stderr, "fatal error: %s\n", message); cyrus_done(); + + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + exit(code); } diff --git a/imap/conversations.c b/imap/conversations.c index 21608f3f2b..0e86cca7f6 100644 --- a/imap/conversations.c +++ b/imap/conversations.c @@ -73,9 +73,9 @@ #include "search_engines.h" #include "seen.h" #include "strhash.h" -#include "stristr.h" #include "sync_log.h" #include "syslog.h" +#include "user.h" #include "util.h" #include "xmalloc.h" #include "xstrlcpy.h" @@ -93,16 +93,20 @@ #define CONVERSATION_ID_STRMAX (1+sizeof(conversation_id_t)*2) /* per user conversations db extension */ -#define FNAME_CONVERSATIONS_SUFFIX "conversations" #define FNKEY "$FOLDER_NAMES" +#define FIKEY "$FOLDER_IDS" #define CFKEY "$COUNTED_FLAGS" #define CONVSPLITFOLDER "#splitconversations" #define DB config_conversations_db -#define CONVERSATIONS_VERSION 0 +struct conversations_open { + struct conversations_state s; + struct mboxlock *local_namespacelock; + struct conversations_open *next; +}; -struct conversations_open *open_conversations; +static struct conversations_open *open_conversations; static conv_status_t NULLSTATUS = CONV_STATUS_INIT; @@ -138,6 +142,9 @@ static char *conversations_path(mbname_t *mbname) * it's hard-coded as the user */ if (!mbname_userid(mbname)) return NULL; + // deleted mailboxes don't have a conversations database + if (mbname_isdeleted(mbname)) + return NULL; if (convdir) return strconcat(convdir, "/", mbname_userid(mbname), ".", suff, (char *)NULL); return mboxname_conf_getpath(mbname, suff); @@ -172,7 +179,7 @@ static int _init_counted(struct conversations_state *state, val = config_getstring(IMAPOPT_CONVERSATIONS_COUNTED_FLAGS); if (!val) val = ""; vallen = strlen(val); - if (vallen) { + if (vallen && !state->is_shared) { r = cyrusdb_store(state->db, CFKEY, strlen(CFKEY), val, vallen, &state->txn); if (r) { @@ -203,9 +210,9 @@ static int _init_counted(struct conversations_state *state, int _saxfolder(int type, struct dlistsax_data *d) { - struct conversations_open *open = (struct conversations_open *)d->rock; + strarray_t *list = (strarray_t *)d->rock; if (type == DLISTSAX_STRING) - strarray_append(open->s.folder_names, d->data); + strarray_append(list, d->data); return 0; } @@ -216,15 +223,16 @@ static int write_folders(struct conversations_state *state) int r; int i; - for (i = 0; i < state->folder_names->count; i++) { - const char *fname = strarray_nth(state->folder_names, i); - dlist_setatom(dl, NULL, fname); + for (i = 0; i < strarray_size(state->folders); i++) { + const char *folder = strarray_nth(state->folders, i); + dlist_setatom(dl, NULL, folder); } dlist_printbuf(dl, 0, &buf); dlist_free(&dl); - r = cyrusdb_store(state->db, FNKEY, strlen(FNKEY), + const char *key = state->folders_byname ? FNKEY : FIKEY; + r = cyrusdb_store(state->db, key, strlen(key), buf.s, buf.len, &state->txn); buf_free(&buf); @@ -232,22 +240,40 @@ static int write_folders(struct conversations_state *state) return r; } -static int folder_number(struct conversations_state *state, - const char *name, +EXPORTED int conversation_folder_number(struct conversations_state *state, + const char *folder, int create_flag) { - int pos = strarray_find(state->folder_names, name, 0); + int pos = strarray_find(state->folders, folder, 0); int r; /* if we have to add it, then save the keys back */ if (pos < 0 && create_flag) { /* replace the first unused if there is one */ - pos = strarray_find(state->folder_names, "-", 0); + pos = strarray_find(state->folders, "-", 0); if (pos >= 0) - strarray_set(state->folder_names, pos, name); + strarray_set(state->folders, pos, folder); /* otherwise append */ else - pos = strarray_append(state->folder_names, name); + pos = strarray_append(state->folders, folder); + + /* track the Trash folder number as it's added */ + if (state->folders_byname) { + if (!strcmpsafe(folder, state->trashmboxname)) + state->trashfolder = pos; + } + else { + if (!state->trashmboxid && state->trashmboxname) { + mbentry_t *mbentry = NULL; + + mboxlist_lookup(state->trashmboxname, &mbentry, NULL); + state->trashmboxid = mbentry ? xstrdup(mbentry->uniqueid) : NULL; + mboxlist_entry_free(&mbentry); + } + + if (!strcmpsafe(folder, state->trashmboxid)) + state->trashfolder = pos; + } /* store must succeed */ r = write_folders(state); @@ -257,6 +283,77 @@ static int folder_number(struct conversations_state *state, return pos; } +EXPORTED int conversations_num_folders(struct conversations_state *state) +{ + return strarray_size(state->folders); +} + +EXPORTED const char *conversations_folder_mboxname(const struct conversations_state *state, + int foldernum) +{ + const char *val = strarray_nth(state->folders, foldernum); + if (!val) return NULL; // missing? Weird - we've requested past the end of the list + if (!strcmp(val, "-")) return NULL; // tombstone + + if (state->folders_byname) + return val; + + // make it writeable even though const, because we're fiddling cache + struct conversations_state *backdoor = (struct conversations_state *)state; + if (!backdoor->altrep) backdoor->altrep = strarray_new(); + const char *res = strarray_nth(backdoor->altrep, foldernum); + if (!res) { + // cache lookup + mbentry_t *mbentry = NULL; + int r = mboxlist_lookup_by_uniqueid(val, &mbentry, NULL); + if (r || !mbentry) return NULL; + strarray_set(backdoor->altrep, foldernum, mbentry->name); + mboxlist_entry_free(&mbentry); + res = strarray_nth(backdoor->altrep, foldernum); + } + return res; +} + +EXPORTED const char *conversations_folder_uniqueid(const struct conversations_state *state, + int foldernum) +{ + const char *val = strarray_nth(state->folders, foldernum); + if (!val) return NULL; // missing? Weird - we've requested past the end of the list + if (!strcmp(val, "-")) return NULL; // tombstone + + if (!state->folders_byname) + return val; + + // make it writeable even though const, because we're fiddling cache + struct conversations_state *backdoor = (struct conversations_state *)state; + if (!backdoor->altrep) backdoor->altrep = strarray_new(); + const char *res = strarray_nth(backdoor->altrep, foldernum); + if (!res) { + // cache lookup + mbentry_t *mbentry = NULL; + int r = mboxlist_lookup(val, &mbentry, NULL); + if (r || !mbentry) return NULL; + strarray_set(backdoor->altrep, foldernum, mbentry->uniqueid); + mboxlist_entry_free(&mbentry); + res = strarray_nth(backdoor->altrep, foldernum); + } + return res; +} + +EXPORTED size_t conversations_estimate_emailcount(struct conversations_state *state) +{ + int i; + size_t count = 0; + conv_status_t status; + for (i = 0; i < strarray_size(state->folders); i++) { + const char *folder = strarray_nth(state->folders, i); + int r = conversation_getstatus(state, folder, &status); + if (r) continue; + count += status.emailexists; + } + return count; +} + EXPORTED int conversations_open_path(const char *fname, const char *userid, int shared, struct conversations_state **statep) { @@ -275,43 +372,53 @@ EXPORTED int conversations_open_path(const char *fname, const char *userid, int } open = xzmalloc(sizeof(struct conversations_open)); + open->s.is_shared = shared; + open->s.path = xstrdup(fname); + open->next = open_conversations; + open_conversations = open; + + /* first ensure that the usernamespace is locked */ + int haslock = user_isnamespacelocked(userid); + if (haslock) { + if (!shared) assert(haslock != LOCK_SHARED); + } + else { + int locktype = shared ? LOCK_SHARED : LOCK_EXCLUSIVE; + open->local_namespacelock = user_namespacelock_full(userid, locktype); + } /* open db */ - int flags = CYRUSDB_CREATE | (shared ? CYRUSDB_SHARED : CYRUSDB_CONVERT); + int flags = CYRUSDB_CREATE | (shared ? (CYRUSDB_SHARED|CYRUSDB_NOCRC) : CYRUSDB_CONVERT); r = cyrusdb_lockopen(DB, fname, flags, &open->s.db, &open->s.txn); if (r || open->s.db == NULL) { - free(open); + _conv_remove(&open->s); return IMAP_IOERROR; } - open->s.path = xstrdup(fname); - open->next = open_conversations; - open_conversations = open; /* load or initialize counted flags */ cyrusdb_fetch(open->s.db, CFKEY, strlen(CFKEY), &val, &vallen, &open->s.txn); r = _init_counted(&open->s, val, vallen); - if (r == CYRUSDB_READONLY) { - /* racy: drop shared lock, grab write lock */ - cyrusdb_commit(open->s.db, open->s.txn); - open->s.txn = NULL; - flags &= ~CYRUSDB_SHARED; - r = cyrusdb_lockopen(DB, fname, flags, &open->s.db, &open->s.txn); - if (!r) r = _init_counted(&open->s, val, vallen); - } if (r) { cyrusdb_abort(open->s.db, open->s.txn); _conv_remove(&open->s); - free(open); return r; } /* we should just read the folder names up front too */ - open->s.folder_names = strarray_new(); + open->s.folders = strarray_new(); /* if there's a value, parse as a dlist */ - if (!cyrusdb_fetch(open->s.db, FNKEY, strlen(FNKEY), - &val, &vallen, &open->s.txn)) { - dlist_parsesax(val, vallen, 0, _saxfolder, open); + vallen = 0; + r = cyrusdb_fetch(open->s.db, FIKEY, strlen(FIKEY), + &val, &vallen, &open->s.txn); + if (r == CYRUSDB_NOTFOUND) { + vallen = 0; + r = cyrusdb_fetch(open->s.db, FNKEY, strlen(FNKEY), + &val, &vallen, &open->s.txn); + if (!r) open->s.folders_byname = 1; + } + if (!r) { + dlist_parsesax(val, vallen, 0, _saxfolder, open->s.folders); } if (userid) @@ -320,11 +427,24 @@ EXPORTED int conversations_open_path(const char *fname, const char *userid, int open->s.annotmboxname = xstrdup(CONVSPLITFOLDER); char *trashmboxname = mboxname_user_mbox(userid, "Trash"); - open->s.trashfolder = folder_number(&open->s, trashmboxname, /*create*/1); - free(trashmboxname); + open->s.trashmboxname = trashmboxname; + open->s.trashfolder = -1; + if (open->s.folders_byname) + open->s.trashfolder = conversation_folder_number(&open->s, trashmboxname, /*create*/0); + else if (trashmboxname) { + mbentry_t *mbentry = NULL; + + mboxlist_lookup(trashmboxname, &mbentry, NULL); + if (mbentry) { + open->s.trashmboxid = xstrdup(mbentry->uniqueid); + open->s.trashfolder = + conversation_folder_number(&open->s, open->s.trashmboxid, /*create*/0); + mboxlist_entry_free(&mbentry); + } + } /* create the status cache */ - construct_hash_table(&open->s.folderstatus, open->s.folder_names->count/4+4, 0); + construct_hash_table(&open->s.folderstatus, strarray_size(open->s.folders)/4+4, 0); *statep = &open->s; @@ -352,7 +472,6 @@ EXPORTED int conversations_open_mbox(const char *mboxname, int shared, struct co free(userid); free(path); return r; - return 0; } EXPORTED struct conversations_state *conversations_get_path(const char *fname) @@ -399,10 +518,16 @@ static void _conv_remove(struct conversations_state *state) *prevp = cur->next; free(cur->s.annotmboxname); free(cur->s.path); + free(cur->s.trashmboxname); + free(cur->s.trashmboxid); if (cur->s.counted_flags) strarray_free(cur->s.counted_flags); - if (cur->s.folder_names) - strarray_free(cur->s.folder_names); + if (cur->s.folders) + strarray_free(cur->s.folders); + if (cur->s.altrep) + strarray_free(cur->s.altrep); + if (cur->local_namespacelock) + mboxname_release(&cur->local_namespacelock); free(cur); return; } @@ -422,12 +547,22 @@ static void commitstatus_cb(const char *key, void *data, void *rock) { conv_status_t *status = (conv_status_t *)data; struct conversations_state *state = (struct conversations_state *)rock; + const char *folder = key+1; /* skip the leading F */ + mbentry_t *mbentry = NULL; conversation_storestatus(state, key, strlen(key), status); /* just in case convdb has a higher modseq for any reason (aka deleted and * recreated while a replica was still valid with the old user) */ - mboxname_setmodseq(key+1, status->threadmodseq, /*mbtype */0, /*dofolder*/0); - sync_log_mailbox(key+1); /* skip the leading F */ + if (!state->folders_byname) { + mboxlist_lookup_by_uniqueid(folder, &mbentry, NULL); + if (!mbentry) return; + + folder = mbentry->name; + } + mboxname_setmodseq(folder, status->threadmodseq, /*mbtype */0, /*flags*/0); + sync_log_mailbox(folder); + + mboxlist_entry_free(&mbentry); } static void conversations_commitcache(struct conversations_state *state) @@ -458,6 +593,7 @@ EXPORTED int conversations_abort(struct conversations_state **statep) return 0; } +static int _write_quota(struct conversations_state *state); EXPORTED int conversations_commit(struct conversations_state **statep) { struct conversations_state *state = *statep; @@ -470,6 +606,9 @@ EXPORTED int conversations_commit(struct conversations_state **statep) /* commit cache, writes to to DB */ conversations_commitcache(state); + r = _write_quota(state); + if (r) return r; + /* finally it's safe to commit the DB itself */ if (state->db) { if (state->txn) @@ -493,9 +632,12 @@ static int check_msgid(const char *msgid, size_t len, size_t *lenp) if (msgid[0] != '<' || msgid[len-1] != '>' || len < 3) return IMAP_INVALID_IDENTIFIER; +#if 0 /* XXX This check results in messages with invalid Message-ID + to be threadid incorrectly. */ /* Leniently accept msg-id without @, but refuse multiple @ */ if (memchr(msgid, '@', len) != memrchr(msgid, '@', len)) return IMAP_INVALID_IDENTIFIER; +#endif /* Leniently accept specials, but refuse the outright broken */ size_t i; @@ -520,15 +662,13 @@ static int _conversations_set_key(struct conversations_state *state, const arrayu64_t *cids, time_t stamp) { int r; - struct buf buf; - int version = CONVERSATIONS_VERSION; - int i; + struct buf buf = BUF_INITIALIZER; + int version = CONVERSATIONS_KEY_VERSION; + size_t i; /* XXX: should this be a delete operation? */ assert(cids->count); - buf_init(&buf); - if (state->db == NULL) return IMAP_IOERROR; @@ -538,7 +678,7 @@ static int _conversations_set_key(struct conversations_state *state, if (i) buf_putc(&buf, ','); buf_printf(&buf, CONV_FMT, cid); } - buf_printf(&buf, " %lu", stamp); + buf_printf(&buf, " " TIME_T_FMT, stamp); r = cyrusdb_store(state->db, key, keylen, @@ -552,27 +692,6 @@ static int _conversations_set_key(struct conversations_state *state, return 0; } -static int _sanity_check_counts(conversation_t *conv) -{ - conv_folder_t *folder; - uint32_t num_records = 0; - uint32_t exists = 0; - - for (folder = conv->folders; folder; folder = folder->next) { - num_records += folder->num_records; - exists += folder->exists; - } - - if (num_records != conv->num_records) - return IMAP_INTERNAL; - - if (exists != conv->exists) - return IMAP_INTERNAL; - - return 0; -} - - EXPORTED int conversations_add_msgid(struct conversations_state *state, const char *msgid, conversation_id_t cid) @@ -607,9 +726,6 @@ static int _conversations_parse(const char *data, size_t datalen, bit64 tval; bit64 version; - /* make sure we don't leak old data */ - arrayu64_truncate(cids, 0); - r = parsenum(data, &rest, datalen, &version); if (r) return IMAP_MAILBOX_BADFORMAT; @@ -618,7 +734,7 @@ static int _conversations_parse(const char *data, size_t datalen, rest++; /* skip space */ restlen = datalen - (rest - data); - if (version != CONVERSATIONS_VERSION) { + if (version != CONVERSATIONS_KEY_VERSION) { /* XXX - an error code for "incorrect version"? */ return IMAP_MAILBOX_BADFORMAT; } @@ -629,7 +745,7 @@ static int _conversations_parse(const char *data, size_t datalen, while (1) { r = parsehex(rest, &rest, 16, &cid); if (r) return IMAP_MAILBOX_BADFORMAT; - arrayu64_append(cids, cid); + if (cids) arrayu64_append(cids, cid); if (rest[0] == ' ') break; if (rest[0] != ',') return IMAP_MAILBOX_BADFORMAT; rest++; /* skip comma */ @@ -661,6 +777,9 @@ EXPORTED int conversations_get_msgid(struct conversations_state *state, const char *data; int r; + /* make sure we don't leak old data */ + arrayu64_truncate(cids, 0); + r = check_msgid(msgid, 0, &keylen); if (r) return r; @@ -683,63 +802,32 @@ EXPORTED int conversations_get_msgid(struct conversations_state *state, /* * Normalise a subject string, to a form which can be used for deciding * whether a message belongs in the same conversation as it's antecedent - * messages. What we're doing here is the same idea as the "base - * subject" algorithm described in RFC5256 but slightly adapted from - * experience. Differences are: - * - * - We eliminate all whitespace; RFC5256 normalises any sequence - * of whitespace characters to a single SP. We do this because - * we have observed combinations of buggy client software both - * add and remove whitespace around folding points. - * - * - Because we eliminate whitespace entirely, and whitespace helps - * delimit some of our other replacements, we do that whitespace - * step last instead of first. - * - * - We eliminate leading tokens like Re: and Fwd: using a simpler - * and more generic rule than RFC5256's; this rule catches a number - * of semantically identical prefixes in other human languages, but - * unfortunately also catches lots of other things. We think we can - * get away with this because the normalised subject is never directly - * seen by human eyes, so some information loss is acceptable as long - * as the subjects in different messages match correctly. - * - * - We eliminate trailing tokens like [SEC=UNCLASSIFIED], - * [DLM=Sensitive], etc which are automatically added by Australian - * Government department email systems. In theory there should be no - * more than one of these on an email subject but in practice multiple - * have been seen. - * http://www.finance.gov.au/files/2012/04/EPMS2012.3.pdf + * messages. */ EXPORTED void conversation_normalise_subject(struct buf *s) { static int initialised_res = 0; static regex_t whitespace_re; + static regex_t bracket_re; static regex_t relike_token_re; - static regex_t blob_start_re; - static regex_t blob_end_re; int r; if (!initialised_res) { - r = regcomp(&whitespace_re, "[ \t\r\n]+", REG_EXTENDED); - assert(r == 0); - r = regcomp(&relike_token_re, "^[ \t]*[A-Za-z0-9]+(\\[[0-9]+\\])?:", REG_EXTENDED); + r = regcomp(&whitespace_re, "([ \t\r\n]+|\xC2\xA0)", REG_EXTENDED); assert(r == 0); - r = regcomp(&blob_start_re, "^[ \t]*\\[[^]]+\\]", REG_EXTENDED); + r = regcomp(&bracket_re, "(\\[[^]\\[]*])|(^[^\\[]*])|(\\[[^]]*$)", REG_EXTENDED); assert(r == 0); - r = regcomp(&blob_end_re, "\\[(SEC|DLM)=[^]]+\\][ \t]*$", REG_EXTENDED); + r = regcomp(&relike_token_re, "^[ \t]*[^ \t\r\n\f]+:", REG_EXTENDED); assert(r == 0); initialised_res = 1; } - /* step 1 is to decode any RFC2047 MIME encoding of the header - * field, but we assume that has already happened */ + /* step 1 is to remove anything within (maybe unmatched) square brackets */ + while (buf_replace_one_re(s, &bracket_re, NULL)) + ; - /* step 2 is to eliminate all "Re:"-like tokens and [] blobs - * at the start, and AusGov [] blobs at the end */ - while (buf_replace_one_re(s, &relike_token_re, NULL) || - buf_replace_one_re(s, &blob_start_re, NULL) || - buf_replace_one_re(s, &blob_end_re, NULL)) + /* step 2 is to eliminate all "Re:"-like tokens */ + while (buf_replace_one_re(s, &relike_token_re, NULL)) ; /* step 3 is eliminating whitespace. */ @@ -750,12 +838,15 @@ static int folder_number_rename(struct conversations_state *state, const char *from_name, const char *to_name) { - int pos = strarray_find(state->folder_names, from_name, 0); + /* Do nothing if using folder ids */ + if (to_name && !state->folders_byname) return 0; + + int pos = strarray_find(state->folders, from_name, 0); if (pos < 0) return 0; /* nothing to do! */ /* replace the name - set to '-' if deleted */ - strarray_set(state->folder_names, pos, to_name ? to_name : "-"); + strarray_set(state->folders, pos, to_name ? to_name : "-"); return write_folders(state); } @@ -778,7 +869,7 @@ EXPORTED int conversation_storestatus(struct conversations_state *state, dlist_setnum32(dl, "EMAILUNSEEN", status->emailunseen); struct buf buf = BUF_INITIALIZER; - buf_printf(&buf, "%d ", CONVERSATIONS_VERSION); + buf_printf(&buf, "%d ", CONVERSATIONS_STATUS_VERSION); dlist_printbuf(dl, 0, &buf); dlist_free(&dl); @@ -793,10 +884,10 @@ EXPORTED int conversation_storestatus(struct conversations_state *state, } EXPORTED int conversation_setstatus(struct conversations_state *state, - const char *mboxname, + const char *mailbox, const conv_status_t *status) { - char *key = strconcat("F", mboxname, (char *)NULL); + char *key = strconcat("F", mailbox, (char *)NULL); conv_status_t *cachestatus = NULL; cachestatus = hash_lookup(key, &state->folderstatus); @@ -819,7 +910,6 @@ static void conv_to_buf(conversation_t *conv, struct buf *buf, int flagcount) const conv_folder_t *folder; const conv_sender_t *sender; const conv_thread_t *thread; - int version = CONVERSATIONS_VERSION; int i; dl = dlist_newlist(NULL, NULL); @@ -873,11 +963,12 @@ static void conv_to_buf(conversation_t *conv, struct buf *buf, int flagcount) dlist_setguid(nn, "GUID", &thread->guid); dlist_setnum32(nn, "EXISTS", thread->exists); dlist_setnum32(nn, "INTERNALDATE", thread->internaldate); + dlist_setnum32(nn, "CREATEDMODSEQ", thread->createdmodseq); } dlist_setnum64(dl, "CREATEDMODSEQ", conv->createdmodseq); - buf_printf(buf, "%d ", version); + buf_printf(buf, "%d ", conv->version); dlist_printbuf(dl, 0, buf); dlist_free(&dl); } @@ -890,11 +981,6 @@ EXPORTED int conversation_store(struct conversations_state *state, conv_to_buf(conv, &buf, state->counted_flags ? state->counted_flags->count : 0); - if (_sanity_check_counts(conv)) { - syslog(LOG_ERR, "IOERROR: conversations_audit on store: %s %.*s %.*s", - state->path, keylen, key, (int)buf.len, buf.s); - } - int r = cyrusdb_store(state->db, key, keylen, buf.s, buf.len, &state->txn); buf_free(&buf); @@ -902,7 +988,7 @@ EXPORTED int conversation_store(struct conversations_state *state, return r; } -static void _apply_delta(uint32_t *valp, int delta) +static void _apply_delta(uint32_t *valp, ssize_t delta) { if (delta >= 0) { *valp += delta; @@ -917,78 +1003,94 @@ static void _apply_delta(uint32_t *valp, int delta) } } -static int _conversation_save(struct conversations_state *state, - const char *key, int keylen, - conversation_t *conv, - struct emailcounts *ecounts) +static int _read_quota(struct conversations_state *state) { - const conv_folder_t *folder; - int r; + const char *data = NULL; + size_t datalen = 0; + bit64 val = 0; + int r = cyrusdb_fetch(state->db, "Q", 1, &data, &datalen, &state->txn); + if (r) return r; + if (datalen) { + const char *rest; + if (datalen < 12) return IMAP_MAILBOX_BADFORMAT; + if (memcmp(data, "1 %(E ", 6)) return IMAP_MAILBOX_BADFORMAT; + r = parsenum(data+6, &rest, datalen-6, &val); + if (r) return r; - /* see if any 'F' keys need to be changed */ - for (folder = conv->folders ; folder ; folder = folder->next) { - const char *mboxname = strarray_nth(state->folder_names, folder->number); - int exists_diff = 0; - int unseen_diff = 0; - int emailexists_diff = 0; - int emailunseen_diff = 0; - conv_status_t status = CONV_STATUS_INIT; - int unseen = conv->unseen; - int prev_unseen = conv->prev_unseen; - - if (folder->number == state->trashfolder) { - unseen = conv->trash_unseen; - prev_unseen = conv->prev_trash_unseen; - } + state->quota.emails = val; - /* case: full removal of conversation - make sure to remove - * unseen as well */ - if (folder->exists) { - if (folder->prev_exists) { - /* both exist, just check for unseen changes */ - unseen_diff = !!unseen - !!prev_unseen; - } - else { - /* adding, check if it's unseen */ - exists_diff = 1; - if (unseen) unseen_diff = 1; - } - } - else if (folder->prev_exists) { - /* removing, check if it WAS unseen */ - exists_diff = -1; - if (prev_unseen) unseen_diff = -1; - } - else { - /* we don't care about unseen if the cid is not registered - * in this folder, and wasn't previously either */ - } + datalen -= (rest - data); + data = rest; + if (datalen < 5) return IMAP_MAILBOX_BADFORMAT; + if (memcmp(data, " S ", 3)) return IMAP_MAILBOX_BADFORMAT; + r = parsenum(data+3, &rest, datalen-3, &val); + if (r) return r; - if (ecounts && !strcmp(ecounts->mboxname, mboxname)) { - // do we have email diffs? - emailexists_diff = !!ecounts->post_emailexists - !!ecounts->pre_emailexists; - emailunseen_diff = !!ecounts->post_emailunseen - !!ecounts->pre_emailunseen; - } + state->quota.storage = val; - /* XXX - it's super inefficient to be doing this for - * every cid in every folder in the transaction. Big - * wins available by caching these in memory and writing - * once at the end of the transaction */ - r = conversation_getstatus(state, mboxname, &status); - if (r) goto done; - if (exists_diff || unseen_diff - || emailexists_diff || emailunseen_diff - || status.threadmodseq < conv->modseq) { - if (status.threadmodseq < conv->modseq) - status.threadmodseq = conv->modseq; - _apply_delta(&status.threadexists, exists_diff); - _apply_delta(&status.threadunseen, unseen_diff); - _apply_delta(&status.emailexists, emailexists_diff); - _apply_delta(&status.emailunseen, emailunseen_diff); - r = conversation_setstatus(state, mboxname, &status); - if (r) goto done; - } + datalen -= (rest - data); + if (datalen != 1) return IMAP_MAILBOX_BADFORMAT; + if (*rest != ')') return IMAP_MAILBOX_BADFORMAT; + return 0; + } + return CYRUSDB_NOTFOUND; +} + +static int _write_quota(struct conversations_state *state) +{ + if (!state->quota_dirty) return 0; + + ssize_t emails = state->quota.emails; + ssize_t storage = state->quota.storage; + + // can't go negative (could happen before a rebuild) + if (emails < 0 || storage < 0) { + syslog(LOG_ERR, "IOERROR: conversations_audit on quota store: %s (%lld %lld)", + state->path, (long long)emails, (long long)storage); + emails = 0; + storage = 0; + } + + struct buf buf = BUF_INITIALIZER; + buf_printf(&buf, "1 %%(E %lld S %lld)", (long long)emails, (long long)storage); + int r = cyrusdb_store(state->db, "Q", 1, buf.s, buf.len, &state->txn); + buf_free(&buf); + + return r; +} + +EXPORTED int conversations_read_quota(struct conversations_state *state, struct conv_quota *q) +{ + if (!state->quota_loaded) { + memset(&state->quota, 0, sizeof(struct conv_quota)); + int r = _read_quota(state); + if (r && r != CYRUSDB_NOTFOUND) return r; + state->quota_loaded = 1; } + if (q) *q = state->quota; + return 0; +} + +static int _update_quotaused(struct conversations_state *state, int existsdiff, ssize_t quotadiff) +{ + // no change? No worries + if (!existsdiff && !quotadiff) return 0; + + int r = conversations_read_quota(state, NULL); + if (r) return r; + + state->quota.emails += existsdiff; + state->quota.storage += quotadiff; + state->quota_dirty = 1; // write on commit + + return 0; +} + +static int _conversation_save(struct conversations_state *state, + const char *key, int keylen, + conversation_t *conv) +{ + int r; if (conv->num_records) { r = conversation_store(state, key, keylen, conv); @@ -998,8 +1100,6 @@ static int _conversation_save(struct conversations_state *state, r = cyrusdb_delete(state->db, key, keylen, &state->txn, 1); } - -done: if (!r) conv->flags &= ~CONV_ISDIRTY; @@ -1008,8 +1108,7 @@ static int _conversation_save(struct conversations_state *state, EXPORTED int conversation_save(struct conversations_state *state, conversation_id_t cid, - conversation_t *conv, - struct emailcounts *ecounts) + conversation_t *conv) { char bkey[CONVERSATION_ID_STRMAX+2]; @@ -1025,7 +1124,7 @@ EXPORTED int conversation_save(struct conversations_state *state, snprintf(bkey, sizeof(bkey), "B" CONV_FMT, cid); - return _conversation_save(state, bkey, strlen(bkey), conv, ecounts); + return _conversation_save(state, bkey, strlen(bkey), conv); } struct convstatusrock { @@ -1039,7 +1138,7 @@ int _saxconvstatus(int type, struct dlistsax_data *d) if (type != DLISTSAX_STRING) return 0; switch (rock->state) { case 0: - rock->status->threadmodseq = atoll(d->data); + rock->status->threadmodseq = atomodseq_t(d->data); rock->state++; return 0; case 1: @@ -1085,7 +1184,7 @@ EXPORTED int conversation_parsestatus(const char *data, size_t datalen, rest++; /* skip space */ restlen = datalen - (rest - data); - if (version != CONVERSATIONS_VERSION) { + if (version != CONVERSATIONS_STATUS_VERSION) { /* XXX - an error code for "incorrect version"? */ return IMAP_MAILBOX_BADFORMAT; } @@ -1094,10 +1193,10 @@ EXPORTED int conversation_parsestatus(const char *data, size_t datalen, } EXPORTED int conversation_getstatus(struct conversations_state *state, - const char *mboxname, + const char *mailbox, conv_status_t *status) { - char *key = strconcat("F", mboxname, (char *)NULL); + char *key = strconcat("F", mailbox, (char *)NULL); const char *data; size_t datalen; int r = 0; @@ -1132,7 +1231,7 @@ EXPORTED int conversation_getstatus(struct conversations_state *state, done: if (r) - syslog(LOG_ERR, "IOERROR: conversations invalid status %s", mboxname); + syslog(LOG_ERR, "IOERROR: conversations invalid status %s", mailbox); free(key); @@ -1194,7 +1293,7 @@ int _saxconvparse(int type, struct dlistsax_data *d) case 1: // modseq if (type != DLISTSAX_STRING) return IMAP_MAILBOX_BADFORMAT; - rock->conv->modseq = atoll(d->data); + rock->conv->modseq = atomodseq_t(d->data); rock->state = 2; return 0; @@ -1216,7 +1315,6 @@ int _saxconvparse(int type, struct dlistsax_data *d) // unseen if (type != DLISTSAX_STRING) return IMAP_MAILBOX_BADFORMAT; rock->conv->unseen = atol(d->data); - rock->conv->prev_unseen = rock->conv->unseen; rock->state = 5; return 0; @@ -1268,7 +1366,7 @@ int _saxconvparse(int type, struct dlistsax_data *d) rock->substate = 1; return 0; case 1: - rock->folder->modseq = atoll(d->data); + rock->folder->modseq = atomodseq_t(d->data); rock->substate = 2; return 0; case 2: @@ -1277,7 +1375,6 @@ int _saxconvparse(int type, struct dlistsax_data *d) return 0; case 3: rock->folder->exists = atol(d->data); - rock->folder->prev_exists = rock->folder->exists; rock->substate = 4; return 0; case 4: @@ -1396,6 +1493,11 @@ int _saxconvparse(int type, struct dlistsax_data *d) rock->thread->internaldate = atol(d->data); rock->substate = 3; return 0; + + case 3: + rock->thread->createdmodseq = atoll(d->data); + rock->substate = 4; + return 0; } return 0; // there might be following fields that we ignore here @@ -1405,7 +1507,7 @@ int _saxconvparse(int type, struct dlistsax_data *d) return 0; } if (type != DLISTSAX_STRING) return IMAP_MAILBOX_BADFORMAT; - rock->conv->createdmodseq = atoll(d->data); + rock->conv->createdmodseq = atomodseq_t(d->data); rock->state = 19; return 0; @@ -1435,7 +1537,7 @@ EXPORTED int conversation_parse(const char *data, size_t datalen, rest++; /* skip space */ restlen = datalen - (rest - data); - if (version != CONVERSATIONS_VERSION) return IMAP_MAILBOX_BADFORMAT; + if (version > CONVERSATIONS_RECORD_VERSION) return IMAP_MAILBOX_BADFORMAT; struct convparserock rock = { conv, STRARRAY_INITIALIZER, NULL, NULL, NULL, 0, 0, flags }; @@ -1444,6 +1546,7 @@ EXPORTED int conversation_parse(const char *data, size_t datalen, if (r) return r; conv->flags = flags; + conv->version = version; return 0; } @@ -1474,19 +1577,11 @@ EXPORTED int conversation_load_advanced(struct conversations_state *state, r = conversation_parse(data, datalen, conv, flags); - if (r || ((conv->flags & CONV_WITHFOLDERS) && _sanity_check_counts(conv))) { - syslog(LOG_ERR, "IOERROR: conversations_audit on load: %s %s %.*s", + if (r) { + syslog(LOG_ERR, "IOERROR: conversations_audit parse error: %s %s %.*s", state->path, bkey, (int)datalen, data); } - const conv_folder_t *folder = conversation_get_folder(conv, state->trashfolder, /*create*/0); - if (folder) { - conv->trash_unseen = conv->prev_trash_unseen = folder->unseen; - } - else { - conv->trash_unseen = conv->prev_trash_unseen = 0; - } - return r; } @@ -1521,7 +1616,7 @@ static int _conversation_load_modseq(const char *data, int datalen, int r; r = parsenum(p, &p, (end-p), &version); - if (r || version != CONVERSATIONS_VERSION) + if (r || version > CONVERSATIONS_RECORD_VERSION) return IMAP_MAILBOX_BADFORMAT; if ((end - p) < 4 || p[0] != ' ' || p[1] != '(') @@ -1572,7 +1667,20 @@ EXPORTED conv_folder_t *conversation_find_folder(struct conversations_state *sta conversation_t *conv, const char *mboxname) { - int number = folder_number(state, mboxname, /*create*/0); + int number; + + if (state->folders_byname) + number = conversation_folder_number(state, mboxname, /*create*/0); + else { + mbentry_t *mbentry = NULL; + + mboxlist_lookup(mboxname, &mbentry, NULL); + if (!mbentry) return NULL; + + number = conversation_folder_number(state, mbentry->uniqueid, /*create*/0); + mboxlist_entry_free(&mbentry); + } + return conversation_get_folder(conv, number, /*create*/0); } @@ -1672,6 +1780,11 @@ static int _thread_datesort(const void **a, const void **b) if (r < 0) return -1; if (r > 0) return 1; + // if same internaldate, use createdmodseq for a stable sort + int64_t r2 = (ta->createdmodseq - tb->createdmodseq); + if (r2 < 0) return -1; + if (r2 > 0) return 1; + return message_guid_cmp(&ta->guid, &tb->guid); } @@ -1705,6 +1818,7 @@ static void conversations_thread_sort(conversation_t *conv) static void conversation_update_thread(conversation_t *conv, const struct message_guid *guid, time_t internaldate, + modseq_t createdmodseq, int delta_exists) { conv_thread_t *thread, **nextp = &conv->thread; @@ -1730,7 +1844,15 @@ static void conversation_update_thread(conversation_t *conv, } message_guid_copy(&thread->guid, guid); - thread->internaldate = internaldate; + // these should always be the same for all copies of an email! + // but if not (e.g. IMAP append) we want the earliest non-zero value + if (!thread->internaldate || thread->internaldate > internaldate) + thread->internaldate = internaldate; + // the same email may exist multiple times in a folder or in multiple + // folders with different createdmodseq. We want to track the earliest + // one + if (!thread->createdmodseq || thread->createdmodseq > createdmodseq) + thread->createdmodseq = createdmodseq; _apply_delta(&thread->exists, delta_exists); conversations_thread_sort(conv); @@ -1744,7 +1866,7 @@ EXPORTED void conversation_update_sender(conversation_t *conv, const char *mailbox, const char *domain, time_t lastseen, - int delta_exists) + ssize_t delta_exists) { conv_sender_t *sender, *ptr, **nextp = &conv->senders; @@ -1825,38 +1947,20 @@ EXPORTED void conversation_update_sender(conversation_t *conv, conv->flags |= CONV_ISDIRTY; } -static int _match1(void *rock, - const char *key __attribute__((unused)), - size_t keylen __attribute__((unused)), - const char *data __attribute__((unused)), - size_t datalen __attribute__((unused))) -{ - int *match = (int *)rock; - *match = 1; - return CYRUSDB_DONE; -} - -EXPORTED int conversations_guid_exists(struct conversations_state *state, - const char *guidrep) -{ - int match = 0; - - char *key = strconcat("G", guidrep, (char *)NULL); - cyrusdb_foreach(state->db, key, strlen(key), NULL, _match1, &match, NULL); - free(key); - - return match; -} - struct guid_foreach_rock { struct conversations_state *state; int(*cb)(const conv_guidrec_t *, void *); void *cbrock; + const void *filterdata; + int filternum; + int filterpos; + struct buf partbuf; }; -static int _guid_one(const char *item, - struct guid_foreach_rock *frock, +static int _guid_one(struct guid_foreach_rock *frock, + const char *key, conversation_id_t cid, + conversation_id_t basecid, uint32_t system_flags, uint32_t internal_flags, time_t internaldate, @@ -1867,21 +1971,27 @@ static int _guid_one(const char *item, uint32_t res; /* Set G record values */ + rec.cstate = frock->state; + rec.guidrep = key+1; rec.cid = cid; + rec.basecid = basecid; rec.system_flags = system_flags; rec.internal_flags = internal_flags; rec.internaldate = internaldate; rec.version = version; + /* ensure a NULL terminated key string */ + buf_cstring(&frock->partbuf); + char *item = frock->partbuf.s; + /* Parse G record key */ p = strchr(item, ':'); if (!p) return IMAP_INTERNAL; - /* mboxname */ + /* folder number */ int r = parseuint32(item, &err, &res); if (r || err != p) return IMAP_INTERNAL; - rec.mboxname = strarray_safenth(frock->state->folder_names, res); - if (!rec.mboxname) return IMAP_INTERNAL; + rec.foldernum = res; /* uid */ r = parseuint32(p + 1, &err, &res); @@ -1891,20 +2001,41 @@ static int _guid_one(const char *item, /* part */ rec.part = NULL; - char *freeme = NULL; if (*p) { - const char *end = strchr(p+1, ']'); + char *end = strchr(p+1, ']'); if (*p != '[' || !end || p+1 == end) { return IMAP_INTERNAL; } - rec.part = freeme = xstrndup(p+1, end-p-1); + // overwrite the end of the part in the buffer buffer to avoid double-dupe + *end = '\0'; + rec.part = p+1; } r = frock->cb(&rec, frock->cbrock); - free(freeme); return r; } +static int _guid_filter_p(void *rock, + const char *key, + size_t keylen, + const char *data __attribute__((unused)), + size_t datalen __attribute__((unused))) +{ + if (keylen < 41) return 0; // bogus record?? + + struct guid_foreach_rock *frock = (struct guid_foreach_rock *)rock; + + for (; frock->filterpos < frock->filternum; frock->filterpos++) { + int cmp = memcmp(frock->filterdata + (41 * frock->filterpos), key+1, 40); + if (cmp > 0) break; // definitely not a match + if (cmp == 0) return 1; // match. + /* We don't also increment for a match because multiple rows + * could have the same GUID */ + } + + return 0; // no match +} + static int _guid_cb(void *rock, const char *key, size_t keylen, @@ -1922,7 +2053,8 @@ static int _guid_cb(void *rock, strarray_t *recs = strarray_nsplit(data, datalen, ",", /*flags*/0); int i; for (i = 0; i < recs->count; i++) { - r = _guid_one(strarray_nth(recs, i), frock, /*cid*/0, + buf_setcstr(&frock->partbuf, strarray_nth(recs, i)); + r = _guid_one(frock, key, /*cid*/0, /*basecid*/0, /*system_flags*/0, /*internal_flags*/0, /*internaldate*/0, /*version*/0); if (r) break; @@ -1936,6 +2068,7 @@ static int _guid_cb(void *rock, return IMAP_INTERNAL; conversation_id_t cid = 0; + conversation_id_t basecid = 0; uint32_t system_flags = 0; uint32_t internal_flags = 0; time_t internaldate = 0; @@ -1947,12 +2080,30 @@ static int _guid_cb(void *rock, if (*p & 0x80) version = *p & 0x7f; if (version > 0) p++; - if (version == 0) { + switch (version) { + case 0: /* cid */ r = parsehex(p, &p, 16, &cid); if (r) return r; - } - else { + break; + + case 1: /* OLD - byname version */ + case 2: /* original byid version (no basecid) */ + /* cid */ + cid = ntohll(*((bit64*)p)); + p += 8; + /* system_flags */ + system_flags = ntohl(*((bit32*)p)); + p += 4; + /* internal flags */ + internal_flags = ntohl(*((bit32*)p)); + p += 4; + /* internaldate*/ + internaldate = (time_t) ntohll(*((bit64*)p)); + p += 8; + break; + + default: /* cid */ cid = ntohll(*((bit64*)p)); p += 8; @@ -1965,13 +2116,38 @@ static int _guid_cb(void *rock, /* internaldate*/ internaldate = (time_t) ntohll(*((bit64*)p)); p += 8; + /* basecid */ + basecid = ntohll(*((bit64*)p)); + p += 8; + break; } } - char *freeme = xstrndup(key+42, keylen-42); - r = _guid_one(freeme, frock, cid, system_flags, internal_flags, + buf_setmap(&frock->partbuf, key+42, keylen-42); + r = _guid_one(frock, key, cid, basecid, system_flags, internal_flags, internaldate, version); - free(freeme); + + return r; +} + +static int _guid_foreach_helper(struct conversations_state *state, + const char *prefix, + int(*cb)(const conv_guidrec_t *, void *), + void *cbrock, + const void *data, + size_t num) +{ + struct guid_foreach_rock rock = { + state, cb, cbrock, data, num, 0, BUF_INITIALIZER + }; + + foreach_p *filter = data ? _guid_filter_p : NULL; + + char *key = strconcat("G", prefix, (char *)NULL); + int r = cyrusdb_foreach(state->db, key, strlen(key), filter, _guid_cb, &rock, &state->txn); + free(key); + + buf_free(&rock.partbuf); return r; } @@ -1981,34 +2157,67 @@ EXPORTED int conversations_guid_foreach(struct conversations_state *state, int(*cb)(const conv_guidrec_t *, void *), void *cbrock) { - struct guid_foreach_rock rock; - rock.state = state; - rock.cb = cb; - rock.cbrock = cbrock; - - char *key = strconcat("G", guidrep, (char *)NULL); - int r = cyrusdb_foreach(state->db, key, strlen(key), NULL, _guid_cb, &rock, &state->txn); - free(key); + return _guid_foreach_helper(state, guidrep, cb, cbrock, NULL, 0); +} - return r; +EXPORTED int conversations_iterate_searchset(struct conversations_state *state, + const void *data, size_t n, + int(*cb)(const conv_guidrec_t*,void*), + void *cbrock) +{ + // magic number to switch from index mode to scan mode + size_t limit = config_getint(IMAPOPT_SEARCH_QUERYSCAN); + if (limit && n > limit) { + size_t estimate = conversations_estimate_emailcount(state); + if (estimate > n*20) { // 5% matches is enough to to iterate! + syslog(LOG_DEBUG, "conversation_iterate_searchset: %s falling back to index for %d/%d records", + state->path, (int)n, (int)estimate); + } + else { + syslog(LOG_DEBUG, "conversation_iterate_searchset: %s using scan mode for %d/%d records", + state->path, (int)n, (int)estimate); + return _guid_foreach_helper(state, "", cb, cbrock, data, n); + } + } + else { + syslog(LOG_DEBUG, "conversation_iterate_searchset: %s using indexed mode for %d records", + state->path, (int)n); + } + size_t i; + for (i = 0; i < n; i++) { + const char *guidrep = data + (i*41); + int r = conversations_guid_foreach(state, guidrep, cb, cbrock); + if (r) return r; + } + return 0; } +struct cidlookupdata { + struct index_record *record; + int found; +}; + static int _getcid(const conv_guidrec_t *rec, void *rock) { - conversation_id_t *cidp = (conversation_id_t *)rock; - if (!rec->part) { - *cidp = rec->cid; + struct cidlookupdata *data = (struct cidlookupdata *)rock; + if (!rec->part && rec->cid) { + if (data->record) { + data->record->cid = rec->cid; + data->record->basecid = rec->basecid; + } + data->found = 1; return CYRUSDB_DONE; } return 0; } -EXPORTED conversation_id_t conversations_guid_cid_lookup(struct conversations_state *state, - const char *guidrep) +EXPORTED int conversations_guid_cid_lookup(struct conversations_state *state, + const char *guidrep, + struct index_record *record) { - conversation_id_t cid = 0; - conversations_guid_foreach(state, guidrep, _getcid, &cid); - return cid; + struct cidlookupdata rock = { record, 0 }; + conversations_guid_foreach(state, guidrep, _getcid, &rock); + return rock.found; } @@ -2016,6 +2225,7 @@ static int conversations_guid_setitem(struct conversations_state *state, const char *guidrep, const char *item, conversation_id_t cid, + conversation_id_t basecid, uint32_t system_flags, uint32_t internal_flags, time_t internaldate, @@ -2055,13 +2265,24 @@ static int conversations_guid_setitem(struct conversations_state *state, buf_appendcstr(&key, item); if (add) { - /* When bumping the G value version, make sure to update _guid_cb */ struct buf val = BUF_INITIALIZER; - buf_putc(&val, 0x80 | CONV_GUIDREC_VERSION); - buf_appendbit64(&val, cid); - buf_appendbit32(&val, system_flags); - buf_appendbit32(&val, internal_flags); - buf_appendbit64(&val, (bit64)internaldate); + if (state->folders_byname) { + buf_putc(&val, 0x80 | CONV_GUIDREC_BYNAME_VERSION); + buf_appendbit64(&val, cid); + buf_appendbit32(&val, system_flags); + buf_appendbit32(&val, internal_flags); + buf_appendbit64(&val, (bit64)internaldate); + } + /* When bumping the G value version, make sure to update _guid_cb */ + else { + buf_putc(&val, 0x80 | CONV_GUIDREC_VERSION); + buf_appendbit64(&val, cid); + buf_appendbit32(&val, system_flags); + buf_appendbit32(&val, internal_flags); + buf_appendbit64(&val, (bit64)internaldate); + buf_appendbit64(&val, basecid); + } + r = cyrusdb_store(state->db, buf_base(&key), buf_len(&key), buf_base(&val), buf_len(&val), &state->txn); @@ -2079,6 +2300,7 @@ static int conversations_guid_setitem(struct conversations_state *state, static int _guid_addbody(struct conversations_state *state, conversation_id_t cid, + conversation_id_t basecid, uint32_t system_flags, uint32_t internal_flags, time_t internaldate, struct body *body, @@ -2095,7 +2317,7 @@ static int _guid_addbody(struct conversations_state *state, buf_setcstr(&buf, base); buf_printf(&buf, "[%s]", body->part_id); const char *guidrep = message_guid_encode(&body->content_guid); - r = conversations_guid_setitem(state, guidrep, buf_cstring(&buf), cid, + r = conversations_guid_setitem(state, guidrep, buf_cstring(&buf), cid, basecid, system_flags, internal_flags, internaldate, add); buf_free(&buf); @@ -2103,11 +2325,11 @@ static int _guid_addbody(struct conversations_state *state, if (r) return r; } - r = _guid_addbody(state, cid, system_flags, internal_flags, internaldate, body->subpart, base, add); + r = _guid_addbody(state, cid, basecid, system_flags, internal_flags, internaldate, body->subpart, base, add); if (r) return r; for (i = 1; i < body->numparts; i++) { - r = _guid_addbody(state, cid, system_flags, internal_flags, internaldate, body->subpart + i, base, add); + r = _guid_addbody(state, cid, basecid, system_flags, internal_flags, internaldate, body->subpart + i, base, add); if (r) return r; } @@ -2119,7 +2341,9 @@ static int conversations_set_guid(struct conversations_state *state, const struct index_record *record, int add) { - int folder = folder_number(state, mailbox->name, /*create*/1); + int folder = conversation_folder_number(state, + CONV_FOLDER_KEY_MBOX(state, mailbox), + /*create*/1); struct buf item = BUF_INITIALIZER; struct body *body = NULL; int r = 0; @@ -2134,88 +2358,95 @@ static int conversations_set_guid(struct conversations_state *state, const char *base = buf_cstring(&item); r = conversations_guid_setitem(state, message_guid_encode(&record->guid), - base, record->cid, + base, record->cid, record->basecid, record->system_flags, record->internal_flags, record->internaldate, add); - if (!r) r = _guid_addbody(state, record->cid, + if (!r) r = _guid_addbody(state, record->cid, record->basecid, record->system_flags, record->internal_flags, record->internaldate, body, base, add); - if (!r && (mailbox->mbtype == MBTYPE_ADDRESSBOOK) && - !strcmp(body->type, "TEXT") && !strcmp(body->subtype, "VCARD")) { - - struct vparse_card *vcard = record_to_vcard(mailbox, record); - - if (vcard) { - struct message_guid guid; - struct vparse_entry *photo = - vparse_get_entry(vcard->objects, NULL, "photo"); - - if (photo && vcard_prop_decode_value(photo, NULL, NULL, &guid)) { - buf_printf(&item, "[%s/VCARD#PHOTO]", body->part_id); - r = conversations_guid_setitem(state, message_guid_encode(&guid), - buf_cstring(&item), 0 /*cid*/, - record->system_flags, - record->internal_flags, - record->internaldate, - add); - } - - vparse_free_card(vcard); - } - } - message_free_body(body); free(body); buf_free(&item); return r; } +struct read_emailcounts_rock { + struct conversations_state *cstate; + struct emailcounts *ecounts; +}; + static int _read_emailcounts_cb(const conv_guidrec_t *rec, void *rock) { - struct emailcounts *ecounts = (struct emailcounts *)rock; if (rec->part) return 0; - if (strcmp(ecounts->mboxname, rec->mboxname)) return 0; - // ok, we're in the same folder - are we expunged? + // only count records with cids, otherwise the zero and rebuild fails + if (!rec->cid) return 0; + + struct emailcounts *ecounts = ((struct read_emailcounts_rock*)rock)->ecounts; + struct conversations_state *cstate = ((struct read_emailcounts_rock*)rock)->cstate; + + struct emailcountitems *i = ecounts->ispost ? &ecounts->post : &ecounts->pre; + + i->numrecords++; + if (rec->foldernum == ecounts->foldernum) i->foldernumrecords++; + + // only count in regular IMAP mailboxes + if (!bv_isset(&ecounts->mbtype_known, rec->foldernum)) { + const char *mboxname = conversations_folder_mboxname(cstate, rec->foldernum); + if (mboxname && !mboxname_isnondeliverymailbox(mboxname, 0)) { + bv_set(&ecounts->mbtype_mail, rec->foldernum); + } + bv_set(&ecounts->mbtype_known, rec->foldernum); + } + if (!bv_isset(&ecounts->mbtype_mail, rec->foldernum)) + return 0; + + // the rest only counts non-deleted records if (rec->version > 0 && (rec->system_flags & FLAG_DELETED || rec->internal_flags & FLAG_INTERNAL_EXPUNGED)) return 0; - if (ecounts->ispost) { - // not expunged or unsure, count it as exists - ecounts->post_emailexists++; - // not seen or unsure, count it as unseen - if (rec->version == 0 || !(rec->system_flags & (FLAG_SEEN|FLAG_DRAFT))) - ecounts->post_emailunseen++; - } - else { - // not expunged or unsure, count it as exists - ecounts->pre_emailexists++; - // not seen or unsure, count it as unseen - if (rec->version == 0 || !(rec->system_flags & (FLAG_SEEN|FLAG_DRAFT))) - ecounts->pre_emailunseen++; + + i->exists++; + if (rec->foldernum == ecounts->foldernum) i->folderexists++; + bv_set(&ecounts->exists_foldernums, rec->foldernum); + + // not seen or unsure, count it as unseen + if (rec->version == 0 || !(rec->system_flags & (FLAG_SEEN|FLAG_DRAFT))) { + if (rec->foldernum != ecounts->trashfolder) i->unseen++; + if (rec->foldernum == ecounts->foldernum) i->folderunseen++; } + return 0; } +EXPORTED void emailcounts_fini(struct emailcounts *ecounts) +{ + bv_fini(&ecounts->exists_foldernums); + bv_fini(&ecounts->mbtype_known); + bv_fini(&ecounts->mbtype_mail); + static struct emailcounts init = EMAILCOUNTS_INIT; + *ecounts = init; +} + EXPORTED int conversations_update_record(struct conversations_state *cstate, struct mailbox *mailbox, const struct index_record *old, struct index_record *new, - int allowrenumber) + int allowrenumber, + int ignorelimits, + int silent) { conversation_t *conv = NULL; - int delta_num_records = 0; - int delta_exists = 0; - int delta_unseen = 0; - int is_trash = 0; - int delta_size = 0; + size_t delta_exists = 0; + size_t delta_size = 0; int *delta_counts = NULL; int i; modseq_t modseq = 0; int r = 0; + struct emailcounts ecounts = EMAILCOUNTS_INIT; if (old && new) { /* we're always moving forwards */ @@ -2230,9 +2461,9 @@ EXPORTED int conversations_update_record(struct conversations_state *cstate, * a removal and re-add, so cache gets parsed and msgids * updated */ if (old->cid != new->cid) { - r = conversations_update_record(cstate, mailbox, old, NULL, 0); + r = conversations_update_record(cstate, mailbox, old, NULL, 0, ignorelimits, silent); if (r) return r; - return conversations_update_record(cstate, mailbox, NULL, new, 0); + return conversations_update_record(cstate, mailbox, NULL, new, 0, ignorelimits, silent); } } @@ -2242,31 +2473,43 @@ EXPORTED int conversations_update_record(struct conversations_state *cstate, if (new && !old && allowrenumber) { /* add the conversation */ r = mailbox_cacherecord(mailbox, new); /* make sure it's loaded */ - if (r) return r; + if (r) goto done; r = message_update_conversations(cstate, mailbox, new, &conv); - if (r) return r; + if (r) goto done; } else if (record->cid) { r = conversation_load(cstate, record->cid, &conv); - if (r) return r; + if (r) goto done; + /* add subject if it loses draft flag */ + if (conv && !conv->subject && !(record->system_flags & FLAG_DRAFT)) { + r = mailbox_cacherecord(mailbox, record); + if (r) goto done; + conv->subject = message_extract_convsubject(record); + } if (!conv) { if (!new) { /* We're trying to delete a conversation that's already * gone...don't try to hard */ syslog(LOG_NOTICE, "conversation "CONV_FMT" already " "deleted, ignoring", record->cid); - return 0; + goto done; } conv = conversation_new(); } } - struct emailcounts ecounts = EMAILCOUNTS_INIT; + ecounts.foldernum = + conversation_folder_number(cstate, CONV_FOLDER_KEY_MBOX(cstate, mailbox), /*create*/1); + ecounts.trashfolder = cstate->trashfolder; + bv_setsize(&ecounts.exists_foldernums, conversations_num_folders(cstate)); + bv_setsize(&ecounts.mbtype_known, conversations_num_folders(cstate)); + bv_setsize(&ecounts.mbtype_mail, conversations_num_folders(cstate)); + /* count the email state before making GUID changes */ - ecounts.mboxname = mailbox->name; + struct read_emailcounts_rock rock = { cstate, &ecounts }; r = conversations_guid_foreach(cstate, message_guid_encode(&record->guid), - _read_emailcounts_cb, &ecounts); - if (r) return r; + _read_emailcounts_cb, &rock); + if (r) goto done; // always update the GUID information first, as it's used for search // even if conversations have not been set on this email @@ -2275,37 +2518,66 @@ EXPORTED int conversations_update_record(struct conversations_state *cstate, old->internal_flags != new->internal_flags || old->internaldate != new->internaldate) { r = conversations_set_guid(cstate, mailbox, new, /*add*/1); - if (r) return r; + if (r) goto done; } } else { if (old) { r = conversations_set_guid(cstate, mailbox, old, /*add*/0); - if (r) return r; + if (r) goto done; } } - // the rest is bookkeeping purely for CIDed messages - if (!record->cid) return 0; + /* we've made any set_guid, so count the state again! */ + ecounts.ispost = 1; + r = conversations_guid_foreach(cstate, message_guid_encode(&record->guid), + _read_emailcounts_cb, &rock); + if (r) goto done; - /* IRIS-2534: check if it's the trash folder - XXX - should be separate - * conversation root or similar more useful method in future */ - is_trash = mboxname_isusertrash(mailbox->name); + // check sanity limits + if (!ignorelimits && ecounts.post.numrecords > ecounts.pre.numrecords) { + if (ecounts.post.numrecords > (size_t)config_getint(IMAPOPT_CONVERSATIONS_MAX_GUIDRECORDS)) + r = IMAP_CONVERSATION_GUIDLIMIT; + if (ecounts.post.exists > (size_t)config_getint(IMAPOPT_CONVERSATIONS_MAX_GUIDEXISTS)) + r = IMAP_CONVERSATION_GUIDLIMIT; + if (ecounts.post.folderexists > (size_t)config_getint(IMAPOPT_CONVERSATIONS_MAX_GUIDINFOLDER)) + r = IMAP_CONVERSATION_GUIDLIMIT; + if (r) { + syslog(LOG_ERR, "IOERROR: conversations GUID limit for %s/%u/%s (%llu %llu %llu)", + mailbox_name(mailbox), record->uid, + message_guid_encode(&record->guid), + (long long unsigned)ecounts.post.numrecords, + (long long unsigned)ecounts.post.exists, + (long long unsigned)ecounts.post.folderexists); + goto done; + } + } + + // set up deltas + if (ecounts.pre.exists) { + delta_exists -= 1; + delta_size -= record->size; + } + if (ecounts.post.exists) { + delta_exists += 1; + delta_size += record->size; + } + + // update quotas + r = _update_quotaused(cstate, delta_exists, delta_size); + if (r) goto done; + + // the rest is bookkeeping purely for CIDed messages + if (!record->cid) goto done; if (cstate->counted_flags) delta_counts = xzmalloc(sizeof(int) * cstate->counted_flags->count); /* calculate the changes */ if (old) { - /* decrease any relevent counts */ + /* decrease any relevant counts */ if (!(old->internal_flags & FLAG_INTERNAL_EXPUNGED) && !(old->system_flags & FLAG_DELETED)) { - delta_exists--; - delta_size -= old->size; - /* drafts don't update the 'unseen' counter so that - * they never turn a conversation "unread" */ - if (!(old->system_flags & (FLAG_SEEN|FLAG_DRAFT))) - delta_unseen--; if (cstate->counted_flags) { for (i = 0; i < cstate->counted_flags->count; i++) { const char *flag = strarray_nth(cstate->counted_flags, i); @@ -2314,7 +2586,6 @@ EXPORTED int conversations_update_record(struct conversations_state *cstate, } } } - delta_num_records--; modseq = MAX(modseq, old->modseq); } @@ -2322,12 +2593,6 @@ EXPORTED int conversations_update_record(struct conversations_state *cstate, /* add any counts */ if (!(new->internal_flags & FLAG_INTERNAL_EXPUNGED) && !(new->system_flags & FLAG_DELETED)) { - delta_exists++; - delta_size += new->size; - /* drafts don't update the 'unseen' counter so that - * they never turn a conversation "unread" */ - if (!(new->system_flags & (FLAG_SEEN|FLAG_DRAFT))) - delta_unseen++; if (cstate->counted_flags) { for (i = 0; i < cstate->counted_flags->count; i++) { const char *flag = strarray_nth(cstate->counted_flags, i); @@ -2336,16 +2601,9 @@ EXPORTED int conversations_update_record(struct conversations_state *cstate, } } } - delta_num_records++; modseq = MAX(modseq, new->modseq); } - /* we've made any set_guid, so count the state again! */ - ecounts.ispost = 1; - r = conversations_guid_foreach(cstate, message_guid_encode(&record->guid), - _read_emailcounts_cb, &ecounts); - if (r) return r; - /* XXX - combine this with the earlier cache parsing */ if (!mailbox_cacherecord(mailbox, record)) { char *env = NULL; @@ -2375,56 +2633,87 @@ EXPORTED int conversations_update_record(struct conversations_state *cstate, conversation_update_thread(conv, &record->guid, record->internaldate, + record->createdmodseq, delta_exists); - conversation_update(cstate, conv, mailbox->name, - is_trash, delta_num_records, - delta_exists, delta_unseen, - delta_size, delta_counts, modseq, - record->createdmodseq); + r = conversation_update(cstate, conv, &ecounts, + delta_size, delta_counts, + modseq, record->createdmodseq, silent); + if (r) goto done; - r = conversation_save(cstate, record->cid, conv, &ecounts); + r = conversation_save(cstate, record->cid, conv); +done: + emailcounts_fini(&ecounts); conversation_free(conv); free(delta_counts); return r; } -EXPORTED void conversation_update(struct conversations_state *state, - conversation_t *conv, const char *mboxname, - int is_trash, int delta_num_records, - int delta_exists, int delta_unseen, - int delta_size, int *delta_counts, - modseq_t modseq, modseq_t createdmodseq) +EXPORTED int conversation_update(struct conversations_state *state, + conversation_t *conv, struct emailcounts *ecounts, + ssize_t delta_size, int *delta_counts, + modseq_t modseq, modseq_t createdmodseq, int silent) { - conv_folder_t *folder; - int number = folder_number(state, mboxname, /*create*/1); - int i; + conv_folder_t *folder = conversation_get_folder(conv, ecounts->foldernum, /*create*/1); - folder = conversation_get_folder(conv, number, /*create*/1); + // only count one per instance of the GUID in each folder, and in total + int delta_num_records = !!ecounts->post.numrecords - !!ecounts->pre.numrecords; + int delta_exists = !!ecounts->post.exists - !!ecounts->pre.exists; + int delta_unseen = !!ecounts->post.unseen - !!ecounts->pre.unseen; + int delta_folder_records = !!ecounts->post.foldernumrecords - !!ecounts->pre.foldernumrecords; + int delta_folder_exists = !!ecounts->post.folderexists - !!ecounts->pre.folderexists; + int delta_folder_unseen = !!ecounts->post.folderunseen - !!ecounts->pre.folderunseen; + // version 0 counted every UID rather than by GUID + if (conv->version < 1) { + delta_num_records = ecounts->post.numrecords - ecounts->pre.numrecords; + delta_exists = ecounts->post.exists - ecounts->pre.exists; + delta_unseen = ecounts->post.unseen - ecounts->pre.unseen; + delta_folder_records = ecounts->post.foldernumrecords - ecounts->pre.foldernumrecords; + delta_folder_exists = ecounts->post.folderexists - ecounts->pre.folderexists; + delta_folder_unseen = ecounts->post.folderunseen - ecounts->pre.folderunseen; + } + + int oldfolderexists = folder->exists; + int oldfolderunseen = folder->unseen; + int oldunseen = conv->unseen; + + int is_trash = (ecounts->foldernum == state->trashfolder); + + /* update the conversation tracking values for the whole conversation */ if (delta_num_records) { _apply_delta(&conv->num_records, delta_num_records); - _apply_delta(&folder->num_records, delta_num_records); conv->flags |= CONV_ISDIRTY; } if (delta_exists) { _apply_delta(&conv->exists, delta_exists); - _apply_delta(&folder->exists, delta_exists); conv->flags |= CONV_ISDIRTY; } if (delta_unseen) { - if (is_trash) _apply_delta(&conv->trash_unseen, delta_unseen); - else _apply_delta(&conv->unseen, delta_unseen); - _apply_delta(&folder->unseen, delta_unseen); + _apply_delta(&conv->unseen, delta_unseen); conv->flags |= CONV_ISDIRTY; } if (delta_size) { _apply_delta(&conv->size, delta_size); conv->flags |= CONV_ISDIRTY; } + if (delta_folder_records) { + _apply_delta(&folder->num_records, delta_folder_records); + conv->flags |= CONV_ISDIRTY; + } + if (delta_folder_exists) { + _apply_delta(&folder->exists, delta_folder_exists); + conv->flags |= CONV_ISDIRTY; + } + if (delta_folder_unseen) { + _apply_delta(&folder->unseen, delta_folder_unseen); + conv->flags |= CONV_ISDIRTY; + } + if (state->counted_flags) { + int i; for (i = 0; i < state->counted_flags->count; i++) { if (delta_counts[i]) { _apply_delta(&conv->counts[i], delta_counts[i]); @@ -2444,6 +2733,118 @@ EXPORTED void conversation_update(struct conversations_state *state, conv->createdmodseq = createdmodseq; conv->flags |= CONV_ISDIRTY; } + + // now update all the folder counts + + int dexists = !!ecounts->post.folderexists - !!ecounts->pre.folderexists; + int dfolderunseen = !!ecounts->post.folderunseen - !!ecounts->pre.folderunseen; + int dfolderexists = !!ecounts->post.folderexists - !!ecounts->pre.folderexists; + int dunseen = !!ecounts->post.unseen - !!ecounts->pre.unseen; + int dthreadexists = !!folder->exists - !!oldfolderexists; + int dthreadunseen = !!conv->unseen - !!oldunseen; + if (is_trash) dthreadunseen = !!folder->unseen - !!oldfolderunseen; + + + for (folder = conv->folders; folder; folder = folder->next) { + conv_status_t status; + const char *mailbox = strarray_nth(state->folders, folder->number); + + int r = conversation_getstatus(state, mailbox, &status); + if (r) return r; + + int dirty = 0; + + // all counts apply to the current folder + if (folder->number == ecounts->foldernum) { + if (dexists) { + _apply_delta(&status.emailexists, dexists); + dirty = 1; + } + + if (folder->number != state->trashfolder) { + if (dfolderexists > 0 && ecounts->post.unseen) { + // email just got added here and is unseen + _apply_delta(&status.emailunseen, 1); + dirty = 1; + } + else if (dfolderexists < 0 && ecounts->pre.unseen) { + // email just got deleted here and was unseen + _apply_delta(&status.emailunseen, -1); + dirty = 1; + } + else if (dfolderexists == 0 && dunseen > 0) { + // email was seen and got unseen + _apply_delta(&status.emailunseen, 1); + dirty = 1; + } + else if (dfolderexists == 0 && dunseen < 0) { + // email was unseen and got seen + _apply_delta(&status.emailunseen, -1); + dirty = 1; + } + } + else if (dfolderunseen) { + // trash only counts its own unseen emails + _apply_delta(&status.emailunseen, dfolderunseen); + dirty = 1; + } + + if (dthreadexists) { + _apply_delta(&status.threadexists, dthreadexists); + dirty = 1; + } + + // case - adding a seen email to a new folder, but the thread is unseen + if (folder->exists && !oldfolderexists && (is_trash ? folder->unseen : conv->unseen)) { + _apply_delta(&status.threadunseen, 1); + dirty = 1; + } + // case - removing the last message from a folder, and the thread remains unseen + else if (!folder->exists && oldfolderexists && (is_trash ? oldfolderunseen : oldunseen)) { + _apply_delta(&status.threadunseen, -1); + dirty = 1; + } + // case there's an email in this folder, and the thread has changed + else if (folder->exists && dthreadunseen) { + _apply_delta(&status.threadunseen, dthreadunseen); + dirty = 1; + } + } + // unseen changes apply to all other folders except trash if this isn't the trash folder + else if (!is_trash && folder->number != state->trashfolder && folder->exists) { + + if (bv_isset(&ecounts->exists_foldernums, folder->number)) { + if (dunseen > 0) { + // email was seen and got unseen + _apply_delta(&status.emailunseen, 1); + dirty = 1; + } + if (dunseen < 0) { + // email was unseen and got seen + _apply_delta(&status.emailunseen, -1); + dirty = 1; + } + } + + if (dthreadunseen) { + _apply_delta(&status.threadunseen, dthreadunseen); + dirty = 1; + } + + } + + if (!silent && status.threadmodseq < modseq) { + status.threadmodseq = modseq; + dirty = 1; + } + + if (dirty) { + r = conversation_setstatus(state, mailbox, &status); + if (r) return r; + } + } + + return 0; } EXPORTED conversation_t *conversation_new() @@ -2451,6 +2852,7 @@ EXPORTED conversation_t *conversation_new() conversation_t *conv; conv = xzmalloc(sizeof(conversation_t)); + conv->version = CONVERSATIONS_RECORD_VERSION; conv->flags |= CONV_ISDIRTY; xstats_inc(CONV_NEW); @@ -2470,10 +2872,10 @@ EXPORTED void conversation_fini(conversation_t *conv) conv_sender_t *sender; while ((sender = conv->senders)) { conv->senders = sender->next; - xfree(sender->name); - xfree(sender->route); - xfree(sender->mailbox); - xfree(sender->domain); + free(sender->name); + free(sender->route); + free(sender->mailbox); + free(sender->domain); free(sender); } @@ -2494,6 +2896,8 @@ EXPORTED void conversation_free(conversation_t *conv) struct prune_rock { struct conversations_state *state; + arrayu64_t *cidfilter; + arrayu64_t cids; time_t thresh; unsigned int nseen; unsigned int ndeleted; @@ -2504,7 +2908,6 @@ static int prunecb(void *rock, const char *data, size_t datalen) { struct prune_rock *prock = (struct prune_rock *)rock; - arrayu64_t cids = ARRAYU64_INITIALIZER; time_t stamp; int r; @@ -2512,12 +2915,23 @@ static int prunecb(void *rock, r = check_msgid(key, keylen, NULL); if (r) goto done; - r = _conversations_parse(data, datalen, &cids, &stamp); + arrayu64_truncate(&prock->cids, 0); + + r = _conversations_parse(data, datalen, &prock->cids, &stamp); if (r) goto done; - /* keep records newer than the threshold */ - if (stamp >= prock->thresh) - goto done; + if (prock->cidfilter) { + size_t i; + for (i = 0; i < arrayu64_size(&prock->cids); i++) { + if (arrayu64_bsearch(prock->cidfilter, arrayu64_nth(&prock->cids, i))) + goto done; // found a match + } + } + else { + /* keep records newer than the threshold */ + if (stamp >= prock->thresh) + goto done; + } prock->ndeleted++; @@ -2527,18 +2941,45 @@ static int prunecb(void *rock, /*force*/1); done: - arrayu64_fini(&cids); return r; } +static int addknowncid(void *rock, + const char *key, size_t keylen, + const char *data __attribute__((unused)), + size_t datalen __attribute__((unused))) +{ + arrayu64_t *knowncids = (arrayu64_t *)rock; + conversation_id_t cid; + const char *rest; + + // errors - just skip them, they won't be readable! + if (keylen != 17 || key[0] != 'B') return 0; + int r = parsehex(key+1, &rest, 16, &cid); + if (!r) arrayu64_append(knowncids, cid); + return 0; +} + EXPORTED int conversations_prune(struct conversations_state *state, time_t thresh, unsigned int *nseenp, unsigned int *ndeletedp) { - struct prune_rock rock = { state, thresh, 0, 0 }; + struct prune_rock rock = { state, NULL, ARRAYU64_INITIALIZER, thresh, 0, 0 }; + + if (config_getswitch(IMAPOPT_CONVERSATIONS_KEEP_EXISTING)) { + // these will be added in CID order, so we don't need to sort them + rock.cidfilter = arrayu64_new(); + cyrusdb_foreach(state->db, "B", 1, NULL, addknowncid, rock.cidfilter, &state->txn); + } cyrusdb_foreach(state->db, "<", 1, NULL, prunecb, &rock, &state->txn); + arrayu64_fini(&rock.cids); + if (rock.cidfilter) { + arrayu64_fini(rock.cidfilter); + free(rock.cidfilter); + } + if (nseenp) *nseenp = rock.nseen; if (ndeletedp) @@ -2581,6 +3022,9 @@ static int folder_key_rename(struct conversations_state *state, if (r) return r; if (to_name) { + /* Do nothing if using folder ids */ + if (!state->folders_byname) return 0; + r = conversation_setstatus(state, to_name, &status); if (r) return r; return conversation_setstatus(state, from_name, NULL); @@ -2665,6 +3109,9 @@ static int zero_b_cb(void *rock, /* zero out the size */ conv->size = 0; + /* Update the version */ + conv->version = CONVERSATIONS_RECORD_VERSION; + r = conversation_store(state, key, keylen, conv); done: @@ -2708,24 +3155,44 @@ static int zero_g_cb(void *rock, return r; } -EXPORTED int conversations_zero_counts(struct conversations_state *state) +EXPORTED int conversations_zero_counts(struct conversations_state *state, int wipe) { int r = 0; - /* wipe B counts */ - r = cyrusdb_foreach(state->db, "B", 1, NULL, zero_b_cb, - state, &state->txn); - if (r) return r; + if (wipe) { + /* wipe the lot and start over, this loses all highestmodseqs */ + r = cyrusdb_foreach(state->db, "", 0, NULL, zero_g_cb, + state, &state->txn); - /* wipe F counts */ - r = cyrusdb_foreach(state->db, "F", 1, NULL, zero_f_cb, - state, &state->txn); - if (r) return r; + if (r) return r; - /* wipe G keys (there's no modseq kept, so we can just wipe them) */ - r = cyrusdb_foreach(state->db, "G", 1, NULL, zero_g_cb, - state, &state->txn); - if (r) return r; + /* wipe our internal state too */ + state->folders_byname = 0; + if (state->folders) strarray_truncate(state->folders, 0); + if (state->altrep) strarray_truncate(state->altrep, 0); + state->trashfolder = -1; + } + else { + /* wipe B counts */ + r = cyrusdb_foreach(state->db, "B", 1, NULL, zero_b_cb, + state, &state->txn); + if (r) return r; + + /* wipe F counts */ + r = cyrusdb_foreach(state->db, "F", 1, NULL, zero_f_cb, + state, &state->txn); + if (r) return r; + + /* wipe G keys (there's no modseq kept, so we can just wipe them) */ + r = cyrusdb_foreach(state->db, "G", 1, NULL, zero_g_cb, + state, &state->txn); + if (r) return r; + + /* wipe quota (it's actually just one key) */ + r = cyrusdb_foreach(state->db, "Q", 1, NULL, zero_g_cb, + state, &state->txn); + if (r) return r; + } /* re-init the counted flags */ r = _init_counted(state, NULL, 0); diff --git a/imap/conversations.h b/imap/conversations.h index e4bd4d729f..a9b4eef66f 100644 --- a/imap/conversations.h +++ b/imap/conversations.h @@ -50,12 +50,21 @@ #include #include #include "arrayu64.h" +#include "bitvector.h" #include "hash.h" #include "hashu64.h" #include "message_guid.h" #include "strarray.h" #include "util.h" +#define FNAME_CONVERSATIONS_SUFFIX "conversations" + +#define CONV_FOLDER_KEY_MBOX(state, mailbox) \ + (state->folders_byname ? mailbox_name(mailbox) : mailbox_uniqueid(mailbox)) + +#define CONV_FOLDER_KEY_MBE(state, mbentry) \ + (!mbentry ? NULL : (state->folders_byname ? mbentry->name : mbentry->uniqueid)) + typedef bit64 conversation_id_t; #define CONV_FMT "%016llx" #define NULLCONVERSATION (0ULL) @@ -63,6 +72,10 @@ typedef bit64 conversation_id_t; struct index_record; struct mailbox; +#define CONVERSATIONS_KEY_VERSION 0 +#define CONVERSATIONS_STATUS_VERSION 0 +#define CONVERSATIONS_RECORD_VERSION 1 + #define CONV_ISDIRTY (1<<0) #define CONV_WITHFOLDERS (1<<1) #define CONV_WITHSENDERS (1<<2) @@ -72,24 +85,32 @@ struct mailbox; #define CONV_WITHALL CONV_WITHFOLDERS|CONV_WITHSENDERS|\ CONV_WITHSUBJECT|CONV_WITHTHREAD +struct conv_quota { + ssize_t emails; + ssize_t storage; +}; + +#define CONV_QUOTA_INIT { 0, 0 } + struct conversations_state { struct db *db; struct txn *txn; char *annotmboxname; strarray_t *counted_flags; - strarray_t *folder_names; + strarray_t *folders; + strarray_t *altrep; // cache the alternative mboxname or uniqueid hash_table folderstatus; + struct conv_quota quota; int trashfolder; + char *trashmboxname; + char *trashmboxid; char *path; + unsigned folders_byname:1; + unsigned quota_loaded:1; + unsigned quota_dirty:1; + unsigned is_shared:1; }; -struct conversations_open { - struct conversations_state s; - struct conversations_open *next; -}; - -extern struct conversations_open *open_conversations; - typedef struct conversation conversation_t; typedef struct conv_folder conv_folder_t; typedef struct conv_sender conv_sender_t; @@ -104,6 +125,7 @@ struct conv_thread { struct message_guid guid; uint32_t exists; time_t internaldate; + modseq_t createdmodseq; }; struct conv_folder { @@ -116,13 +138,17 @@ struct conv_folder { uint32_t prev_exists; }; -#define CONV_GUIDREC_VERSION 0x1 // (must be <= 127) +#define CONV_GUIDREC_VERSION 3 // (must be <= 127) +#define CONV_GUIDREC_BYNAME_VERSION 1 // last folders byname version struct conv_guidrec { - const char *mboxname; + const struct conversations_state *cstate; // this conversationsdb! + const char *guidrep; // [MESSAGE_GUID_SIZE*2], hex-encoded + int foldernum; uint32_t uid; const char *part; conversation_id_t cid; + conversation_id_t basecid; char version; uint32_t system_flags; // if version >= 1 uint32_t internal_flags; // if version >= 1 @@ -149,13 +175,11 @@ struct conv_status { #define CONV_STATUS_INIT { 0, 0, 0, 0, 0 } struct conversation { + int version; modseq_t modseq; uint32_t num_records; uint32_t exists; uint32_t unseen; - uint32_t prev_unseen; - uint32_t trash_unseen; - uint32_t prev_trash_unseen; uint32_t size; uint32_t counts[32]; conv_folder_t *folders; @@ -166,18 +190,35 @@ struct conversation { int flags; }; -#define CONVERSATION_INIT { 0, 0, 0, 0, 0, 0, 0, 0, {0}, NULL, NULL, NULL, NULL, 0, CONV_ISDIRTY } +#define CONVERSATION_INIT { CONVERSATIONS_RECORD_VERSION, 0, 0, 0, 0, 0, {0}, \ + NULL, NULL, NULL, NULL, 0, CONV_ISDIRTY } + +struct emailcountitems { + size_t foldernumrecords; + size_t folderexists; + size_t folderunseen; + size_t numrecords; + size_t exists; + size_t unseen; +}; + +#define EMAILCOUNTITEMS_INIT { 0, 0, 0, 0, 0, 0 } struct emailcounts { - const char *mboxname; + int foldernum; + int trashfolder; int ispost; - int pre_emailexists; - int pre_emailunseen; - int post_emailexists; - int post_emailunseen; + struct emailcountitems pre; + struct emailcountitems post; + bitvector_t exists_foldernums; + bitvector_t mbtype_known; + bitvector_t mbtype_mail; }; -#define EMAILCOUNTS_INIT { NULL, 0, 0, 0, 0, 0 } +#define EMAILCOUNTS_INIT { -1, -1, 0, EMAILCOUNTITEMS_INIT, EMAILCOUNTITEMS_INIT, \ + BV_INITIALIZER, BV_INITIALIZER, BV_INITIALIZER } + +extern void emailcounts_fini(struct emailcounts *ecounts); #include "mailbox.h" @@ -198,6 +239,15 @@ extern struct conversations_state *conversations_get_path(const char *path); extern struct conversations_state *conversations_get_user(const char *username); extern struct conversations_state *conversations_get_mbox(const char *mboxname); +extern int conversations_num_folders(struct conversations_state *state); +extern const char* conversations_folder_mboxname(const struct conversations_state *state, + int foldernum); +extern const char* conversations_folder_uniqueid(const struct conversations_state *state, + int foldernum); +extern int conversation_folder_number(struct conversations_state *state, + const char *name, + int create_flag); + /* either of these close */ extern int conversations_abort(struct conversations_state **state); extern int conversations_commit(struct conversations_state **state); @@ -215,21 +265,36 @@ extern conv_folder_t *conversation_get_folder(conversation_t *conv, extern void conversation_normalise_subject(struct buf *); /* G record */ -extern int conversations_guid_exists(struct conversations_state *state, - const char *guidrep); extern int conversations_guid_foreach(struct conversations_state *state, const char *guidrep, int(*cb)(const conv_guidrec_t*,void*), void *rock); -extern conversation_id_t conversations_guid_cid_lookup(struct conversations_state *state, - const char *guidrep); - +extern int conversations_iterate_searchset(struct conversations_state *state, + const void *data, size_t n, + int(*cb)(const conv_guidrec_t*,void*), + void *rock); +extern int conversations_guid_cid_lookup(struct conversations_state *state, + const char *guidrep, struct index_record *record); + +/* lookup the matching name or uniqueid */ +#define conv_guidrec_mboxname(rec) conversations_folder_mboxname((rec)->cstate, (rec)->foldernum) +#define conv_guidrec_uniqueid(rec) conversations_folder_uniqueid((rec)->cstate, (rec)->foldernum) +#define conv_guidrec_mbentry(rec, mbentryp) ( \ + ((rec)->version > CONV_GUIDREC_BYNAME_VERSION) ? \ + mboxlist_lookup_by_uniqueid(conv_guidrec_uniqueid(rec), mbentryp, NULL) : \ + mboxlist_lookup(conv_guidrec_mboxname(rec), mbentryp, NULL) \ + ) +#define conv_guidrec_mboxcmp(rec, mbox) ( \ + ((rec)->version > CONV_GUIDREC_BYNAME_VERSION) ? \ + strcmpsafe(conv_guidrec_uniqueid(rec), mailbox_uniqueid(mbox)) : \ + strcmpsafe(conv_guidrec_mboxname(rec), mailbox_name(mbox)) \ + ) /* F record items */ extern int conversation_getstatus(struct conversations_state *state, - const char *mboxname, + const char *mailbox, conv_status_t *status); extern int conversation_setstatus(struct conversations_state *state, - const char *mboxname, + const char *mailbox, const conv_status_t *status); extern int conversation_storestatus(struct conversations_state *state, const char *key, size_t keylen, @@ -243,8 +308,7 @@ extern int conversation_get_modseq(struct conversations_state *state, modseq_t *modseqp); extern int conversation_save(struct conversations_state *state, conversation_id_t cid, - conversation_t *conv, - struct emailcounts *ecounts); + conversation_t *conv); extern int conversation_load_advanced(struct conversations_state *state, conversation_id_t cid, conversation_t *convp, @@ -264,20 +328,16 @@ extern int conversation_store(struct conversations_state *state, extern int conversations_update_record(struct conversations_state *cstate, struct mailbox *mailbox, const struct index_record *old, - struct index_record *new, - int allowrenumber); + struct index_record *new_, + int allowrenumber, + int ignorelimits, + int silent); -extern void conversation_update(struct conversations_state *state, +extern int conversation_update(struct conversations_state *state, conversation_t *conv, - const char *mboxname, - int is_trash, - int delta_num_records, - int delta_exists, - int delta_unseen, - int delta_size, - int *delta_counts, - modseq_t modseq, - modseq_t createdmodseq); + struct emailcounts *ecounts, + ssize_t delta_size, int *delta_counts, + modseq_t modseq, modseq_t createdmodseq, int silent); extern conv_folder_t *conversation_find_folder(struct conversations_state *state, conversation_t *, const char *mboxname); @@ -291,7 +351,7 @@ extern void conversation_update_sender(conversation_t *conv, const char *mailbox, const char *domain, time_t lastseen, - int delta_exists); + ssize_t delta_exists); extern int conversations_prune(struct conversations_state *state, time_t thresh, unsigned int *, @@ -305,7 +365,7 @@ extern const char *conversation_id_encode(conversation_id_t cid); extern int conversation_id_decode(conversation_id_t *cid, const char *text); -extern int conversations_zero_counts(struct conversations_state *state); +extern int conversations_zero_counts(struct conversations_state *state, int wipe); extern int conversations_cleanup_zero(struct conversations_state *state); extern int conversations_rename_folder(struct conversations_state *state, @@ -314,4 +374,6 @@ extern int conversations_rename_folder(struct conversations_state *state, extern int conversations_check_msgid(const char *msgid, size_t len); +extern int conversations_read_quota(struct conversations_state *state, struct conv_quota *q); + #endif /* __CYRUS_CONVERSATIONS_H_ */ diff --git a/imap/css3_color.c b/imap/css3_color.c index 8da4d2ea54..8a405e7b3d 100644 --- a/imap/css3_color.c +++ b/imap/css3_color.c @@ -46,6 +46,8 @@ #include #include +#include "util.h" + struct css3_color_t { const char *name; unsigned char r; @@ -168,7 +170,7 @@ static const struct css3_color_t css3_colors[] = { { "palevioletred", 219, 112, 147 }, { "papayawhip", 255, 239, 213 }, { "peachpuff", 255, 218, 185 }, - { "peru", 205, 133, 63 }, + { "peru", 205, 133, 63 }, { "pink", 255, 192, 203 }, { "plum", 221, 160, 221 }, { "powderblue", 176, 224, 230 }, @@ -190,7 +192,7 @@ static const struct css3_color_t css3_colors[] = { { "snow", 255, 250, 250 }, { "springgreen", 0, 255, 127 }, { "steelblue", 70, 130, 180 }, - { "tan", 210, 180, 140 }, + { "tan", 210, 180, 140 }, { "teal", 0, 128, 128 }, { "thistle", 216, 191, 216 }, { "tomato", 255, 99, 71 }, @@ -206,7 +208,7 @@ static const struct css3_color_t css3_colors[] = { /* Take a hex value for a color and find best matching css3 color name using: https://en.wikipedia.org/wiki/Color_difference */ -const char *css3_color_hex_to_name(const char *hexstr) +EXPORTED const char *css3_color_hex_to_name(const char *hexstr) { if (!hexstr || hexstr[0] != '#') return NULL; @@ -255,3 +257,12 @@ const char *css3_color_hex_to_name(const char *hexstr) return name; } + +EXPORTED int is_css3_color(const char *s) +{ + const struct css3_color_t *c; + + for (c = css3_colors; c->name && strcasecmp(s, c->name); c++); + + return (c->name != NULL); +} diff --git a/imap/css3_color.h b/imap/css3_color.h index 9cc2d65ff4..cc2b8086ea 100644 --- a/imap/css3_color.h +++ b/imap/css3_color.h @@ -44,6 +44,8 @@ #ifndef CSS3_COLOR_H #define CSS3_COLOR_H -extern const char *css3_color_hex_to_name(const char *hexstr); +const char *css3_color_hex_to_name(const char *hexstr); + +int is_css3_color(const char *s); #endif /* CSS3_COLOR_H */ diff --git a/imap/ctl_conversationsdb.c b/imap/ctl_conversationsdb.c index 5f9789f997..2bb881739a 100644 --- a/imap/ctl_conversationsdb.c +++ b/imap/ctl_conversationsdb.c @@ -44,6 +44,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -62,6 +63,7 @@ #include "message.h" #include "util.h" #include "xmalloc.h" +#include "xunlink.h" /* generated headers are not necessarily in current directory */ #include "imap/imap_err.h" @@ -71,12 +73,13 @@ const int config_need_data = CONFIG_NEED_PARTITION_DATA; enum { UNKNOWN, DUMP, UNDUMP, ZERO, BUILD, RECALC, AUDIT, CHECKFOLDERS }; -int verbose = 0; +static int verbose = 0; int mode = UNKNOWN; static const char *audit_temp_directory; int recalc_silent = 1; +hashu64_table *zerocids = NULL; static int do_dump(const char *fname, const char *userid) { @@ -150,7 +153,10 @@ static int zero_cid_cb(const mbentry_t *mbentry, int r; r = mailbox_open_iwl(mbentry->name, &mailbox); - if (r) return r; + if (r) { + fprintf(stderr, "Failed to open mailbox %s, skipping\n", mbentry->name); + return 0; + } struct mailbox_iter *iter = mailbox_iter_init(mailbox, 0, ITER_SKIP_UNLINKED); const message_t *msg; @@ -160,6 +166,10 @@ static int zero_cid_cb(const mbentry_t *mbentry, if (record->cid == NULLCONVERSATION) continue; + /* if we're only doing some cids, check if this is one */ + if (zerocids && !hashu64_lookup(record->cid, zerocids)) + continue; + struct index_record oldrecord = *record; oldrecord.cid = NULLCONVERSATION; oldrecord.basecid = NULLCONVERSATION; @@ -173,6 +183,29 @@ static int zero_cid_cb(const mbentry_t *mbentry, return r; } +static int delannot_cb(const char *mboxname, + uint32_t uid __attribute__((unused)), + const char *entry, + const char *userid, + const struct buf *value, + const struct annotate_metadata *mdata __attribute__((unused)), + void *rock) +{ + if (zerocids) { + conversation_id_t keycid = NULLCONVERSATION; + conversation_id_t valuecid = NULLCONVERSATION; + + parsehex(entry + strlen(IMAP_ANNOT_NS) + 7, NULL, 16, &keycid); + parsehex(value->s, NULL, 16, &valuecid); + + // if neither are being zeroed, leave them + if (!hashu64_lookup(keycid, zerocids) && !hashu64_lookup(valuecid, zerocids)) + return 0; + } + return annotatemore_write(mboxname, entry, userid, (const struct buf *)rock); +} + + static int do_zero(const char *userid) { struct conversations_state *state = NULL; @@ -184,10 +217,11 @@ static int do_zero(const char *userid) r = mboxlist_usermboxtree(userid, NULL, zero_cid_cb, NULL, 0); if (r) goto done; - /* XXX: - * annotatemore_findall(state->annotmboxname, IMAP_ANNOT_NS "basecid/%", &deleteannot); - * remove all the basecid mappings so they don't create bad splits on rebuild - */ + // remove all "newcid" mappings, since we've zeroed all the basecids already + struct buf zerobuf = BUF_INITIALIZER; + r = annotatemore_findall_mboxname(state->annotmboxname, /*uid*/0, IMAP_ANNOT_NS "newcid/%", + /*modseq*/0, &delannot_cb, &zerobuf, /*flags*/0); + if (r) goto done; done: conversations_commit(&state); @@ -206,7 +240,10 @@ static int build_cid_cb(const mbentry_t *mbentry, while (!r && count) { r = mailbox_open_iwl(mbentry->name, &mailbox); - if (r) return r; + if (r) { + fprintf(stderr, "Failed to open mailbox %s, skipping\n", mbentry->name); + return 0; + } count = 0; @@ -222,6 +259,8 @@ static int build_cid_cb(const mbentry_t *mbentry, r = mailbox_cacherecord(mailbox, &oldrecord); if (r) goto done; + oldrecord.ignorelimits = 1; + r = message_update_conversations(cstate, mailbox, &oldrecord, NULL); if (r) goto done; @@ -300,7 +339,10 @@ static int do_recalc(const char *userid) r = conversations_open_user(userid, 0/*shared*/, &state); if (r) return r; - r = conversations_zero_counts(state); + // wipe if it's currently folders_byname, will recreate with byid + int wipe = state->folders_byname; + + r = conversations_zero_counts(state, wipe); if (r) goto err; r = mboxlist_usermboxtree(userid, NULL, recalc_counts_cb, NULL, 0); @@ -599,7 +641,7 @@ int do_checkfolders(const char *userid) } /* don't mess with the original */ - copy1 = strarray_dup(state->folder_names); + copy1 = strarray_dup(state->folders); /* remove empty folders first, they will duplicate for sure */ strarray_remove_all(copy1, "-"); copy2 = strarray_dup(copy1); @@ -649,7 +691,7 @@ static int do_audit(const char *userid) assert(strcmp(filename_temp, filename_real)); /* Initialise the temp copy of the database */ - unlink(filename_temp); + xunlink(filename_temp); r = cyrusdb_copyfile(filename_real, filename_temp); if (r) { fprintf(stderr, "Cannot make temp copy of conversations db %s: %s\n", @@ -665,7 +707,7 @@ static int do_audit(const char *userid) goto out; } - r = conversations_zero_counts(state_temp); + r = conversations_zero_counts(state_temp, /*wipe*/0); if (r) { fprintf(stderr, "Failed to zero counts in %s: %s\n", filename_temp, error_message(r)); @@ -754,6 +796,8 @@ static int do_user(const char *userid, void *rock __attribute__((unused))) char *fname; int r = 0; + signals_poll(); + fname = conversations_getuserpath(userid); if (fname == NULL) { fprintf(stderr, "Unable to get conversations database " @@ -808,15 +852,48 @@ static int do_user(const char *userid, void *rock __attribute__((unused))) return r; } +static void shut_down(int code) __attribute__((noreturn)); +static void shut_down(int code) +{ + in_shutdown = 1; + + libcyrus_run_delayed(); + + cyrus_done(); + + exit(code); +} + int main(int argc, char **argv) { int c; const char *alt_config = NULL; const char *userid = NULL; - int r = 0; int recursive = 0; - while ((c = getopt(argc, argv, "durzSAbvRFC:T:")) != EOF) { + /* keep in alphabetical order */ + static const char short_options[] = "AC:FRST:bdruvzZ:"; + + static const struct option long_options[] = { + { "audit", no_argument, NULL, 'A' }, + /* n.b. no long option for -C */ + { "check-folders", no_argument, NULL, 'F' }, + { "update-counts", no_argument, NULL, 'R' }, + { "split", no_argument, NULL, 'S' }, + { "audit-temp-directory", required_argument, NULL, 'T' }, + { "rebuild", no_argument, NULL, 'b' }, + { "dump", no_argument, NULL, 'd' }, + { "recursive", no_argument, NULL, 'r' }, + { "undump", no_argument, NULL, 'u' }, + { "verbose", no_argument, NULL, 'v' }, + { "clear", no_argument, NULL, 'z' }, + { "clearcid", required_argument, NULL, 'Z' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (c = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (c) { case 'd': if (mode != UNKNOWN) @@ -840,6 +917,26 @@ int main(int argc, char **argv) mode = ZERO; break; + case 'Z': + if (mode != UNKNOWN && mode != ZERO) + usage(argv[0]); + mode = ZERO; + if (!zerocids) { + zerocids = xzmalloc(sizeof(hashu64_table)); + construct_hashu64_table(zerocids, 256, 0); + } + strarray_t *ids = strarray_split(optarg, ",", 0); + int i; + for (i = 0; i < strarray_size(ids); i++) { + conversation_id_t cid = NULLCONVERSATION; + if (!conversation_id_decode(&cid, strarray_nth(ids, i))) + usage(argv[0]); + if (cid) + hashu64_insert(cid, (void*)1, zerocids); + } + strarray_free(ids); + break; + case 'b': if (mode != UNKNOWN) usage(argv[0]); @@ -898,15 +995,22 @@ int main(int argc, char **argv) cyrus_init(alt_config, "ctl_conversationsdb", 0, 0); + signals_set_shutdown(&shut_down); + signals_add_handlers(0); + if (recursive) { mboxlist_alluser(do_user, NULL); } - else + else { do_user(userid, NULL); + } - cyrus_done(); + if (zerocids) { + free_hashu64_table(zerocids, NULL); + free(zerocids); + } - return r; + shut_down(0); } static int usage(const char *name) @@ -930,10 +1034,12 @@ static int usage(const char *name) exit(EX_USAGE); } -void fatal(const char* s, int code) +EXPORTED void fatal(const char* s, int code) { fprintf(stderr, "ctl_conversationsdb: %s\n", s); cyrus_done(); + + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + exit(code); } - diff --git a/imap/ctl_cyrusdb.c b/imap/ctl_cyrusdb.c index 81e78370ee..26ec316000 100644 --- a/imap/ctl_cyrusdb.c +++ b/imap/ctl_cyrusdb.c @@ -45,6 +45,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -83,6 +84,7 @@ #include "util.h" #include "xmalloc.h" #include "xstrlcpy.h" +#include "xunlink.h" #ifdef ENABLE_BACKUP #include "backup/backup.h" @@ -129,11 +131,12 @@ static void usage(void) static int fixmbox(const mbentry_t *mbentry, void *rock __attribute__((unused))) { - int r; + int r, r2; /* if MBTYPE_RESERVED, unset it & call mboxlist_delete */ if (mbentry->mbtype & MBTYPE_RESERVE) { - r = mboxlist_deletemailbox(mbentry->name, 1, NULL, NULL, NULL, 0, 0, 1, 0); + r = mboxlist_deletemailboxlock(mbentry->name, 1, NULL, NULL, NULL, + MBOXLIST_DELETE_FORCE); if (r) { /* log the error */ syslog(LOG_ERR, @@ -158,26 +161,74 @@ static int fixmbox(const mbentry_t *mbentry, free(userid); } mbentry_t *copy = mboxlist_entry_copy(mbentry); - /* XXX - const correctness */ - free(copy->legacy_specialuse); - copy->legacy_specialuse = NULL; - mboxlist_update(copy, /*localonly*/1); + xzfree(copy->legacy_specialuse); + mboxlist_updatelock(copy, /*localonly*/1); mboxlist_entry_free(©); } - r = mboxlist_update_intermediaries(mbentry->name, mbentry->mbtype, /*modseq*/0); - if (r) { - syslog(LOG_ERR, - "failed to update intermediaries to mailboxes list for %s: %s", - mbentry->name, cyrusdb_strerror(r)); + /* make sure every local mbentry has a uniqueid! */ + if (!mbentry->uniqueid && mbentry_is_local_mailbox(mbentry)) { + struct mailbox *mailbox = NULL; + mbentry_t *copy = NULL; + + r = mailbox_open_from_mbe(mbentry, &mailbox); + if (r) { + /* XXX what does it mean if there's an mbentry, but the mailbox + * XXX was not openable? + */ + syslog(LOG_DEBUG, "%s: mailbox_open_from_mbe %s returned %s", + __func__, mbentry->name, error_message(r)); + goto skip_uniqueid; + } + + if (!mailbox->h.uniqueid) { + /* yikes, no uniqueid in header either! */ + mailbox_make_uniqueid(mailbox); + xsyslog(LOG_INFO, "mailbox header had no uniqueid, creating one", + "mboxname=<%s> newuniqueid=<%s>", + mbentry->name, mailbox->h.uniqueid); + } + + copy = mboxlist_entry_copy(mbentry); + copy->uniqueid = xstrdup(mailbox->h.uniqueid); + xsyslog(LOG_INFO, "mbentry had no uniqueid, setting from header", + "mboxname=<%s> newuniqueid=<%s>", + copy->name, copy->uniqueid); + + r = mboxlist_updatelock(copy, /*localonly*/1); + if (r) { + xsyslog(LOG_ERR, "failed to update mboxlist", + "mboxname=<%s> error=<%s>", + mbentry->name, error_message(r)); + r2 = mailbox_abort(mailbox); + if (r2) { + xsyslog(LOG_ERR, "DBERROR: error aborting transaction", + "error=<%s>", cyrusdb_strerror(r2)); + } + } + else { + r2 = mailbox_commit(mailbox); + if (r2) { + xsyslog(LOG_ERR, "DBERROR: error committing transaction", + "error=<%s>", cyrusdb_strerror(r2)); + } + } + mailbox_close(&mailbox); + mboxlist_entry_free(©); + +skip_uniqueid: + ; /* hush "label at end of compound statement" warning */ } return 0; } -static void process_mboxlist(void) +static void process_mboxlist(int *upgraded) { - /* build a list of mailboxes - we're using internal names here */ + /* upgrade database to new mailboxes-by-id records */ + mboxlist_upgrade(upgraded); + + /* run fixmbox across all mboxlist entries */ mboxlist_allmbox(NULL, fixmbox, NULL, MBOXTREE_INTERMEDIATES); /* enable or disable RACLs per config */ @@ -203,7 +254,7 @@ static const char *dbfname(struct cyrusdb *db) else if (!strcmp(db->name, FNAME_DELIVERDB)) fname = config_getstring(IMAPOPT_DUPLICATE_DB_PATH); else if (!strcmp(db->name, FNAME_TLSSESSIONS)) - fname = config_getstring(IMAPOPT_TLSCACHE_DB_PATH); + fname = config_getstring(IMAPOPT_TLS_SESSIONS_DB_PATH); else if (!strcmp(db->name, FNAME_PTSDB)) fname = config_getstring(IMAPOPT_PTSCACHE_DB_PATH); else if (!strcmp(db->name, FNAME_STATUSCACHEDB)) @@ -247,8 +298,7 @@ static void check_convert(struct cyrusdb *db, const char *fname) int main(int argc, char *argv[]) { - extern char *optarg; - int opt, r, r2; + int opt, r = 0, r2 = 0; char *alt_config = NULL; int reserve_flag = 1; enum { RECOVER, CHECKPOINT, NONE } op = NONE; @@ -257,9 +307,21 @@ int main(int argc, char *argv[]) char *msg = ""; int i, rotated = 0; - r = r2 = 0; + /* keep this in alphabetical order */ + static const char short_options[] = "C:crx"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "checkpoint", no_argument, NULL, 'c' }, + { "recover", no_argument, NULL, 'r' }, + { "no-cleanup", no_argument, NULL, 'x' }, - while ((opt = getopt(argc, argv, "C:rxc")) != EOF) { + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; @@ -346,7 +408,7 @@ int main(int argc, char *argv[]) while ((dirent = readdir(dirp)) != NULL) { if (dirent->d_name[0] == '.') continue; file = strconcat(backup2, "/", dirent->d_name, (char *)NULL); - unlink(file); + xunlink(file); free(file); } @@ -388,8 +450,11 @@ int main(int argc, char *argv[]) strarray_fini(&files); - if(op == RECOVER && reserve_flag) - process_mboxlist(); + if (op == RECOVER && reserve_flag) { + int upgraded = 0; + process_mboxlist(&upgraded); + if (upgraded) annotatemore_upgrade(); + } free(dirname); free(backup1); diff --git a/imap/ctl_deliver.c b/imap/ctl_deliver.c index 36590eadb1..c70bf27488 100644 --- a/imap/ctl_deliver.c +++ b/imap/ctl_deliver.c @@ -45,6 +45,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -75,7 +76,21 @@ int main(int argc, char *argv[]) char *days = NULL; enum { DUMP, PRUNE, NONE } op = NONE; - while ((opt = getopt(argc, argv, "C:drE:f:")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:E:df:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + /* n.b. no long option for "deprecated" -E */ + { "dump", no_argument, NULL, 'd' }, + { "filename", required_argument, NULL, 'f' }, + + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; diff --git a/imap/ctl_mboxlist.c b/imap/ctl_mboxlist.c index 832ed7cd80..63226d2dda 100644 --- a/imap/ctl_mboxlist.c +++ b/imap/ctl_mboxlist.c @@ -65,6 +65,8 @@ #ifdef HAVE_UNISTD_H #include #endif +#include +#include #include #include #include @@ -75,9 +77,11 @@ #include "annotate.h" #include "dlist.h" #include "global.h" +#include "json_support.h" #include "libcyr_cfg.h" #include "mboxlist.h" #include "mupdate.h" +#include "user.h" #include "util.h" #include "xmalloc.h" #include "xstrlcpy.h" @@ -85,24 +89,21 @@ /* generated headers are not necessarily in current directory */ #include "imap/imap_err.h" #include "imap/mupdate_err.h" - -extern int optind; -extern char *optarg; +#include "lib/ptrarray.h" enum mboxop { DUMP, M_POPULATE, - RECOVER, - CHECKPOINT, UNDUMP, VERIFY, NONE }; struct dumprock { - enum mboxop op; - const char *partition; int purge; + const char *sep; +}; +struct popmupdaterock { mupdate_handle *h; }; @@ -170,206 +171,185 @@ static int mupdate_list_cb(struct mupdate_mailboxdata *mdata, return 0; } -static int dump_cb(const mbentry_t *mbentry, void *rockp) +static int pop_mupdate_cb(const mbentry_t *mbentry, void *rockp) { - struct dumprock *d = (struct dumprock *) rockp; + struct popmupdaterock *rock = (struct popmupdaterock *) rockp; int r = 0; - switch (d->op) { - case DUMP: - if (!d->partition || !strcmpsafe(d->partition, mbentry->partition)) { - printf("%s\t%d ", mbentry->name, mbentry->mbtype); - if (mbentry->server) printf("%s!", mbentry->server); - printf("%s %s\n", mbentry->partition, mbentry->acl); - if (d->purge) { - mboxlist_delete(mbentry->name); + if (mbentry->mbtype & MBTYPE_DELETED) + return 0; + + /* realpart is 'hostname!partition' */ + char *realpart = + strconcat(config_servername, "!", mbentry->partition, (char *)NULL); + int skip_flag = 0; + + /* If it is marked MBTYPE_MOVING, and it DOES match the entry, + * we need to unmark it. If it does not match the entry in our + * list, then we assume that it successfully made the move and + * we delete it from the local disk */ + + /* If they match, then we should check that we actually need + * to update it. If they *don't* match, then we believe that we + * need to send fresh data. There will be no point at which something + * is in the act_head list that we do not have locally, because that + * is a condition of being in the act_head list */ + if (act_head && !strcmp(mbentry->name, act_head->mailbox)) { + struct mb_node *tmp; + + /* If this mailbox was moving, we want to unmark the movingness, + * since the MUPDATE server agreed that it lives here. */ + /* (and later also force an mupdate push) */ + if (mbentry->mbtype & MBTYPE_MOVING) { + struct mb_node *next; + + syslog(LOG_WARNING, "Remove remote flag on: %s", mbentry->name); + + if (warn_only) { + printf("Remove remote flag on: %s\n", mbentry->name); + } else { + next = xzmalloc(sizeof(struct mb_node)); + strlcpy(next->mailbox, mbentry->name, sizeof(next->mailbox)); + next->next = unflag_head; + unflag_head = next; + } + + /* No need to update mupdate NOW, we'll get it when we + * untag the mailbox */ + skip_flag = 1; + } else if (act_head->acl) { + if ( + !strcmp(realpart, act_head->location) && + !strcmp(mbentry->acl, act_head->acl) + ) { + + /* Do not update if location does match, and there is an acl, + * and the acl matches */ + + skip_flag = 1; } } - break; - case M_POPULATE: - { - if (mbentry->mbtype & MBTYPE_DELETED) - return 0; - - /* realpart is 'hostname!partition' */ - char *realpart = - strconcat(config_servername, "!", mbentry->partition, (char *)NULL); - int skip_flag = 0; - - /* If it is marked MBTYPE_MOVING, and it DOES match the entry, - * we need to unmark it. If it does not match the entry in our - * list, then we assume that it successfully made the move and - * we delete it from the local disk */ - - /* If they match, then we should check that we actually need - * to update it. If they *don't* match, then we believe that we - * need to send fresh data. There will be no point at which something - * is in the act_head list that we do not have locally, because that - * is a condition of being in the act_head list */ - if (act_head && !strcmp(mbentry->name, act_head->mailbox)) { - struct mb_node *tmp; - - /* If this mailbox was moving, we want to unmark the movingness, - * since the MUPDATE server agreed that it lives here. */ - /* (and later also force an mupdate push) */ - if (mbentry->mbtype & MBTYPE_MOVING) { - struct mb_node *next; - syslog(LOG_WARNING, "Remove remote flag on: %s", mbentry->name); + /* in any case, free the node. */ + if (act_head->acl) free(act_head->acl); + tmp = act_head; + act_head = act_head->next; + if (tmp) free(tmp); + } else { + /* if they do not match, do an explicit MUPDATE find on the + * mailbox, and if it is living somewhere else, delete the local + * data, if it is NOT living somewhere else, recreate it in + * mupdate */ + struct mupdate_mailboxdata *mdata; + + /* if this is okay, we found it (so it is on another host, since + * it wasn't in our list in this position) */ + if (!local_authoritative && + !mupdate_find(rock->h, mbentry->name, &mdata)) { + /* since it lives on another server, schedule it for a wipe */ + struct mb_node *next; + + /* + * Verify that what we found points at another host, + * not back to this host. Good idea, since if our assumption + * if wrong, we'll end up removing the authoritative + * mailbox. + */ + if (strcmp(realpart, mdata->location) == 0 ) { + if ( act_head ) { + fprintf( stderr, "mupdate said: %s %s %s\n", + act_head->mailbox, act_head->location, act_head->acl ); + } + fprintf( stderr, "mailboxes.db said: %s %s %s\n", + mbentry->name, realpart, mbentry->acl ); + fprintf( stderr, "mupdate says: %s %s %s\n", + mdata->mailbox, mdata->location, mdata->acl ); + fatal("mupdate said not us before it said us", EX_SOFTWARE); + } + + /* + * Where does "unified" murder fit into ctl_mboxlist? + * 1. Only check locally hosted mailboxes. + * 2. Check everything. + * Either way, this check is just wrong! + */ + if (config_mupdate_config != + IMAP_ENUM_MUPDATE_CONFIG_UNIFIED) { + /* But not for a unified configuration */ + + syslog(LOG_WARNING, "Remove Local Mailbox: %s", mbentry->name); if (warn_only) { - printf("Remove remote flag on: %s\n", mbentry->name); + printf("Remove Local Mailbox: %s\n", mbentry->name); } else { next = xzmalloc(sizeof(struct mb_node)); strlcpy(next->mailbox, mbentry->name, sizeof(next->mailbox)); - next->next = unflag_head; - unflag_head = next; - } - - /* No need to update mupdate NOW, we'll get it when we - * untag the mailbox */ - skip_flag = 1; - } else if (act_head->acl) { - if ( - !strcmp(realpart, act_head->location) && - !strcmp(mbentry->acl, act_head->acl) - ) { - - /* Do not update if location does match, and there is an acl, - * and the acl matches */ - - skip_flag = 1; + next->next = wipe_head; + wipe_head = next; } } - /* in any case, free the node. */ - if (act_head->acl) free(act_head->acl); - tmp = act_head; - act_head = act_head->next; - if (tmp) free(tmp); + skip_flag = 1; } else { - /* if they do not match, do an explicit MUPDATE find on the - * mailbox, and if it is living somewhere else, delete the local - * data, if it is NOT living somewhere else, recreate it in - * mupdate */ - struct mupdate_mailboxdata *mdata; - - /* if this is okay, we found it (so it is on another host, since - * it wasn't in our list in this position) */ - if (!local_authoritative && - !mupdate_find(d->h, mbentry->name, &mdata)) { - /* since it lives on another server, schedule it for a wipe */ + /* Check that it isn't flagged moving */ + if (mbentry->mbtype & MBTYPE_MOVING) { + /* it's flagged moving, we'll fix it later (and + * push it then too) */ struct mb_node *next; - /* - * Verify that what we found points at another host, - * not back to this host. Good idea, since if our assumption - * if wrong, we'll end up removing the authoritative - * mailbox. - */ - if (strcmp(realpart, mdata->location) == 0 ) { - if ( act_head ) { - fprintf( stderr, "mupdate said: %s %s %s\n", - act_head->mailbox, act_head->location, act_head->acl ); - } - fprintf( stderr, "mailboxes.db said: %s %s %s\n", - mbentry->name, realpart, mbentry->acl ); - fprintf( stderr, "mupdate says: %s %s %s\n", - mdata->mailbox, mdata->location, mdata->acl ); - fatal("mupdate said not us before it said us", EX_SOFTWARE); - } + syslog(LOG_WARNING, "Remove remote flag on: %s", mbentry->name); - /* - * Where does "unified" murder fit into ctl_mboxlist? - * 1. Only check locally hosted mailboxes. - * 2. Check everything. - * Either way, this check is just wrong! - */ - if (config_mupdate_config != - IMAP_ENUM_MUPDATE_CONFIG_UNIFIED) { - /* But not for a unified configuration */ - - syslog(LOG_WARNING, "Remove Local Mailbox: %s", mbentry->name); - - if (warn_only) { - printf("Remove Local Mailbox: %s\n", mbentry->name); - } else { - next = xzmalloc(sizeof(struct mb_node)); - strlcpy(next->mailbox, mbentry->name, sizeof(next->mailbox)); - next->next = wipe_head; - wipe_head = next; - } + if (warn_only) { + printf("Remove remote flag on: %s\n", mbentry->name); + } else { + next = xzmalloc(sizeof(struct mb_node)); + strlcpy(next->mailbox, mbentry->name, sizeof(next->mailbox)); + next->next = unflag_head; + unflag_head = next; } + /* No need to update mupdate now, we'll get it when we + * untag the mailbox */ skip_flag = 1; - } else { - /* Check that it isn't flagged moving */ - if (mbentry->mbtype & MBTYPE_MOVING) { - /* it's flagged moving, we'll fix it later (and - * push it then too) */ - struct mb_node *next; - - syslog(LOG_WARNING, "Remove remote flag on: %s", mbentry->name); - - if (warn_only) { - printf("Remove remote flag on: %s\n", mbentry->name); - } else { - next = xzmalloc(sizeof(struct mb_node)); - strlcpy(next->mailbox, mbentry->name, sizeof(next->mailbox)); - next->next = unflag_head; - unflag_head = next; - } - - /* No need to update mupdate now, we'll get it when we - * untag the mailbox */ - skip_flag = 1; - } } } + } - if (skip_flag) { - free(realpart); - break; - } - - syslog(LOG_WARNING, "Force Activate: %s", mbentry->name); - - if (warn_only) { - printf("Force Activate: %s\n", mbentry->name); - free(realpart); - break; - } - - r = mupdate_activate(d->h, mbentry->name, realpart, mbentry->acl); + if (skip_flag) { + free(realpart); + return 0; + } - if (r == MUPDATE_NOCONN) { - fprintf(stderr, "permanent failure storing '%s'\n", mbentry->name); - r = IMAP_IOERROR; - } else if (r == MUPDATE_FAIL) { - fprintf(stderr, - "temporary failure storing '%s' (update continuing)\n", - mbentry->name); - r = 0; - } else if (r) { - fprintf( - stderr, - "error storing '%s' (update continuing): %s\n", - mbentry->name, - error_message(r) - ); - r = 0; - } + syslog(LOG_WARNING, "Force Activate: %s", mbentry->name); + if (warn_only) { + printf("Force Activate: %s\n", mbentry->name); free(realpart); - - break; + return 0; } - default: /* yikes ! */ - abort(); - break; + r = mupdate_activate(rock->h, mbentry->name, realpart, mbentry->acl); + + if (r == MUPDATE_NOCONN) { + fprintf(stderr, "permanent failure storing '%s'\n", mbentry->name); + r = IMAP_IOERROR; + } else if (r == MUPDATE_FAIL) { + fprintf(stderr, + "temporary failure storing '%s' (update continuing)\n", + mbentry->name); + r = 0; + } else if (r) { + fprintf( + stderr, + "error storing '%s' (update continuing): %s\n", + mbentry->name, + error_message(r) + ); + r = 0; } + free(realpart); + return r; } @@ -405,219 +385,367 @@ static int yes(void) * If it is not local and present on mupdate for this host, delete it from * mupdate. */ - -static void do_dump(enum mboxop op, const char *part, int purge, int intermediary) +static void do_pop_mupdate(void) { - struct dumprock d; + struct popmupdaterock popmupdaterock = {0}; int ret; char buf[8192]; - assert(op == DUMP || op == M_POPULATE); - assert(op == DUMP || !purge); - assert(op == DUMP || !part); + ret = mupdate_connect(NULL, NULL, &(popmupdaterock.h), NULL); + if (ret) { + fprintf(stderr, "couldn't connect to mupdate server\n"); + exit(1); + } + + /* now we need a list of what the remote thinks we have + * To generate it, ask for a prefix of '!', + * (to ensure we get exactly our hostname) */ + snprintf(buf, sizeof(buf), "%s!", config_servername); + ret = mupdate_list(popmupdaterock.h, mupdate_list_cb, buf, NULL); + if (ret) { + fprintf(stderr, "couldn't do LIST command on mupdate server\n"); + exit(1); + } - d.op = op; - d.partition = part; - d.purge = purge; + /* Run pending mupdate deletes */ + while (del_head) { + struct mb_node *me = del_head; + del_head = del_head->next; - if (op == M_POPULATE) { - ret = mupdate_connect(NULL, NULL, &(d.h), NULL); + syslog(LOG_WARNING, "Remove from MUPDATE: %s", me->mailbox); + + if (warn_only) { + printf("Remove from MUPDATE: %s\n", me->mailbox); + } else { + ret = mupdate_delete(popmupdaterock.h, me->mailbox); + if (ret) { + fprintf(stderr, + "couldn't mupdate delete %s\n", me->mailbox); + exit(1); + } + } + + free(me); + } + + /* Run callback for mailboxes */ + int flags = MBOXTREE_TOMBSTONES; + mboxlist_allmbox("", &pop_mupdate_cb, &popmupdaterock, flags); + + /* Remove MBTYPE_MOVING flags (unflag_head) */ + while (unflag_head) { + mbentry_t *mbentry = NULL; + struct mb_node *me = unflag_head; + + unflag_head = unflag_head->next; + + ret = mboxlist_lookup(me->mailbox, &mbentry, NULL); if (ret) { - fprintf(stderr, "couldn't connect to mupdate server\n"); + fprintf(stderr, + "couldn't perform lookup to un-remote-flag %s\n", + me->mailbox); exit(1); } - /* now we need a list of what the remote thinks we have - * To generate it, ask for a prefix of '!', - * (to ensure we get exactly our hostname) */ - snprintf(buf, sizeof(buf), "%s!", config_servername); - ret = mupdate_list(d.h, mupdate_list_cb, buf, NULL); + /* Reset the partition! */ + free(mbentry->server); + mbentry->server = NULL; + mbentry->mbtype &= ~(MBTYPE_MOVING|MBTYPE_REMOTE); + ret = mboxlist_updatelock(mbentry, 1); if (ret) { - fprintf(stderr, "couldn't do LIST command on mupdate server\n"); + fprintf(stderr, + "couldn't perform update to un-remote-flag %s\n", + me->mailbox); exit(1); } - /* Run pending mupdate deletes */ - while (del_head) { - struct mb_node *me = del_head; - del_head = del_head->next; + /* force a push to mupdate */ + snprintf(buf, sizeof(buf), "%s!%s", config_servername, mbentry->partition); + ret = mupdate_activate(popmupdaterock.h, me->mailbox, buf, mbentry->acl); + if (ret) { + fprintf(stderr, + "couldn't perform mupdatepush to un-remote-flag %s\n", + me->mailbox); + exit(1); + } - syslog(LOG_WARNING, "Remove from MUPDATE: %s", me->mailbox); + mboxlist_entry_free(&mbentry); + free(me); + } - if (warn_only) { - printf("Remove from MUPDATE: %s\n", me->mailbox); - } else { - ret = mupdate_delete(d.h, me->mailbox); - if (ret) { - fprintf(stderr, - "couldn't mupdate delete %s\n", me->mailbox); - exit(1); - } + /* Delete local mailboxes where needed (wipe_head) */ + if (interactive) { + int count = 0; + struct mb_node *me; + + for (me = wipe_head; me != NULL; me = me->next) count++; + + if ( count > 0 ) { + fprintf(stderr, "OK to delete %d local mailboxes? ", count); + if (!yes()) { + fprintf(stderr, "Cancelled!\n"); + exit(1); } + } + } + + while (wipe_head) { + struct mb_node *me = wipe_head; + wipe_head = wipe_head->next; + + struct mboxlock *namespacelock = mboxname_usernamespacelock(me->mailbox); + + if (!mboxlist_delayed_delete_isenabled() || + mboxname_isdeletedmailbox(me->mailbox, NULL)) { + ret = mboxlist_deletemailbox(me->mailbox, 1, "", NULL, NULL, + MBOXLIST_DELETE_LOCALONLY|MBOXLIST_DELETE_FORCE); + } else { + ret = mboxlist_delayed_deletemailbox(me->mailbox, 1, "", NULL, NULL, + MBOXLIST_DELETE_LOCALONLY|MBOXLIST_DELETE_FORCE); + } + + mboxname_release(&namespacelock); - free(me); + if (ret) { + fprintf(stderr, "couldn't delete defunct mailbox %s\n", + me->mailbox); + exit(1); } + + free(me); } - /* Dump Database */ - int flags = MBOXTREE_TOMBSTONES; - if (intermediary) flags |= MBOXTREE_INTERMEDIATES; - mboxlist_allmbox("", &dump_cb, &d, flags); + /* Done with mupdate */ + mupdate_disconnect(&(popmupdaterock.h)); + sasl_done(); +} - if (op == M_POPULATE) { - /* Remove MBTYPE_MOVING flags (unflag_head) */ - while (unflag_head) { - mbentry_t *mbentry = NULL; - struct mb_node *me = unflag_head; +/* XXX based on mailbox_acl_to_dlist. this should probably be in lib/acl.c! */ +static json_t *acl_to_json(const char *aclstr) +{ + const char *p, *q; + json_t *jacl = json_object(); - unflag_head = unflag_head->next; + p = aclstr; - ret = mboxlist_lookup(me->mailbox, &mbentry, NULL); - if (ret) { - fprintf(stderr, - "couldn't perform lookup to un-remote-flag %s\n", - me->mailbox); - exit(1); - } + while (p && *p) { + char *name, *val; - /* Reset the partition! */ - free(mbentry->server); - mbentry->server = NULL; - mbentry->mbtype &= ~(MBTYPE_MOVING|MBTYPE_REMOTE); - ret = mboxlist_update(mbentry, 1); - if (ret) { - fprintf(stderr, - "couldn't perform update to un-remote-flag %s\n", - me->mailbox); - exit(1); - } + q = strchr(p, '\t'); + if (!q) break; - /* force a push to mupdate */ - snprintf(buf, sizeof(buf), "%s!%s", config_servername, mbentry->partition); - ret = mupdate_activate(d.h, me->mailbox, buf, mbentry->acl); - if (ret) { - fprintf(stderr, - "couldn't perform mupdatepush to un-remote-flag %s\n", - me->mailbox); - exit(1); - } + name = xstrndup(p, q-p); + q++; - mboxlist_entry_free(&mbentry); - free(me); + p = strchr(q, '\t'); + if (p) { + val = xstrndup(q, p-q); + p++; } + else + val = xstrdup(q); - /* Delete local mailboxes where needed (wipe_head) */ - if (interactive) { - int count = 0; - struct mb_node *me; + json_object_set_new(jacl, name, json_string(val)); - for (me = wipe_head; me != NULL; me = me->next) count++; + free(name); + free(val); + } - if ( count > 0 ) { - fprintf(stderr, "OK to delete %d local mailboxes? ", count); - if (!yes()) { - fprintf(stderr, "Cancelled!\n"); - exit(1); - } - } - } + return jacl; +} - while (wipe_head) { - struct mb_node *me = wipe_head; +static int dump_cb(const mbentry_t *mbentry, void *rockp) +{ + struct dumprock *d = (struct dumprock *) rockp; + int i, r = 0; + json_t *jparent, *jobj, *jname_history; + char *output = NULL; + static struct buf buf = BUF_INITIALIZER; + + /* skip if we're limiting by partition and this one doesn't match */ + if (d->partition && strcmpsafe(d->partition, mbentry->partition)) + return 0; + + jobj = json_object(); + + /* char *name; */ + json_object_set_new(jobj, "name", json_string(mbentry->name)); + + /* char *ext_name + * this field is a place to cache a calculated value, not + * a real value in mailboxes.db, so don't output it. + */ + + /* time_t mtime; */ + buf_reset(&buf); + buf_printf(&buf, TIME_T_FMT, mbentry->mtime); + json_object_set_new(jobj, "mtime", json_string(buf_cstring(&buf))); + + /* uint32_t uidvalidity; */ + buf_reset(&buf); + buf_printf(&buf, "%" PRIu32, mbentry->uidvalidity); + json_object_set_new(jobj, "uidvalidity", json_string(buf_cstring(&buf))); + + /* modseq_t createdmodseq; */ + buf_reset(&buf); + buf_printf(&buf, MODSEQ_FMT, mbentry->createdmodseq); + json_object_set_new(jobj, "createdmodseq", json_string(buf_cstring(&buf))); + + /* modseq_t foldermodseq; */ + buf_reset(&buf); + buf_printf(&buf, MODSEQ_FMT, mbentry->foldermodseq); + json_object_set_new(jobj, "foldermodseq", json_string(buf_cstring(&buf))); + + /* uint32_t mbtype; */ + json_object_set_new(jobj, "mbtype", + json_string(mboxlist_mbtype_to_string(mbentry->mbtype))); + + /* char *partition; */ + json_object_set_new(jobj, "partition", json_string(mbentry->partition)); + + /* char *server; */ + json_object_set_new(jobj, "server", json_string(mbentry->server)); + + /* char *acl; */ + json_object_set_new(jobj, "acl", acl_to_json(mbentry->acl)); + + /* char *uniqueid; */ + json_object_set_new(jobj, "uniqueid", json_string(mbentry->uniqueid)); + + /* char *legacy_specialuse; */ + json_object_set_new(jobj, "legacy_specialuse", + json_string(mbentry->legacy_specialuse)); + + /* ptrarray_t name_history; */ + jname_history = json_array(); + for (i = 0; i < mbentry->name_history.count; i++) { + former_name_t *histitem = ptrarray_nth(&mbentry->name_history, i); + json_t *jhistitem = json_object(); + + json_object_set_new(jhistitem, "name", json_string(histitem->name)); + buf_reset(&buf); + buf_printf(&buf, TIME_T_FMT, histitem->mtime); + json_object_set_new(jhistitem, "mtime", + json_string(buf_cstring(&buf))); + buf_reset(&buf); + buf_printf(&buf, "%" PRIu32, histitem->uidvalidity); + json_object_set_new(jhistitem, "uidvalidity", + json_string(buf_cstring(&buf))); + buf_reset(&buf); + buf_printf(&buf, MODSEQ_FMT, histitem->createdmodseq); + json_object_set_new(jhistitem, "createdmodseq", + json_string(buf_cstring(&buf))); + buf_reset(&buf); + buf_printf(&buf, MODSEQ_FMT, histitem->foldermodseq); + json_object_set_new(jhistitem, "foldermodseq", + json_string(buf_cstring(&buf))); + json_object_set_new(jhistitem, "mbtype", + json_string(mboxlist_mbtype_to_string(histitem->mbtype))); + json_object_set_new(jhistitem, "partition", + json_string(histitem->partition)); + + json_array_append_new(jname_history, jhistitem); + } + json_object_set_new(jobj, "name_history", jname_history); - wipe_head = wipe_head->next; - if (!mboxlist_delayed_delete_isenabled()) { - ret = mboxlist_deletemailbox(me->mailbox, 1, "", NULL, NULL, 0, 1, 1, 0); - } else if (mboxname_isdeletedmailbox(me->mailbox, NULL)) { - ret = mboxlist_deletemailbox(me->mailbox, 1, "", NULL, NULL, 0, 1, 1, 0); - } else { - ret = mboxlist_delayed_deletemailbox(me->mailbox, 1, "", NULL, NULL, 0, 1, 1, 0); - } + jparent = json_object(); + json_object_set_new(jparent, mbentry->name, jobj); - if (ret) { - fprintf(stderr, "couldn't delete defunct mailbox %s\n", - me->mailbox); - exit(1); - } + output = json_dumps(jparent, JSON_EMBED); + if (!output) { + xsyslog(LOG_ERR, "unable to stringify json object", + "mboxname=<%s>", mbentry->name); + return IMAP_INTERNAL; + } - free(me); - } + printf("%s%s", d->sep, output); + + if (d->sep && !*d->sep) + d->sep = ",\n"; - /* Done with mupdate */ - mupdate_disconnect(&(d.h)); - sasl_done(); + free(output); + json_decref(jparent); + + if (d->purge) { + mboxlist_deletelock(mbentry); } - return; + return r; } -static void do_undump(void) +static void do_dump(const char *part, int purge, int intermediary) +{ + struct dumprock d = { part, purge, "" }; + + /* Dump Database */ + int flags = MBOXTREE_TOMBSTONES; + if (intermediary) flags |= MBOXTREE_INTERMEDIATES; + + puts("{"); + mboxlist_allmbox("", &dump_cb, &d, flags); + puts("\n}"); +} + +static void do_undump_legacy(void) { - int r = 0; char buf[16384]; int line = 0; - const char *name, *partition, *acl; - int mbtype; - char *p; while (fgets(buf, sizeof(buf), stdin)) { - mbentry_t *newmbentry = NULL; - const char *server = NULL; - + mbentry_t *newmbentry = mboxlist_entry_create(); line++; - name = buf; - for (p = buf; *p && *p != '\t'; p++) ; - if (!*p) { + sscanf(buf, "%m[^\t]\t%d %ms %m[^>]>%ms " TIME_T_FMT " %" SCNu32 + " %llu %llu %m[^\n]\n", &newmbentry->name, &newmbentry->mbtype, + &newmbentry->partition, &newmbentry->acl, &newmbentry->uniqueid, + &newmbentry->mtime, &newmbentry->uidvalidity, &newmbentry->foldermodseq, + &newmbentry->createdmodseq, &newmbentry->legacy_specialuse); + + if (!newmbentry->acl) { + /* + * This can be valid, e.g. for folders created by + * 0000 CREATE #calendars (TYPE CALENDAR) + * 0001 CREATE #addressbooks (TYPE ADDRESSBOOK) + * 0002 CREATE #calendars/Shared (TYPE CALENDAR) + * 0003 CREATE #addressbooks/Shared (TYPE ADDRESSBOOK) + * For these read the uniqueid, mtime, etc. + */ + mboxlist_entry_free(&newmbentry); + newmbentry = mboxlist_entry_create(); + sscanf(buf, "%m[^\t]\t%d %ms >%ms " TIME_T_FMT " %" SCNu32 + " %llu %llu %m[^\n]\n", &newmbentry->name, &newmbentry->mbtype, + &newmbentry->partition, &newmbentry->uniqueid, + &newmbentry->mtime, &newmbentry->uidvalidity, &newmbentry->foldermodseq, + &newmbentry->createdmodseq, &newmbentry->legacy_specialuse); + } + + if (!newmbentry->partition) { fprintf(stderr, "line %d: no partition found\n", line); + mboxlist_entry_free(&newmbentry); continue; } - *p++ = '\0'; - if (Uisdigit(*p)) { - /* new style dump */ - mbtype = strtol(p, &p, 10); - /* skip trailing space */ - if (*p == ' ') p++; - } - else mbtype = 0; - - partition = p; - for (; *p && (*p != ' ') && (*p != '\t'); p++) { - if (*p == '!') { - *p++ = '\0'; - server = partition; - partition = p; - } - } - if (!*p) { - fprintf(stderr, "line %d: no acl found\n", line); - continue; + + char *server_sep = strchr(newmbentry->partition, '!'); + if (server_sep) { + *server_sep = '\0'; + newmbentry->server = newmbentry->partition; + newmbentry->partition = xstrdup(server_sep + 1); } - *p++ = '\0'; - acl = p; - /* chop off the newline */ - for (; *p && *p != '\r' && *p != '\n'; p++) ; - *p++ = '\0'; - if (strlen(name) >= MAX_MAILBOX_BUFFER) { + if (strlen(newmbentry->name) >= MAX_MAILBOX_BUFFER) { + /* XXX should be MAX_MAILBOX_NAME, not MAX_MAILBOX_BUFFER? */ fprintf(stderr, "line %d: mailbox name too long\n", line); + mboxlist_entry_free(&newmbentry); continue; } - if (strlen(partition) >= MAX_PARTITION_LEN) { + if (strlen(newmbentry->partition) >= MAX_PARTITION_LEN) { fprintf(stderr, "line %d: partition name too long\n", line); + mboxlist_entry_free(&newmbentry); continue; } /* generate a new entry */ - newmbentry = mboxlist_entry_create(); - newmbentry->name = xstrdup(name); - newmbentry->mbtype = mbtype; - newmbentry->server = xstrdupnull(server); - newmbentry->partition = xstrdupnull(partition); - newmbentry->acl = xstrdupnull(acl); - /* XXX - still missing all the new fields */ - - r = mboxlist_update(newmbentry, /*localonly*/1); + int r = mboxlist_updatelock(newmbentry, /*localonly*/1); mboxlist_entry_free(&newmbentry); if (r) break; @@ -626,10 +754,206 @@ static void do_undump(void) return; } +static void undump_name_history(ptrarray_t *name_history, + const json_t *jname_history) +{ + size_t index; + json_t *value; + + /* XXX check lengths of mailbox and partition names */ + + json_array_foreach(jname_history, index, value) { + former_name_t *histitem; + const char *tmp; + + histitem = xzmalloc(sizeof(*histitem)); + + /* char *name; */ + if ((tmp = json_string_value(json_object_get(value, "name")))) { + histitem->name = xstrdup(tmp); + } + + /* time_t mtime; */ + if ((tmp = json_string_value(json_object_get(value, "mtime")))) { + histitem->mtime = atoi(tmp); + } + + /* uint32_t uidvalidity; */ + if ((tmp = json_string_value(json_object_get(value, "uidvalidity")))) { + histitem->uidvalidity = strtoul(tmp, NULL, 10); + } + + /* modseq_t createdmodseq; */ + if ((tmp = json_string_value(json_object_get(value, "createdmodseq")))) { + histitem->createdmodseq = atomodseq_t(tmp); + } + + /* modseq_t foldermodseq; */ + if ((tmp = json_string_value(json_object_get(value, "foldermodseq")))) { + histitem->foldermodseq = atomodseq_t(tmp); + } + + /* uint32_t mbtype; */ + if ((tmp = json_string_value(json_object_get(value, "mbtype")))) { + histitem->mbtype = mboxlist_string_to_mbtype(tmp); + } + + /* char *partition; */ + if ((tmp = json_string_value(json_object_get(value, "partition")))) { + histitem->partition = xstrdup(tmp); + } + + ptrarray_append(name_history, histitem); + } +} + +static int do_undump(void) +{ + json_t *jmailboxes = NULL; + json_error_t jerr; + const char *key; + json_t *value; + + jmailboxes = json_loadf(stdin, 0, &jerr); + if (!jmailboxes) { + fprintf(stderr, "parse error at line %d: %s\n", jerr.line, jerr.text); + return -1; + } + + json_object_foreach(jmailboxes, key, value) { + mbentry_t *newmbentry = mboxlist_entry_create(); + const char *tmp; + json_t *jtmp; + + /* char *name; */ + if (strlen(key) >= MAX_MAILBOX_NAME) { + fprintf(stderr, "mailbox name too long: %s\n", key); + goto skip; + } + newmbentry->name = xstrdup(key); + + /* char *ext_name + * this field is a place to cache a calculated value, not + * a real value in mailboxes.db, so don't expect it. + */ + + /* time_t mtime; + * this field is ignored, the new mbentry will always be created with + * the current time in its mtime field. + */ + + /* uint32_t uidvalidity; */ + if ((tmp = json_string_value(json_object_get(value, "uidvalidity")))) { + newmbentry->uidvalidity = strtoul(tmp, NULL, 10); + } + else { + fprintf(stderr, "missing uidvalidity for %s\n", key); + } + + /* modseq_t createdmodseq; */ + if ((tmp = json_string_value(json_object_get(value, "createdmodseq")))) { + newmbentry->createdmodseq = atomodseq_t(tmp); + } + else { + fprintf(stderr, "missing createdmodseq for %s\n", key); + } + + /* modseq_t foldermodseq; */ + if ((tmp = json_string_value(json_object_get(value, "foldermodseq")))) { + newmbentry->foldermodseq = atomodseq_t(tmp); + } + else { + fprintf(stderr, "missing foldermodseq for %s\n", key); + } + + /* uint32_t mbtype; */ + if ((tmp = json_string_value(json_object_get(value, "mbtype")))) { + newmbentry->mbtype = mboxlist_string_to_mbtype(tmp); + } + else { + // XXX possibly infer mbtype from name + // We might want to set/verify mbtype based on the name. For + // instance user.foo.#calendars* should be MBTYPE_CALENDAR, + // user.foo.#jmap should be MBTYPE_COLLECTION, etc. + fprintf(stderr, "missing mbtype for %s\n", key); + } + + /* char *partition; */ + if ((tmp = json_string_value(json_object_get(value, "partition")))) { + if (strlen(tmp) >= MAX_PARTITION_LEN) { + fprintf(stderr, "partition too long for %s\n", key); + goto skip; + } + newmbentry->partition = xstrdup(tmp); + } + else { + fprintf(stderr, "missing mbtype for %s\n", key); + goto skip; + } + + /* char *server; */ + if ((tmp = json_string_value(json_object_get(value, "server")))) { + newmbentry->server = xstrdup(tmp); + } + else { + // XXX detect whether this needs to be present, whinge if it's not + // Its mandatory for frontends and mupdate in a Murder. Backends + // shouldn't have this in a traditional (non-unified) Murder. + } + + /* char *acl; */ + if ((jtmp = json_object_get(value, "acl"))) { + const char *aclkey; + json_t *aclvalue; + struct buf buf = BUF_INITIALIZER; + + json_object_foreach(jtmp, aclkey, aclvalue) { + buf_printf(&buf, "%s\t%s\t", + aclkey, + json_string_value(aclvalue)); + } + newmbentry->acl = buf_release(&buf); + } + + /* char *uniqueid; */ + if ((tmp = json_string_value(json_object_get(value, "uniqueid")))) { + newmbentry->uniqueid = xstrdup(tmp); + } + else { + /* XXX could potentially infer this if the mailbox is on disk */ + fprintf(stderr, "missing uniqueid for %s\n", key); + goto skip; + } + + /* char *legacy_specialuse; */ + if ((tmp = json_string_value(json_object_get(value, "legacy_specialuse")))) { + newmbentry->legacy_specialuse = xstrdup(tmp); + } + + /* ptrarray_t name_history; */ + if ((jtmp = json_object_get(value, "name_history"))) { + undump_name_history(&newmbentry->name_history, jtmp); + } + + /* generate a new entry */ + mboxlist_updatelock(newmbentry, /*localonly*/1); + /* XXX should we auditlog something here? */ + +skip: + mboxlist_entry_free(&newmbentry); + } + + json_decref(jmailboxes); + + return 0; +} + enum { ROOT = (1<<0), DOMAIN = (1<<1), - MBOX = (1<<2) + MBOX = (1<<2), + UUID = (1<<3), + MATCHED = (1<<4) }; struct found_data { @@ -639,45 +963,35 @@ struct found_data { char path[MAX_MAILBOX_PATH+1]; }; -struct found_list { - int idx; - int size; - int alloc; - struct found_data *data; -}; - -static void add_path(struct found_list *found, int type, +static void add_path(ptrarray_t *found, int type, const char *name, const char *part, const char *path) { struct found_data *new; - if (found->size == found->alloc) { - /* reached the end of our allocated array, double it */ - found->alloc *= 2; - found->data = xrealloc(found->data, - found->alloc * sizeof(struct found_data)); - } - - /* add our new node to the end of the array */ - new = &found->data[found->size++]; + new = xmalloc(sizeof(struct found_data)); new->type = type; strcpy(new->mboxname, name); strcpy(new->partition, part); strcpy(new->path, path); + + /* add our new node to the end of the list */ + ptrarray_append(found, new); } -static void add_part(struct found_list *found, +static void add_part(ptrarray_t *found, const char *part, const char *path, int override) { int i; + struct found_data *entry; /* see if we already added a partition having this name */ - for (i = 0; i < found->size; i++){ - if (!strcmp(found->data[i].partition, part)) { + for (i = 0; i < ptrarray_size(found); i++){ + entry = ptrarray_nth(found, i); + if (!strcmp(entry->partition, part)) { /* found it */ if (override) { /* replace the path with the one containing cyrus.header */ - strcpy(found->data[i].path, path); + strcpy(entry->path, path); } /* we already have the proper path, so we're done */ @@ -692,7 +1006,7 @@ static void add_part(struct found_list *found, static void get_partitions(const char *key, const char *value, void *rock) { static int check_meta = -1; - struct found_list *found = (struct found_list *) rock; + ptrarray_t *found = (ptrarray_t *) rock; if (check_meta == -1) { /* see if cyrus.header might be contained in a metapartition */ @@ -709,10 +1023,10 @@ static void get_partitions(const char *key, const char *value, void *rock) /* skip any other overflow strings */ } -static int compar_mbox(const void *v1, const void *v2) +static int compar_mbox(const void **v1, const void **v2) { - struct found_data *d1 = (struct found_data *) v1; - struct found_data *d2 = (struct found_data *) v2; + struct found_data *d1 = (struct found_data *) *v1; + struct found_data *d2 = (struct found_data *) *v2; /* non-mailboxes get pushed to the end of the array, otherwise we do an ASCII sort */ @@ -724,74 +1038,109 @@ static int compar_mbox(const void *v1, const void *v2) else return 0; } -static int verify_cb(const mbentry_t *mbentry, void *rockp) +static int add_mbox_cb(const mbentry_t *mbentry, void *rockp) { // This function is called for every entry in the database, - // and supplied an inventory in &found. *data however does - // not pass dlist_parsemap() unlike is the case with dump_db(). + // and stores all mailboxes in &mboxes. - struct found_list *found = (struct found_list *) rockp; - int r = 0; + ptrarray_t *mboxes = (ptrarray_t *) rockp; - if (r) { - printf("'%s' has a directory '%s' but no DB entry\n", - found->data[found->idx].mboxname, - found->data[found->idx].path - ); - } else { - // Walk the directories to see if the mailbox from data does have + /* skip deleted mailboxes and mailboxes without partition + as they cannot have a path in the filesystem */ + if (mbentry->partition == NULL || + mbentry->mbtype & MBTYPE_DELETED) + return 0; + + if (mbentry->mbtype & MBTYPE_LEGACY_DIRS) + add_path(mboxes, MBOX, mbentry->name, mbentry->partition, mbentry->uniqueid); + else + add_path(mboxes, MBOX | UUID, mbentry->uniqueid, mbentry->partition, mbentry->name); + + return 0; +} + +static void verify_mboxes(ptrarray_t *mboxes, ptrarray_t *found, int *idx) +{ + int i; + int r; + char *mbname; + struct found_data *found_mailbox_entry; + struct found_data *found_path_entry; + + for (i = 0; i < ptrarray_size(mboxes); i++) { + + found_mailbox_entry = ptrarray_nth(mboxes, i); + + if (found_mailbox_entry->type & UUID) + mbname = found_mailbox_entry->path; + else + mbname = found_mailbox_entry->mboxname; + + // Walk the directories to see if the mailbox does have // paths on the filesystem. do { r = -1; + found_path_entry = ptrarray_nth(found, *idx); if ( - (found->idx >= found->size) || /* end of array */ - !(found->data[found->idx].type & MBOX) || /* end of mailboxes */ - (r = strcmp(mbentry->name, found->data[found->idx].mboxname)) < 0 + !(found_path_entry->type & MBOX) || /* end of mailboxes */ + (r = strcmp(found_mailbox_entry->mboxname, found_path_entry->mboxname)) < 0 ) { printf("'%s' has a DB entry but no directory on partition '%s'\n", - mbentry->name, mbentry->partition); - + mbname, found_mailbox_entry->partition); + break; } - else if (r > 0) { - printf("'%s' has a directory '%s' but no DB entry\n", - found->data[found->idx].mboxname, - found->data[found->idx].path - ); - - found->idx++; + else if (r == 0) { + if (found_path_entry->type & MATCHED) { + printf("'%s' has an additional match to DB entry of mailbox '%s' on partition '%s'\n", + found_path_entry->path, mbname, found_mailbox_entry->partition); + } + /* mark filesystem entry as matched */ + found_path_entry->type |= MATCHED; } - else found->idx++; + (*idx)++; } while (r > 0); - } - return 0; + /* now report all unmatched mailboxes found in filesystem */ + for (i = 0; i < ptrarray_size(found); i++) { + found_path_entry = ptrarray_nth(found, i); + if (!(found_path_entry->type & MBOX)) break; + if (!(found_path_entry->type & MATCHED)) { + printf("'%s' has a directory '%s' but no DB entry\n", + found_path_entry->mboxname, + found_path_entry->path + ); + } + } } static void do_verify(void) { - struct found_list found; + ptrarray_t *found; + ptrarray_t *mboxes; int i; + int idx = 0; - found.idx = 0; - found.size = 0; - found.alloc = 10; - found.data = xmalloc(found.alloc * sizeof(struct found_data)); + found = ptrarray_new(); + ptrarray_init(found); + mboxes = ptrarray_new(); + ptrarray_init(mboxes); /* gather a list of partition paths to search */ - config_foreachoverflowstring(get_partitions, &found); + config_foreachoverflowstring(get_partitions, found); /* scan all paths in our list, tagging valid mailboxes, and adding paths as we find them */ - for (i = 0; i < found.size; i++) { + for (i = 0; i < ptrarray_size(found); i++) { DIR *dirp; struct dirent *dirent; char name[MAX_MAILBOX_BUFFER]; char part[MAX_MAILBOX_BUFFER]; char path[MAX_MAILBOX_PATH+1]; int type; + struct found_data *entry = ptrarray_nth(found, i); - if (config_hashimapspool && (found.data[i].type & ROOT)) { + if (config_hashimapspool && (entry->type & ROOT)) { /* need to add hashed directories */ int config_fulldirhash = libcyrus_config_getswitch(CYRUSOPT_FULLDIRHASH); char *tail; @@ -799,77 +1148,97 @@ static void do_verify(void) /* make the toplevel partition /a */ if (config_fulldirhash) { - strcat(found.data[i].path, "/A"); + strcat(entry->path, "/A"); c = 'B'; } else { - strcat(found.data[i].path, "/a"); + strcat(entry->path, "/a"); c = 'b'; } - type = (found.data[i].type &= ~ROOT); + type = (entry->type &= ~ROOT); /* make a template path for /b - /z */ - strcpy(name, found.data[i].mboxname); - strcpy(part, found.data[i].partition); - strcpy(path, found.data[i].path); + strcpy(name, entry->mboxname); + strcpy(part, entry->partition); + strcpy(path, entry->path); tail = path + strlen(path) - 1; for (j = 1; j < 26; j++, c++) { *tail = c; - add_path(&found, type, name, part, path); + add_path(found, type, name, part, path); } if (config_virtdomains && !type) { /* need to add root domain directory */ strcpy(tail, "domain"); - add_path(&found, DOMAIN | ROOT, name, part, path); + add_path(found, DOMAIN | ROOT, name, part, path); } + + /* need to add uuid directory */ + strcpy(tail, "uuid"); + add_path(found, type | UUID, name, part, path); } - if (!(dirp = opendir(found.data[i].path))) continue; + if (!(dirp = opendir(entry->path))) continue; while ((dirent = readdir(dirp))) { + const char *fname = FNAME_HEADER; if (dirent->d_name[0] == '.') continue; - else if (!strcmp(dirent->d_name, FNAME_HEADER+1)) { + else if (!strcmp(dirent->d_name, fname+1)) { /* XXX - check that it can be opened */ - found.data[i].type |= MBOX; + entry->type |= MBOX; + strcpy(name, entry->mboxname); } else if (!strchr(dirent->d_name, '.') || - (found.data[i].type & DOMAIN)) { + (entry->type & DOMAIN)) { /* probably a directory, add it to the array */ type = 0; - strcpy(name, found.data[i].mboxname); + strcpy(name, entry->mboxname); if (config_virtdomains && - (found.data[i].type == ROOT) && + (entry->type == ROOT) && !strcmp(dirent->d_name, "domain")) { /* root domain directory */ type = DOMAIN | ROOT; } - else if (!name[0] && found.data[i].type & DOMAIN) { + else if (!name[0] && entry->type & DOMAIN) { /* toplevel domain directory */ strcat(name, dirent->d_name); strcat(name, "!"); type = DOMAIN | ROOT; } + else if (entry->type & UUID) { + /* possibly a mailbox directory, use directory name without ancestor information */ + strcpy(name, dirent->d_name); + } else { /* possibly a mailbox directory */ - if (name[0] && !(found.data[i].type & DOMAIN)) strcat(name, "."); + if (name[0] && !(entry->type & DOMAIN)) strcat(name, "."); strcat(name, dirent->d_name); } - strcpy(part, found.data[i].partition); - strcpy(path, found.data[i].path); + strcpy(part, entry->partition); + strcpy(path, entry->path); strcat(path, "/"); strcat(path, dirent->d_name); - add_path(&found, type, name, part, path); + /* inherit UUID flag from parent entry */ + type = entry->type & UUID; + add_path(found, type, name, part, path); } } closedir(dirp); } - qsort(found.data, found.size, sizeof(struct found_data), compar_mbox); + ptrarray_sort(found, compar_mbox); + + /* gather all mailboxes and sort them, so that UUID and non-UUID + mailboxes are sorted in the way we need them to be to avoid + full nested looping */ + + mboxlist_allmbox("", &add_mbox_cb, mboxes, MBOXTREE_TOMBSTONES); + + ptrarray_sort(mboxes, compar_mbox); - mboxlist_allmbox("", &verify_cb, &found, MBOXTREE_TOMBSTONES); + verify_mboxes(mboxes, found, &idx); } static void usage(void) @@ -878,7 +1247,7 @@ static void usage(void) fprintf(stderr, " ctl_mboxlist [-C ] -d [-x] [-y] [-p partition] [-f filename]\n"); fprintf(stderr, "UNDUMP:\n"); fprintf(stderr, - " ctl_mboxlist [-C ] -u [-f filename]" + " ctl_mboxlist [-C ] -u [-f filename] [-L]" " [< mboxlist.dump]\n"); fprintf(stderr, "MUPDATE populate:\n"); fprintf(stderr, " ctl_mboxlist [-C ] -m [-a] [-w] [-i] [-f filename]\n"); @@ -896,31 +1265,39 @@ int main(int argc, char *argv[]) enum mboxop op = NONE; char *alt_config = NULL; int dointermediary = 0; - - while ((opt = getopt(argc, argv, "C:awmdurcxf:p:viy")) != EOF) { + int undump_legacy = 0; + + /* keep this in alphabetical order */ + static const char short_options[] = "C:Ladf:imp:uvwxy"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "legacy", no_argument, NULL, 'L' }, + { "authoritative", no_argument, NULL, 'a' }, + { "dump", no_argument, NULL, 'd' }, + { "filename", required_argument, NULL, 'f' }, + { "interactive", no_argument, NULL, 'i' }, + { "sync-mupdate", no_argument, NULL, 'm' }, + { "partition", required_argument, NULL, 'p' }, + { "undump", no_argument, NULL, 'u' }, + { "verify", no_argument, NULL, 'v' }, + { "warn-only", no_argument, NULL, 'w' }, + { "remove-dumped", no_argument, NULL, 'x' }, + { "include-intermediaries", no_argument, NULL, 'y' }, + + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; break; - case 'r': - /* deprecated, but we still support it */ - fprintf(stderr, "ctl_mboxlist -r is deprecated: " - "use ctl_cyrusdb -r instead\n"); - syslog(LOG_WARNING, "ctl_mboxlist -r is deprecated: " - "use ctl_cyrusdb -r instead"); - if (op == NONE) op = RECOVER; - else usage(); - break; - - case 'c': - /* deprecated, but we still support it */ - fprintf(stderr, "ctl_mboxlist -c is deprecated: " - "use ctl_cyrusdb -c instead\n"); - syslog(LOG_WARNING, "ctl_mboxlist -c is deprecated: " - "use ctl_cyrusdb -c instead"); - if (op == NONE) op = CHECKPOINT; - else usage(); + case 'L': + undump_legacy = 1; break; case 'f': @@ -985,54 +1362,46 @@ int main(int argc, char *argv[]) if (op != DUMP && partition) usage(); if (op != DUMP && dopurge) usage(); if (op != DUMP && dointermediary) usage(); - - if (op == RECOVER) { - syslog(LOG_NOTICE, "running mboxlist recovery"); - libcyrus_config_setint(CYRUSOPT_DB_INIT_FLAGS, CYRUSDB_RECOVER); - } + if (op != UNDUMP && undump_legacy) usage(); cyrus_init(alt_config, "ctl_mboxlist", 0, 0); global_sasl_init(1,0,NULL); switch (op) { - case RECOVER: - /* this was done by the call to cyrus_init via libcyrus */ - syslog(LOG_NOTICE, "done running mboxlist recovery"); - break; - - case CHECKPOINT: - syslog(LOG_NOTICE, "checkpointing mboxlist"); + case M_POPULATE: + syslog(LOG_NOTICE, "%spopulating mupdate", warn_only ? "test " : ""); mboxlist_init(); - mboxlist_open(NULL); + mboxlist_open(mboxdb_fname); + + do_pop_mupdate(); + mboxlist_close(); mboxlist_done(); - syslog(LOG_NOTICE, "done checkpointing mboxlist"); - break; - case M_POPULATE: - syslog(LOG_NOTICE, "%spopulating mupdate", warn_only ? "test " : ""); - GCC_FALLTHROUGH + syslog(LOG_NOTICE, "done %spopulating mupdate", warn_only ? "test " : ""); + break; case DUMP: mboxlist_init(); mboxlist_open(mboxdb_fname); - do_dump(op, partition, dopurge, dointermediary); + do_dump(partition, dopurge, dointermediary); mboxlist_close(); mboxlist_done(); - if (op == M_POPULATE) { - syslog(LOG_NOTICE, - "done %spopulating mupdate", warn_only ? "test " : ""); - } break; case UNDUMP: mboxlist_init(); mboxlist_open(mboxdb_fname); - do_undump(); + if (undump_legacy) { + do_undump_legacy(); + } + else { + do_undump(); + } mboxlist_close(); mboxlist_done(); diff --git a/imap/ctl_zoneinfo.c b/imap/ctl_zoneinfo.c index 185781922a..f42cff3131 100644 --- a/imap/ctl_zoneinfo.c +++ b/imap/ctl_zoneinfo.c @@ -47,6 +47,7 @@ #include #endif #include +#include #include #include #include @@ -56,6 +57,7 @@ #include #include +#include #include #include "annotate.h" /* for strlist functionality */ @@ -67,13 +69,10 @@ #include "xml_support.h" #include "zoneinfo_db.h" -extern int optind; -extern char *optarg; - /* config.c stuff */ const int config_need_data = 0; -int verbose = 0; +static int verbose = 0; /* forward declarations */ void usage(void); @@ -88,10 +87,23 @@ int main(int argc, char **argv) { int opt, r = 0; char *alt_config = NULL, *pub = NULL, *ver = NULL, *winfile = NULL; - char prefix[2048]; + const char *zoneinfo_dir = NULL; enum { REBUILD, WINZONES, NONE } op = NONE; - while ((opt = getopt(argc, argv, "C:r:vw:")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:r:vw:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "rebuild", required_argument, NULL, 'r' }, + { "verbose", no_argument, NULL, 'v' }, + { "windows-zone-xml", required_argument, NULL, 'w' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; @@ -130,7 +142,12 @@ int main(int argc, char **argv) signals_set_shutdown(&shut_down); signals_add_handlers(0); - snprintf(prefix, sizeof(prefix), "%s%s", config_dir, FNAME_ZONEINFODIR); + zoneinfo_dir = config_getstring(IMAPOPT_ZONEINFO_DIR); + if (!zoneinfo_dir) { + fprintf(stderr, "zoneinfo_dir must be set for tzdist service\n"); + cyrus_done(); + return EX_CONFIG; + } switch (op) { case REBUILD: { @@ -150,7 +167,7 @@ int main(int argc, char **argv) hash_insert(INFO_TZID, info, &tzentries); /* Add LEAP record (last updated and hash) */ - snprintf(buf, sizeof(buf), "%s%s", prefix, FNAME_LEAPSECFILE); + snprintf(buf, sizeof(buf), "%s%s", zoneinfo_dir, FNAME_LEAPSECFILE); if (verbose) printf("Processing leap seconds file %s\n", buf); if (!(fp = fopen(buf, "r"))) { fprintf(stderr, "Could not open leap seconds file %s\n", buf); @@ -187,7 +204,7 @@ int main(int argc, char **argv) } /* Add ZONE/LINK records */ - do_zonedir(prefix, &tzentries, info); + do_zonedir(zoneinfo_dir, &tzentries, info); zoneinfo_open(NULL); @@ -243,8 +260,8 @@ int main(int argc, char **argv) goto done; } - if (chdir(prefix)) { - fprintf(stderr, "chdir(%s) failed\n", prefix); + if (chdir(zoneinfo_dir)) { + fprintf(stderr, "chdir(%s) failed\n", zoneinfo_dir); goto done; } @@ -322,8 +339,8 @@ int main(int argc, char **argv) void usage(void) { fprintf(stderr, - "usage: zoneinfo_reconstruct [-C ] [-v]" - " -r :\n"); + "usage: ctl_zoneinfo [-C ] [-v]" + " -r : | -w \n"); exit(EX_USAGE); } @@ -342,6 +359,7 @@ void do_zonedir(const char *dir, struct hash_table *tzentries, dirp = opendir(dir); if (!dirp) { fprintf(stderr, "can't open zoneinfo directory %s\n", dir); + return; } while ((dirent = readdir(dirp))) { @@ -371,7 +389,7 @@ void do_zonedir(const char *dir, struct hash_table *tzentries, /* Isolate alias in path */ path[plen-4] = '\0'; /* Trim ".ics" */ - alias = path + strlen(config_dir) + strlen("zoneinfo") + 2; + alias = path + strlen(dir) + 1; if (verbose) printf("\tLINK: %s -> %s\n", alias, tzid); diff --git a/imap/cvt_cyrusdb.c b/imap/cvt_cyrusdb.c index ef488182c6..64bc1c6267 100644 --- a/imap/cvt_cyrusdb.c +++ b/imap/cvt_cyrusdb.c @@ -42,6 +42,7 @@ #include +#include #include #include #include @@ -74,7 +75,17 @@ int main(int argc, char *argv[]) int opt; char *alt_config = NULL; - while ((opt = getopt(argc, argv, "C:")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; @@ -104,7 +115,7 @@ int main(int argc, char *argv[]) if (old_db[0] != '/' || new_db[0] != '/') { printf("\nSorry, you cannot use this tool with relative path names.\n" - "This is because some database backends (mainly berkeley) do not\n" + "This is because some database backends do not\n" "always do what you would expect with them.\n" "\nPlease use absolute pathnames instead.\n\n"); exit(EX_OSERR); @@ -122,7 +133,11 @@ int main(int argc, char *argv[]) printf("Converting from %s (%s) to %s (%s)\n", old_db, OLDDB, new_db, NEWDB); - cyrusdb_convert(old_db, new_db, OLDDB, NEWDB); + int r = cyrusdb_convert(old_db, new_db, OLDDB, NEWDB); + if (r) { + printf("\nDBERROR: Conversion failed (r was %d)." + " Check syslog for details.\n", r); + } cyrus_done(); diff --git a/imap/cvt_xlist_specialuse.c b/imap/cvt_xlist_specialuse.c index 6e93acbab8..ca534461e6 100644 --- a/imap/cvt_xlist_specialuse.c +++ b/imap/cvt_xlist_specialuse.c @@ -43,6 +43,7 @@ #include +#include #include #include #include @@ -86,6 +87,9 @@ EXPORTED void fatal(const char *s, int code) { fprintf(stderr, "Fatal error: %s\n", s); syslog(LOG_ERR, "Fatal error: %s", s); + + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + exit(code); } @@ -115,8 +119,7 @@ static int set_specialuse(struct findall_data *data, void *rock) int r; if (!data) return 0; - - if (!data->mbname) return 0; /* XXX what does this mean? */ + if (!data->is_exactmatch) return 0; if (!mbname_userid(data->mbname)) return 0; @@ -162,7 +165,18 @@ int main (int argc, char **argv) hash_table xlist = HASH_TABLE_INITIALIZER; strarray_t patterns = STRARRAY_INITIALIZER; - while ((opt = getopt(argc, argv, "C:v")) != -1) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:v"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "verbose", no_argument, NULL, 'v' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': alt_config = optarg; diff --git a/imap/cyr_buildinfo.c b/imap/cyr_buildinfo.c index f5d7caa3c1..d60bf1ff1a 100644 --- a/imap/cyr_buildinfo.c +++ b/imap/cyr_buildinfo.c @@ -45,17 +45,29 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include #include #include -#include "global.h" -#include "proc.h" -#include "util.h" -#include "../master/masterconf.h" -#include "xmalloc.h" +#include "lib/proc.h" +#include "lib/util.h" +#include "lib/xmalloc.h" + +#include "imap/conversations.h" +#include "imap/global.h" +#include "imap/mailbox.h" +#include "imap/statuscache.h" +#include "imap/zoneinfo_db.h" + +#include "master/masterconf.h" + +#ifdef USE_SIEVE +#include "sieve/bytecode.h" +#include "sieve/sieve_interface.h" +#endif /* Make ld happy */ const char *MASTER_CONFIG_FILENAME = DEFAULT_MASTER_CONFIG_FILENAME; @@ -77,18 +89,22 @@ static void usage(void) /* Gather the build configuration parameters as JSON object */ static json_t *buildinfo() { - json_t *component = json_pack("{}"); - json_t *dependency = json_pack("{}"); - json_t *database = json_pack("{}"); - json_t *search = json_pack("{}"); - json_t *hardware = json_pack("{}"); - json_t *buildconf = json_pack("{}"); + json_t *component = json_object(); + json_t *dependency = json_object(); + json_t *database = json_object(); + json_t *search = json_object(); + json_t *hardware = json_object(); + json_t *buildconf = json_object(); + json_t *version = json_object(); + json_t *ical = json_object(); json_object_set_new(buildconf, "component", component); json_object_set_new(buildconf, "dependency", dependency); json_object_set_new(buildconf, "database", database); json_object_set_new(buildconf, "search", search); + json_object_set_new(buildconf, "ical", ical); json_object_set_new(buildconf, "hardware", hardware); + json_object_set_new(buildconf, "version", version); /* Yikes... */ @@ -159,16 +175,10 @@ static json_t *buildinfo() #else json_object_set_new(component, "backup", json_false()); #endif -#if defined(HAVE_UCDSNMP) || defined(HAVE_NETSNMP) - json_object_set_new(component, "snmp", json_true()); -#else - json_object_set_new(component, "snmp", json_false()); -#endif -#ifdef CYRUS_TIMEZONES_ZONEINFO_DIR - json_object_set_new(component, "default_zoneinfo_dir", - json_string(CYRUS_TIMEZONES_ZONEINFO_DIR)); +#ifdef ENABLE_DEBUG_SLOWIO + json_object_set_new(component, "slowio", json_true()); #else - json_object_set_new(component, "default_zoneinfo_dir", json_null()); + json_object_set_new(component, "slowio", json_false()); #endif /* Build dependencies */ @@ -197,21 +207,16 @@ static json_t *buildinfo() #else json_object_set_new(dependency, "pcre", json_false()); #endif +#if defined(ENABLE_REGEX) && defined(HAVE_PCRE2POSIX_H) + json_object_set_new(dependency, "pcre2", json_true()); +#else + json_object_set_new(dependency, "pcre2", json_false()); +#endif #ifdef HAVE_CLAMAV json_object_set_new(dependency, "clamav", json_true()); #else json_object_set_new(dependency, "clamav", json_false()); #endif -#ifdef HAVE_UCDSNMP - json_object_set_new(dependency, "ucdsnmp", json_true()); -#else - json_object_set_new(dependency, "ucdsnmp", json_false()); -#endif -#ifdef HAVE_NETSNMP - json_object_set_new(dependency, "netsnmp", json_true()); -#else - json_object_set_new(dependency, "netsnmp", json_false()); -#endif #ifdef WITH_OPENIO json_object_set_new(dependency, "openio", json_true()); #else @@ -242,6 +247,11 @@ static json_t *buildinfo() #else json_object_set_new(dependency, "ical", json_false()); #endif +#ifdef HAVE_LIBICALVCARD + json_object_set_new(dependency, "icalvcard", json_true()); +#else + json_object_set_new(dependency, "icalvcard", json_false()); +#endif #ifdef HAVE_ICU json_object_set_new(dependency, "icu4c", json_true()); #else @@ -257,6 +267,16 @@ static json_t *buildinfo() #else json_object_set_new(dependency, "chardet", json_false()); #endif +#ifdef HAVE_CLD2 + json_object_set_new(dependency, "cld2", json_true()); +#else + json_object_set_new(dependency, "cld2", json_false()); +#endif +#ifdef HAVE_GUESSTZ + json_object_set_new(dependency, "guesstz", json_true()); +#else + json_object_set_new(dependency, "guesstz", json_false()); +#endif /* Enabled databases */ #ifdef HAVE_MYSQL @@ -286,15 +306,56 @@ static json_t *buildinfo() #else json_object_set_new(search, "xapian", json_false()); #endif - json_object_set_new(search, "xapian_flavor", json_string(XAPIAN_FLAVOR)); + json_object_set_new(search, "xapian_cjk_tokens", json_string(XAPIAN_CJK_TOKENS)); + + /* iCalendar features */ +#ifdef HAVE_ICALPARSER_CTRL + json_object_set_new(ical, "ctrl", json_true()); +#else + json_object_set_new(ical, "ctrl", json_false()); +#endif - /* Supported hardware features */ -#ifdef HAVE_SSE42 - json_object_set_new(hardware, "sse42", json_true()); + /* Internal version numbers */ +#ifdef USE_SIEVE + json_object_set_new(version, "BYTECODE_MIN_VERSION", + json_integer(BYTECODE_MIN_VERSION)); + json_object_set_new(version, "BYTECODE_VERSION", + json_integer(BYTECODE_VERSION)); +#endif + json_object_set_new(version, "CONVERSATIONS_KEY_VERSION", + json_integer(CONVERSATIONS_KEY_VERSION)); + json_object_set_new(version, "CONVERSATIONS_RECORD_VERSION", + json_integer(CONVERSATIONS_RECORD_VERSION)); + json_object_set_new(version, "CONVERSATIONS_STATUS_VERSION", + json_integer(CONVERSATIONS_STATUS_VERSION)); + json_object_set_new(version, "CONV_GUIDREC_BYNAME_VERSION", + json_integer(CONV_GUIDREC_BYNAME_VERSION)); + json_object_set_new(version, "CONV_GUIDREC_VERSION", + json_integer(CONV_GUIDREC_VERSION)); + json_object_set_new(version, "CYRUS_VERSION", + json_string(CYRUS_VERSION)); + json_object_set_new(version, "MAILBOX_CACHE_MINOR_VERSION", + json_integer(MAILBOX_CACHE_MINOR_VERSION)); + json_object_set_new(version, "MAILBOX_MINOR_VERSION", + json_integer(MAILBOX_MINOR_VERSION)); +#ifdef USE_SIEVE + json_object_set_new(version, "SIEVE_VERSION", + json_string(SIEVE_VERSION)); +#endif + json_object_set_new(version, "STATUSCACHE_VERSION", + json_integer(STATUSCACHE_VERSION)); + json_object_set_new(version, "ZONEINFO_VERSION", + json_integer(ZONEINFO_VERSION)); + +#if defined __USE_FORTIFY_LEVEL && __USE_FORTIFY_LEVEL > 0 + json_object_set_new(version, "FORTIFY_LEVEL", + json_integer(__USE_FORTIFY_LEVEL)); #else - json_object_set_new(hardware, "sse42", json_false()); + json_object_set_new(version, "FORTIFY_LEVEL", + json_integer(0)); #endif + /* Whew ... */ return buildconf; } @@ -349,8 +410,18 @@ int main(int argc, char *argv[]) struct buf buf = BUF_INITIALIZER; json_t *bi; + /* keep this in alphabetical order */ + static const char short_options[] = "C:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { 0, 0, 0, 0 }, + }; + /* Parse arguments */ - while ((opt = getopt(argc, argv, "C:")) != EOF) { + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file. We don't care but don't bark for -C. */ break; diff --git a/imap/cyr_cd.sh b/imap/cyr_cd.sh new file mode 100644 index 0000000000..0eebeff47d --- /dev/null +++ b/imap/cyr_cd.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# +# cyr_cd -- Shell script to change directory within a mailbox spool +# +# Copyright (c) 1994-2019 Carnegie Mellon University. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. The name "Carnegie Mellon University" must not be used to +# endorse or promote products derived from this software without +# prior written permission. For permission or any legal +# details, please contact +# Carnegie Mellon University +# Center for Technology Transfer and Enterprise Creation +# 4615 Forbes Avenue +# Suite 302 +# Pittsburgh, PA 15213 +# (412) 268-7393, fax: (412) 268-7395 +# innovation@andrew.cmu.edu +# +# 4. Redistributions of any form whatsoever must retain the following +# acknowledgment: +# "This product includes software developed by Computing Services +# at Carnegie Mellon University (http://www.cmu.edu/computing/)." +# +# CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO +# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE +# FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +# OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +# This script should be sourced from the command line or from within .bashrc + +cyr_cd() { + set -- `mbpath $@` + if test $# -ne 0 + then + cd "$@" + fi +} diff --git a/imap/cyr_dbtool.c b/imap/cyr_dbtool.c index cdfbd3b0ae..355ed2f902 100644 --- a/imap/cyr_dbtool.c +++ b/imap/cyr_dbtool.c @@ -42,6 +42,7 @@ #include +#include #include #include #include @@ -155,7 +156,6 @@ static void batch_commands(struct db *db) int r = 0; prot_setisclient(in, 1); - prot_setisclient(out, 1); while (1) { buf_reset(&cmd); @@ -166,9 +166,9 @@ static void batch_commands(struct db *db) if (c == EOF) break; if (c == ' ') - c = getastring(in, NULL, &key); + c = getbastring(in, NULL, &key); if (c == ' ') - c = getastring(in, NULL, &val); + c = getbastring(in, NULL, &val); if (c == '\r') c = prot_getc(in); if (c != '\n') { r = IMAP_PROTOCOL_BAD_PARAMETERS; @@ -197,9 +197,17 @@ static void batch_commands(struct db *db) const char *res; size_t reslen; r = cyrusdb_fetch(db, key.s, key.len, &res, &reslen, tidp); - if (r) goto done; - aprinter_cb(out, key.s, key.len, res, reslen); - prot_flush(out); + switch (r) { + case 0: + aprinter_cb(out, key.s, key.len, res, reslen); + prot_flush(out); + break; + case CYRUSDB_NOTFOUND: + r = 0; + break; + default: + goto done; + } } else if (!strcmp(cmd.s, "DELETE")) { r = cyrusdb_delete(db, key.s, key.len, tidp, 1); @@ -238,6 +246,13 @@ static void batch_commands(struct db *db) fprintf(stderr, "FAILED: line %d at cmd %.*s with error %s\n", line, (int)cmd.len, cmd.s, error_message(r)); } + + prot_free(in); + prot_free(out); + + buf_free(&cmd); + buf_free(&key); + buf_free(&val); } int main(int argc, char *argv[]) @@ -259,7 +274,22 @@ int main(int argc, char *argv[]) struct txn *tid = NULL; struct txn **tidp = NULL; - while ((opt = getopt(argc, argv, "C:MntTc")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:MTcnt"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "improved-mboxlist-sort", no_argument, NULL, 'M' }, + { "use-transaction", no_argument, NULL, 'T' }, + { "convert", no_argument, NULL, 'c' }, /* XXX undocumented */ + { "create", no_argument, NULL, 'n' }, + { "no-transaction", no_argument, NULL, 't' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; @@ -297,6 +327,11 @@ int main(int argc, char *argv[]) fprintf(stderr, "\n"); fprintf(stderr, "\n"); + fprintf(stderr, "Options:\n"); + fprintf(stderr, " -c convert database to named backend if not already\n"); + fprintf(stderr, " -M use \"improved_mboxlist_sort\" order\n"); + fprintf(stderr, " -n create the database if it doesn't exist\n"); + fprintf(stderr, "\n"); fprintf(stderr, "Actions:\n"); fprintf(stderr, "* show []\n"); fprintf(stderr, "* get \n"); @@ -317,7 +352,7 @@ int main(int argc, char *argv[]) if(fname[0] != '/') { printf("\nSorry, you cannot use this tool with relative path names.\n" - "This is because some database backends (mainly berkeley) do not\n" + "This is because some database backends do not\n" "always do what you would expect with them.\n" "\nPlease use absolute pathnames instead.\n\n"); exit(EX_OSERR); diff --git a/imap/cyr_deny.c b/imap/cyr_deny.c index ce88d1754f..c45a89ccfb 100644 --- a/imap/cyr_deny.c +++ b/imap/cyr_deny.c @@ -45,6 +45,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -185,7 +186,21 @@ int main(int argc, char **argv) const char *services = NULL; int r; - while ((opt = getopt(argc, argv, "C:alm:s:")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:alm:s:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "allow", no_argument, NULL, 'a' }, + { "list", no_argument, NULL, 'l' }, + { "message", required_argument, NULL, 'm' }, + { "services", required_argument, NULL, 's' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; diff --git a/imap/cyr_df.c b/imap/cyr_df.c index a1c0d4469d..fc5b7f4480 100644 --- a/imap/cyr_df.c +++ b/imap/cyr_df.c @@ -45,6 +45,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -56,9 +57,6 @@ #include "util.h" #include "xmalloc.h" -extern int optind; -extern char *optarg; - /* forward declarations */ static void usage(void); static void get_part_stats(const char *key, const char *val, void *rock); @@ -69,7 +67,18 @@ int main(int argc, char *argv[]) char *alt_config = NULL; int meta = 0; - while ((opt = getopt(argc, argv, "C:m")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:m"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "metadata", no_argument, NULL, 'm' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; diff --git a/imap/cyr_expire.c b/imap/cyr_expire.c index bc6682e269..f0ae27dcd3 100644 --- a/imap/cyr_expire.c +++ b/imap/cyr_expire.c @@ -53,6 +53,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -60,7 +61,6 @@ #include #include #include -#include #include #include #include @@ -75,6 +75,7 @@ #include "mboxevent.h" #include "mboxlist.h" #include "conversations.h" +#include "user.h" #include "util.h" #include "xmalloc.h" #include "strarray.h" @@ -87,9 +88,9 @@ #define SECS_IN_A_DAY (24 * SECS_IN_AN_HR) /* global state */ -static volatile sig_atomic_t sigquit = 0; static int verbose = 0; static const char *progname = NULL; +static time_t progtime = 0; static struct namespace expire_namespace; /* current namespace */ /* command line arguments */ @@ -109,6 +110,8 @@ struct arguments { const char *altconfig; const char *mbox_prefix; const char *userid; + + char *freeme; /* for mbox_prefix */ }; struct archive_rock { @@ -121,6 +124,7 @@ struct expire_rock { struct hash_table table; time_t expire_mark; time_t expunge_mark; + time_t tombstone_mark; unsigned long mailboxes_seen; unsigned long messages_seen; unsigned long messages_expired; @@ -154,9 +158,7 @@ struct cyr_expire_ctx { struct expire_rock erock; }; -static const struct cyr_expire_ctx zero_ctx; - -static void sighandler(int sig); +static struct cyr_expire_ctx ctx; /* verbosep - a wrapper to print if the 'verbose' option is turned on. @@ -177,18 +179,7 @@ static inline void verbosep(const char *fmt, ...) static void cyr_expire_init(const char *progname, struct cyr_expire_ctx *ctx) { - struct sigaction action; - - /* Initialise signal handlers */ - sigemptyset(&action.sa_mask); - action.sa_flags = 0; - action.sa_handler = sighandler; - if (sigaction(SIGQUIT, &action, NULL) < 0) - fatal("unable to install signal handler for SIGQUIT", EX_TEMPFAIL); - if (sigaction(SIGINT, &action, NULL) < 0) - fatal("unable to install signal handler for SIGINT", EX_TEMPFAIL); - if (sigaction(SIGTERM, &action, NULL) < 0) - fatal("unable to install signal handler for SIGTERM", EX_TEMPFAIL); + signals_add_handlers(0); construct_hash_table(&ctx->erock.table, 10000, 1); strarray_init(&ctx->drock.to_delete); @@ -209,6 +200,8 @@ static void cyr_expire_init(const char *progname, struct cyr_expire_ctx *ctx) static void cyr_expire_cleanup(struct cyr_expire_ctx *ctx) { + if (ctx->args.freeme) free(ctx->args.freeme); + free_hash_table(&ctx->erock.table, free); free_hash_table(&ctx->crock.seen, NULL); strarray_fini(&ctx->drock.to_delete); @@ -244,64 +237,17 @@ static void usage(void) } /* - * Parse a non-negative duration string as seconds. - * - * Convert "23.5m" to fractional days. Accepts the suffixes "d" (day), - * (day), "h" (hour), "m" (minute) and "s" (second). If no suffix, assume - * days. - * Returns 1 if successful and *secondsp is filled in, or 0 if the suffix - * is unknown or on error. - */ -static int parse_duration(const char *s, int *secondsp) -{ - char *end = NULL; - double val; - int multiplier = SECS_IN_A_DAY; /* default is days */ - - /* no negative or empty numbers please */ - if (!*s || *s == '-') - return 0; - - val = strtod(s, &end); - /* Allow 'd', 'h', 'm' and 's' as end, else return error. */ - if (*end) { - if (end[1]) return 0; /* trailing extra junk */ - - switch (*end) { - case 'd': - /* already the default */ - break; - case 'h': - multiplier = SECS_IN_AN_HR; - break; - case 'm': - multiplier = SECS_IN_A_MIN; - break; - case 's': - multiplier = 1; - break; - default: - return 0; - } - } - - *secondsp = multiplier * val; - - return 1; -} - -/* - * Given an annotation, reads it, and converts it into 'seconds', - * using `parse_duration`. + * Given an annotation, reads it from the mailbox or any of its + * parents if iterate is true. * * On Success: Returns 1 * On Failure: Returns 0 */ static int get_annotation_value(const char *mboxname, const char *annot_entry, - int *secondsp, bool iterate) + struct buf *annot_value, + bool iterate) { - struct buf attrib = BUF_INITIALIZER; int ret = 0; /* mboxname needs to be copied since `mboxname_make_parent` * runs a strrchr() on it. @@ -313,22 +259,97 @@ static int get_annotation_value(const char *mboxname, * so we need to iterate all the way up to "" (server entry). */ do { - buf_free(&attrib); - ret = annotatemore_lookup(buf, annot_entry, "", &attrib); + buf_reset(annot_value); + ret = annotatemore_lookup(buf, annot_entry, "", annot_value); if (ret || /* error */ - attrib.s) /* found an entry */ + buf_len(annot_value)) /* found an entry */ break; } while (mboxname_make_parent(buf) && iterate); - if (attrib.s && parse_duration(attrib.s, secondsp)) + free(buf); + + return buf_len(annot_value) ? 1 : 0; +} + +static int get_duration_annotation(const char *mboxname, + const char *annot_entry, + int *secondsp, bool iterate) +{ + struct buf attrib = BUF_INITIALIZER; + int ret = 0; + + if (get_annotation_value(mboxname, annot_entry, &attrib, iterate) && + !config_parseduration(buf_cstring(&attrib), 'd', secondsp)) ret = 1; - else - ret = 0; buf_free(&attrib); - free(buf); + return ret; +} + +static int get_time_annotation(const char *mboxname, + const char *annot_entry, + time_t *timep, bool iterate) +{ + struct buf attrib = BUF_INITIALIZER; + int ret = 0; + + if (get_annotation_value(mboxname, annot_entry, &attrib, iterate)) { + const char *end = NULL; + bit64 v64 = 0; + if (!parsenum(buf_cstring(&attrib), &end, 0, &v64) && !*end) { + *timep = v64; + ret = 1; + } + } + + buf_free(&attrib); + return ret; +} - syslog(LOG_DEBUG, "get_annotation_value: ret(%d):secondsp(%d)\n", ret, *secondsp); +static int noexpire_mailbox(const mbentry_t *mbentry) +{ + int ret = 0; + mbname_t *mbname = mbname_from_intname(mbentry->name); + + if (mbname_userid(mbname)) { + // Cache result for the last seen userid + static struct { + struct buf userid; + int has_noexpire; + } last_seen = { BUF_INITIALIZER, -1 }; + + if (!strcmp(mbname_userid(mbname), buf_cstring(&last_seen.userid))) { + ret = last_seen.has_noexpire; + goto done; + } + + if (user_isreplicaonly(mbname_userid(mbname))) { + ret = 1; + goto done; + } + + // Determine user inbox name + if (mbname_isdeleted(mbname)) { + mbname_t *tmp = mbname_from_userid(mbname_userid(mbname)); + mbname_free(&mbname); + mbname = tmp; + } + mbname_truncate_boxes(mbname, 0); + + // Lookup annotation, ignoring any pre-epoch timestamps + time_t until; + if (get_time_annotation(mbname_intname(mbname), + IMAP_ANNOT_NS "noexpire_until", &until, false)) + ret = !until || (until > 0 && progtime < until); + + // Update cache + buf_setcstr(&last_seen.userid, mbname_userid(mbname)); + last_seen.has_noexpire = ret; + } + +done: + if (ret) verbosep("(noexpire) %s", mbname_intname(mbname)); + mbname_free(&mbname); return ret; } @@ -340,10 +361,10 @@ static int expunge_userflags(struct mailbox *mailbox, struct expire_rock *erock) for (i = 0; i < MAX_USER_FLAGS; i++) { if (erock->userflags[i/32] & 1<<(i&31)) continue; - if (!mailbox->flagname[i]) + if (!mailbox->h.flagname[i]) continue; - verbosep("Expunging userflag %u (%s) from %s\n", - i, mailbox->flagname[i], mailbox->name); + verbosep("Expunging userflag %u (%s) from %s", + i, mailbox->h.flagname[i], mailbox_name(mailbox)); r = mailbox_remove_user_flag(mailbox, i); if (r) return r; erock->userflags_expunged++; @@ -375,8 +396,7 @@ static int archive(const mbentry_t *mbentry, void *rock) struct mailbox *mailbox = NULL; int archive_seconds = -1; - if (sigquit) - return 1; + signals_poll(); if (mbentry->mbtype & MBTYPE_DELETED) goto done; @@ -389,7 +409,7 @@ static int archive(const mbentry_t *mbentry, void *rock) /* check /vendor/cmu/cyrus-imapd/archive */ if (!arock->skip_annotate && - get_annotation_value(mbentry->name, IMAP_ANNOT_NS "archive", + get_duration_annotation(mbentry->name, IMAP_ANNOT_NS "archive", &archive_seconds, false)) { arock->archive_mark = archive_seconds ? time(0) - archive_seconds : 0; @@ -399,10 +419,11 @@ static int archive(const mbentry_t *mbentry, void *rock) * in imap/mailbox.c. This one takes the arock->archive_mark as the * callback data. */ - mailbox_archive(mailbox, NULL, &arock->archive_mark, ITER_SKIP_EXPUNGED); + mailbox_archive(mailbox, NULL, NULL, &arock->archive_mark); done: mailbox_close(&mailbox); + libcyrus_run_delayed(); /* move on to the next mailbox regardless of errors */ return 0; @@ -446,10 +467,7 @@ static int expire(const mbentry_t *mbentry, void *rock) int expire_seconds = 0; int did_expunge = 0; - if (sigquit) { - /* don't care if we leak some memory, we are shutting down */ - return 1; - } + signals_poll(); /* Skip remote mailboxes */ if (mbentry->mbtype & MBTYPE_REMOTE) @@ -457,10 +475,10 @@ static int expire(const mbentry_t *mbentry, void *rock) /* clean up deleted entries after 7 days */ if (mbentry->mbtype & MBTYPE_DELETED) { - if (time(0) - mbentry->mtime > SECS_IN_A_DAY*7) { - verbosep("Removing stale tombstone for %s\n", mbentry->name); + if (mbentry->mtime < erock->tombstone_mark) { + verbosep("Removing stale tombstone for %s", mbentry->name); syslog(LOG_NOTICE, "Removing stale tombstone for %s", mbentry->name); - mboxlist_delete(mbentry->name); + mboxlist_deletelock(mbentry); } goto done; } @@ -475,12 +493,16 @@ static int expire(const mbentry_t *mbentry, void *rock) goto done; } + /* see if this mailbox should be ignored */ + if (noexpire_mailbox(mbentry)) + goto done; + /* see if we need to expire messages. * since mailboxes inherit /vendor/cmu/cyrus-imapd/expire, * we need to iterate all the way up to "" (server entry) */ if (!erock->skip_annotate && - get_annotation_value(mbentry->name, IMAP_ANNOT_NS "expire", + get_duration_annotation(mbentry->name, IMAP_ANNOT_NS "expire", &expire_seconds, true)) { /* add mailbox to table */ erock->expire_mark = expire_seconds ? @@ -490,11 +512,11 @@ static int expire(const mbentry_t *mbentry, void *rock) &erock->table); if (expire_seconds) { - verbosep("expiring messages in %s older than %0.2f days\n", + verbosep("expiring messages in %s older than %0.2f days", mbentry->name, ((double)expire_seconds/SECS_IN_A_DAY)); - r = mailbox_expunge(mailbox, expire_cb, erock, NULL, + r = mailbox_expunge(mailbox, NULL, expire_cb, erock, NULL, EVENT_MESSAGE_EXPIRE); if (r) syslog(LOG_ERR, "failed to expire old messages: %s", mbentry->name); @@ -503,7 +525,7 @@ static int expire(const mbentry_t *mbentry, void *rock) } if (!did_expunge && erock->do_userflags) { - r = mailbox_expunge(mailbox, userflag_cb, erock, NULL, + r = mailbox_expunge(mailbox, NULL, userflag_cb, erock, NULL, EVENT_MESSAGE_EXPIRE); if (r) syslog(LOG_ERR, "failed to scan user flags for %s: %s", @@ -515,9 +537,9 @@ static int expire(const mbentry_t *mbentry, void *rock) if (erock->do_userflags) expunge_userflags(mailbox, erock); - verbosep("cleaning up expunged messages in %s\n", mbentry->name); + verbosep("cleaning up expunged messages in %s", mbentry->name); - r = mailbox_expunge_cleanup(mailbox, erock->expunge_mark, &numexpunged); + r = mailbox_expunge_cleanup(mailbox, NULL, erock->expunge_mark, &numexpunged); erock->messages_expunged += numexpunged; erock->mailboxes_seen++; @@ -529,6 +551,7 @@ static int expire(const mbentry_t *mbentry, void *rock) done: mailbox_close(&mailbox); + libcyrus_run_delayed(); /* Even if we had a problem with one mailbox, continue with the others */ return 0; } @@ -539,8 +562,7 @@ static int delete(const mbentry_t *mbentry, void *rock) time_t timestamp; int delete_seconds = -1; - if (sigquit) - return 1; + signals_poll(); if (mbentry->mbtype & MBTYPE_DELETED) goto done; @@ -552,9 +574,13 @@ static int delete(const mbentry_t *mbentry, void *rock) if (!mboxname_isdeletedmailbox(mbentry->name, ×tamp)) goto done; + /* see if this mailbox should be ignored */ + if (noexpire_mailbox(mbentry)) + goto done; + /* check /vendor/cmu/cyrus-imapd/delete */ if (!drock->skip_annotate && - get_annotation_value(mbentry->name, IMAP_ANNOT_NS "delete", + get_duration_annotation(mbentry->name, IMAP_ANNOT_NS "delete", &delete_seconds, false)) { drock->delete_mark = delete_seconds ? time(0) - delete_seconds: 0; @@ -563,7 +589,7 @@ static int delete(const mbentry_t *mbentry, void *rock) if ((timestamp == 0) || (timestamp > drock->delete_mark)) goto done; - verbosep("Cleaning up %s\n", mbentry->name); + verbosep("Cleaning up %s", mbentry->name); /* Add this mailbox to list of mailboxes to delete */ strarray_append(&drock->to_delete, mbentry->name); @@ -580,8 +606,7 @@ static int expire_conversations(const mbentry_t *mbentry, void *rock) unsigned int nseen = 0, ndeleted = 0; char *filename = NULL; - if (sigquit) - return 1; + signals_poll(); if (mbentry->mbtype & MBTYPE_DELETED) goto done; @@ -599,11 +624,12 @@ static int expire_conversations(const mbentry_t *mbentry, void *rock) if (hash_lookup(filename, &crock->seen)) goto done; - verbosep("Pruning conversations from db %s\n", filename); + verbosep("Pruning conversations from db %s", filename); if (!conversations_open_mbox(mbentry->name, 0/*shared*/, &state)) { conversations_prune(state, crock->expire_mark, &nseen, &ndeleted); conversations_commit(&state); + libcyrus_run_delayed(); } hash_insert(filename, (void *)1, &crock->seen); @@ -617,16 +643,10 @@ static int expire_conversations(const mbentry_t *mbentry, void *rock) return 0; } -static void sighandler(int sig __attribute((unused))) -{ - sigquit = 1; - return; -} - static int do_archive(struct cyr_expire_ctx *ctx) { if (ctx->args.archive_seconds >= 0) { - syslog(LOG_DEBUG, ">> do_archive: archive_seconds(%d) >= 0\n", + syslog(LOG_DEBUG, ">> do_archive: archive_seconds(%d) >= 0", ctx->args.archive_seconds); ctx->arock.archive_mark = time(0) - ctx->args.archive_seconds; @@ -656,10 +676,13 @@ static int do_expunge(struct cyr_expire_ctx *ctx) } else { ctx->erock.expunge_mark = time(0) - ctx->args.expunge_seconds; - verbosep("Expunging deleted messages in mailboxes older than %0.2f days\n", + verbosep("Expunging deleted messages in mailboxes older than %0.2f days", ((double)ctx->args.expunge_seconds/SECS_IN_A_DAY)); } + /* XXX _ a control for this too? */ + ctx->erock.tombstone_mark = time(0) - SECS_IN_A_DAY*7; + if (ctx->args.userid) mboxlist_usermboxtree(ctx->args.userid, NULL, expire, &ctx->erock, MBOXTREE_DELETED|MBOXTREE_TOMBSTONES); @@ -673,8 +696,8 @@ static int do_expunge(struct cyr_expire_ctx *ctx) ctx->erock.messages_expunged, ctx->erock.messages_seen, ctx->erock.mailboxes_seen); - verbosep("\nExpired %lu and expunged %lu out of %lu " - "messages from %lu mailboxes\n", + verbosep("Expired %lu and expunged %lu out of %lu " + "messages from %lu mailboxes", ctx->erock.messages_expired, ctx->erock.messages_expunged, ctx->erock.messages_seen, @@ -683,7 +706,7 @@ static int do_expunge(struct cyr_expire_ctx *ctx) if (ctx->erock.do_userflags) { syslog(LOG_NOTICE, "Expunged %lu user flags", ctx->erock.userflags_expunged); - verbosep("Expunged %lu user flags\n", + verbosep("Expunged %lu user flags", ctx->erock.userflags_expunged); } @@ -697,10 +720,10 @@ static int do_cid_expire(struct cyr_expire_ctx *ctx) if (ctx->args.do_cid_expire) { int cid_expire_seconds; - cid_expire_seconds = config_getint(IMAPOPT_CONVERSATIONS_EXPIRE_DAYS) * SECS_IN_A_DAY; + cid_expire_seconds = config_getduration(IMAPOPT_CONVERSATIONS_EXPIRE_AFTER, 'd'); ctx->crock.expire_mark = time(0) - cid_expire_seconds; - verbosep("Removing conversation entries older than %0.2f days\n", + verbosep("Removing conversation entries older than %0.2f days", (double)(cid_expire_seconds/SECS_IN_A_DAY)); if (ctx->args.userid) @@ -716,7 +739,7 @@ static int do_cid_expire(struct cyr_expire_ctx *ctx) ctx->crock.msgids_seen, ctx->crock.databases_seen); verbosep("Expired %lu entries of %lu entries seen " - "in %lu conversation databases\n", + "in %lu conversation databases", ctx->crock.msgids_expired, ctx->crock.msgids_seen, ctx->crock.databases_seen); @@ -735,31 +758,33 @@ static int do_delete(struct cyr_expire_ctx *ctx) int count = 0; int i; - verbosep("Removing deleted mailboxes older than %0.2f days\n", + verbosep("Removing deleted mailboxes older than %0.2f days", ((double)ctx->args.delete_seconds/SECS_IN_A_DAY)); ctx->drock.delete_mark = time(0) - ctx->args.delete_seconds; if (ctx->args.userid) mboxlist_usermboxtree(ctx->args.userid, NULL, delete, - &ctx->drock, MBOXTREE_DELETED); + &ctx->drock, MBOXTREE_DELETED|MBOXTREE_INTERMEDIATES); else - mboxlist_allmbox(ctx->args.mbox_prefix, delete, &ctx->drock, 0); + mboxlist_allmbox(ctx->args.mbox_prefix, delete, &ctx->drock, MBOXTREE_INTERMEDIATES); for (i = 0 ; i < ctx->drock.to_delete.count ; i++) { char *name = ctx->drock.to_delete.data[i]; - if (sigquit) - return ret; /* return from here, will quit in main. */ + signals_poll(); - verbosep("Removing: %s\n", name); + verbosep("Removing: %s", name); - ret = mboxlist_deletemailbox(name, 1, NULL, NULL, NULL, 0, 0, 0, 0); + int flags = MBOXLIST_DELETE_KEEP_INTERMEDIARIES | MBOXLIST_DELETE_SILENT; + + ret = mboxlist_deletemailboxlock(name, 1, NULL, NULL, NULL, flags); + libcyrus_run_delayed(); /* XXX: Ignoring the return from mboxlist_deletemailbox() ??? */ count++; } - verbosep("Removed %d deleted mailboxes\n", count); + verbosep("Removed %d deleted mailboxes", count); syslog(LOG_NOTICE, "Removed %d deleted mailboxes", count); } @@ -778,9 +803,28 @@ static int do_duplicate_prune(struct cyr_expire_ctx *ctx) static int parse_args(int argc, char *argv[], struct arguments *args) { - extern char *optarg; int opt; + /* keep this in alphabetical order */ + static const char short_options[] = "A:C:D:E:X:achp:tu:vx"; + + static const struct option long_options[] = { + { "archive-duration", required_argument, NULL, 'A' }, + /* n.b. no long option for -C */ + { "delete-duration", required_argument, NULL, 'D' }, + { "expire-duration", required_argument, NULL, 'E' }, + { "expunge-duration", required_argument, NULL, 'X' }, + { "ignore-annotations", no_argument, NULL, 'a' }, + { "no-conversations", no_argument, NULL, 'c' }, + { "help", no_argument, NULL, 'h' }, + { "prefix", required_argument, NULL, 'p' }, + { "prune-userflags", required_argument, NULL, 't' }, + { "userid", required_argument, NULL, 'u' }, + { "verbose", no_argument, NULL, 'v' }, + { "no-expunge", no_argument, NULL, 'x' }, + { 0, 0, 0, 0 }, + }; + args->archive_seconds = -1; args->delete_seconds = -1; args->expire_seconds = -1; @@ -788,10 +832,13 @@ static int parse_args(int argc, char *argv[], struct arguments *args) args->do_expunge = true; args->do_cid_expire = -1; - while ((opt = getopt(argc, argv, "C:D:E:X:A:p:u:vaxtch")) != EOF) { + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'A': - if (!parse_duration(optarg, &args->archive_seconds)) usage(); + if (config_parseduration(optarg, 'd', &args->archive_seconds) < 0) + usage(); break; case 'C': @@ -799,17 +846,17 @@ static int parse_args(int argc, char *argv[], struct arguments *args) break; case 'D': - if (!parse_duration(optarg, &args->delete_seconds)) + if (config_parseduration(optarg, 'd', &args->delete_seconds) < 0) usage(); break; case 'E': - if (!parse_duration(optarg, &args->expire_seconds)) + if (config_parseduration(optarg, 'd', &args->expire_seconds) < 0) usage(); break; case 'X': - if (!parse_duration(optarg, &args->expunge_seconds)) + if (config_parseduration(optarg, 'd', &args->expunge_seconds) < 0) usage(); break; @@ -822,6 +869,7 @@ static int parse_args(int argc, char *argv[], struct arguments *args) break; case 'p': + if (args->userid) usage(); args->mbox_prefix = optarg; break; @@ -830,6 +878,7 @@ static int parse_args(int argc, char *argv[], struct arguments *args) break; case 'u': + if (args->mbox_prefix) usage(); args->userid = optarg; break; @@ -859,16 +908,26 @@ static int parse_args(int argc, char *argv[], struct arguments *args) return -EINVAL; } - return 0; } +static void shut_down(int code) __attribute__((noreturn)); +static void shut_down(int code) +{ + in_shutdown = 1; + + cyr_expire_cleanup(&ctx); + + exit(code); +} + + int main(int argc, char *argv[]) { int r = 0; - struct cyr_expire_ctx ctx = zero_ctx; progname = basename(argv[0]); + progtime = time(NULL); if (parse_args(argc, argv, &ctx.args) != 0) exit(EXIT_FAILURE); @@ -880,43 +939,36 @@ int main(int argc, char *argv[]) ctx.args.do_cid_expire = config_getswitch(IMAPOPT_CONVERSATIONS); /* Set namespace -- force standard (internal) */ - if ((r = mboxname_init_namespace(&expire_namespace, 1)) != 0) { + if ((r = mboxname_init_namespace(&expire_namespace, NAMESPACE_OPTION_ADMIN))) { syslog(LOG_ERR, "%s", error_message(r)); fatal(error_message(r), EX_CONFIG); } mboxevent_setnamespace(&expire_namespace); + /* now that we have a namespace, convert mbox_prefix to internal ns */ + if (ctx.args.mbox_prefix) { + char *intname = mboxname_from_external(ctx.args.mbox_prefix, + &expire_namespace, NULL); + ctx.args.mbox_prefix = ctx.args.freeme = intname; + } + if (duplicate_init(NULL) != 0) { fprintf(stderr, "cyr_expire: unable to init duplicate delivery database\n"); exit(1); } - r = do_archive(&ctx); + do_archive(&ctx); - if (sigquit) - goto finish; + do_expunge(&ctx); - r = do_expunge(&ctx); + do_cid_expire(&ctx); - if (sigquit) - goto finish; - - r = do_cid_expire(&ctx); - - if (sigquit) - goto finish; - - r = do_delete(&ctx); - - if (sigquit) - goto finish; + do_delete(&ctx); /* purge deliver.db entries of expired messages */ - r = do_duplicate_prune(&ctx); + do_duplicate_prune(&ctx); - finish: - cyr_expire_cleanup(&ctx); - exit(r); + shut_down(0); } diff --git a/imap/cyr_info.c b/imap/cyr_info.c index 60cc716ced..9597a425c8 100644 --- a/imap/cyr_info.c +++ b/imap/cyr_info.c @@ -45,7 +45,9 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include +#include #include #include #include @@ -69,18 +71,24 @@ struct service_item { static void usage(void) { - fprintf(stderr, "cyr_info [-C ] [-M ] [-n servicename] command\n"); + fprintf(stderr, "cyr_info [-C ] [-M ] [-n servicename] [-s oldversion] command\n"); fprintf(stderr, "\n"); - fprintf(stderr, "If you give a service name, it will show config as if you were\n"); - fprintf(stderr, "running that service, i.e. imap\n"); + fprintf(stderr, "If you give a service name (-n), it will show config as if you\n"); + fprintf(stderr, "were running that service, i.e. imap\n"); + fprintf(stderr, "\n"); + fprintf(stderr, "If you give an old version (-s), it will highlight config\n"); + fprintf(stderr, "options that are new or whose behaviour has changed since that\n"); + fprintf(stderr, "version.\n"); fprintf(stderr, "\n"); fprintf(stderr, "Where command is one of:\n"); fprintf(stderr, "\n"); - fprintf(stderr, " * conf-all - listing of all config values\n"); fprintf(stderr, " * conf - listing of non-default config values\n"); + fprintf(stderr, " * conf-all - listing of all config values\n"); fprintf(stderr, " * conf-default - listing of all default config values\n"); fprintf(stderr, " * conf-lint - unknown config keys\n"); fprintf(stderr, " * proc - listing of all open processes\n"); + fprintf(stderr, " * version - Cyrus version\n"); + fprintf(stderr, "\n"); cyrus_done(); exit(-1); } @@ -110,7 +118,15 @@ static void print_overflow(const char *key, const char *val, printf("%s: %s\n", key, val); } -static void do_conf(int only_changed) +static void highlight(uint32_t version) +{ + printf("(%u.%u.%u) ", + (version >> 24) & 0xff, + (version >> 16) & 0xff, + (version >> 8) & 0xff); +} + +static void do_conf(int only_changed, int want_since, uint32_t since) { int i; unsigned j; @@ -123,6 +139,8 @@ static void do_conf(int only_changed) if (only_changed) { if (imapopts[i].def.x == imapopts[i].val.x) break; } + if (want_since && since < imapopts[i].last_modified) + highlight(imapopts[i].last_modified); printf("%s:", imapopts[i].optname); for (j = 0; imapopts[i].enum_options[j].name; j++) { if (imapopts[i].val.x & (1<next) { + for (svc = cbrock->known_services; svc; svc = svc->next) { if (!strncmp(key, svc->prefix, svc->prefixlen)) { /* check if it's a known key */ if (known_regularkey(key+svc->prefixlen)) return; @@ -310,6 +359,27 @@ static void lint_callback(const char *key, const char *val, void *rock) } } + for (i = 0; i < strarray_size(cbrock->known_channels); i++) { + const char *channel = strarray_nth(cbrock->known_channels, i); + size_t channel_len = strlen(channel); + if (!strcmp(channel, "\"\"")) { + /* ignore default channel, it cannot be a prefix */ + continue; + } + else if (!strncmp(key, channel, channel_len)) { + /* channel prefix must be separated by an underscore */ + if (strlen(key) <= channel_len + 1) break; + if (key[channel_len] != '_') break; + + /* channel prefix only applies to sync_* options */ + if (strncmp(key + channel_len + 1, "sync_", strlen("sync_"))) break; + + /* check if it's a known key */ + if (known_regularkey(key + channel_len + 1)) return; + if (known_overflowkey(key + channel_len + 1)) return; + } + } + printf("%s: %s\n", key, val); } @@ -327,33 +397,91 @@ static void add_service(const char *name, static void do_lint(void) { - struct service_item *ks = NULL; + struct lint_callback_rock rock = {0}; /* pull the config from cyrus.conf to get service names */ - masterconf_getsection("SERVICES", &add_service, &ks); + masterconf_getsection("SERVICES", &add_service, &rock.known_services); + + /* read channels from sync_log_channels config */ + rock.known_channels = strarray_split(config_getstring(IMAPOPT_SYNC_LOG_CHANNELS), + " ", 0); /* check all overflow strings */ - config_foreachoverflowstring(lint_callback, ks); + config_foreachoverflowstring(lint_callback, &rock); /* XXX - check directories and permissions? */ /* clean up */ + struct service_item *ks = rock.known_services; while (ks) { struct service_item *next = ks->next; free(ks->prefix); free(ks); ks = next; } + strarray_free(rock.known_channels); +} + +static uint32_t parse_since_version(const char *str) +{ + unsigned parts[3] = {0}, i; + const char *p; + int saw_digit = 0; + size_t pnlen = strlen(PACKAGE_NAME); + + /* politely strip 'cyrus-imapd[- ]' from start of version string */ + if (!strncmp(str, PACKAGE_NAME, pnlen) + && (str[pnlen] == '-' || str[pnlen] == ' ')) + str += pnlen + 1; + + for (p = str, i = 0; *p; p++) { + if (cyrus_isdigit(*p)) { + saw_digit++; + parts[i] *= 10; + parts[i] += *p - '0'; + if (parts[i] > 255) usage(); + } + else if (*p == '.') { + if (!saw_digit) usage(); + saw_digit = 0; + if (++i > 2) break; + } + else if (*p == '-') { + break; + } + else { + usage(); + } + } + + return (parts[0] & 0xff) * 0x01000000 + + (parts[1] & 0xff) * 0x00010000 + + (parts[2] & 0xff) * 0x00000100 + + 0; } int main(int argc, char *argv[]) { - extern char *optarg; int opt; - char *alt_config = NULL; - char *srvname = "cyr_info"; - - while ((opt = getopt(argc, argv, "C:M:n:")) != EOF) { + const char *alt_config = NULL; + const char *srvname = "cyr_info"; + uint32_t since = 0; + int want_since = 0; + + /* keep this in alphabetical order */ + static const char short_options[] = "C:M:n:s:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + /* n.b. no long option for -M */ + { "service", required_argument, NULL, 'n' }, + { "since", required_argument, NULL, 's' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; @@ -367,25 +495,36 @@ int main(int argc, char *argv[]) srvname = optarg; break; + case 's': + want_since = 1; + since = parse_since_version(optarg); + break; + default: usage(); break; } } - cyrus_init(alt_config, srvname, 0, 0); - if (optind >= argc) usage(); + /* we don't need to read config to handle this one */ + if (!strcmp(argv[optind], "version")) { + printf("%s %s\n", PACKAGE_NAME, CYRUS_VERSION); + return 0; + } + + cyrus_init(alt_config, srvname, 0, 0); + if (!strcmp(argv[optind], "proc")) do_proc(); else if (!strcmp(argv[optind], "conf-all")) - do_conf(0); + do_conf(0, want_since, since); else if (!strcmp(argv[optind], "conf")) - do_conf(1); + do_conf(1, want_since, since); else if (!strcmp(argv[optind], "conf-default")) - do_defconf(); + do_defconf(want_since, since); else if (!strcmp(argv[optind], "conf-lint")) do_lint(); else diff --git a/imap/cyr_ls.c b/imap/cyr_ls.c new file mode 100644 index 0000000000..fd348f64f3 --- /dev/null +++ b/imap/cyr_ls.c @@ -0,0 +1,450 @@ +/* cyr_ls.c -- list the contents of a mailbox + * + * Copyright (c) 1994-2019 Carnegie Mellon University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#ifdef HAVE_UNISTD_H +#include +#endif +#if HAVE_DIRENT_H +# include +# define NAMLEN(dirent) strlen((dirent)->d_name) +#else +# define dirent direct +# define NAMLEN(dirent) (dirent)->d_namlen +# if HAVE_SYS_NDIR_H +# include +# endif +# if HAVE_SYS_DIR_H +# include +# endif +# if HAVE_NDIR_H +# include +# endif +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bsearch.h" +#include "util.h" +#include "global.h" +#include "mailbox.h" +#include "xmalloc.h" +#include "mboxlist.h" +#include "user.h" + +/* generated headers are not necessarily in current directory */ +#include "imap/imap_err.h" + +/* current namespace */ +static struct namespace cyr_ls_namespace; + +static int usage(const char *error) +{ + fprintf(stderr,"usage: cyr_ls [-C ] [-p] [-m] [-i] [-l] [-R] [-1] [mailbox name]\n"); + fprintf(stderr, "\n"); + fprintf(stderr,"\t-p\targument is a UNIX path, not mailbox\n"); + fprintf(stderr,"\t-7\tmailbox argument is in modified UTF7 rather than UTF8\n"); + fprintf(stderr,"\t-m\tlist the contents of the metadata directory (if different from the data directory)\n"); + fprintf(stderr,"\t-i\tprint ID of each mailbox\n"); + fprintf(stderr,"\t-l\tlong listing format\n"); + fprintf(stderr,"\t-R\tlist submailboxes recursively\n"); + fprintf(stderr,"\t-1\tlist one file per line\n"); + if (error) { + fprintf(stderr,"\n"); + fprintf(stderr,"ERROR: %s", error); + } + exit(-1); +} + +#define SECONDS_PER_YEAR 31536000 /* 365 * 24 * 60 * 60 */ + +#define ANSI_COLOR_RESET "\x1b[0m" +#define ANSI_COLOR_RED "\x1b[31m" +#define ANSI_COLOR_GREEN "\x1b[32m" +#define ANSI_COLOR_YELLOW "\x1b[33m" +#define ANSI_COLOR_BLUE "\x1b[34m" +#define ANSI_COLOR_MAGENTA "\x1b[35m" +#define ANSI_COLOR_CYAN "\x1b[36m" + +#define ANSI_COLOR_GRAY "\x1b[90m" +#define ANSI_COLOR_BR_BLUE "\x1b[94m" + +#define SPECIALS " !\"#$&'()*,;<>?[\\]^`{|}~" + +static int print_name(const char *name, int utf8) +{ + char *utf8name = NULL; + + if (utf8) { + charset_t imaputf7 = charset_lookupname("imap-mailbox-name"); + utf8name = charset_to_utf8cstr(name, strlen(name), imaputf7, ENCODING_NONE); + name = utf8name; + } + + size_t n = strcspn(name, SPECIALS); + + if (n == strlen(name)) { + /* No specials */ + n = printf(" %s", name); + } + else if (strchr(name + n, '\'')) { + if (strchr(name + n, '"')) { + /* Need to escape single quote */ + putchar('\''); + + for (n = 0; *name; name++, n++) { + if (*name == '\'') printf("\\'"); + else putchar(*name); + } + + putchar('\''); + + n += 2; + } + else { + /* Use double quotes */ + n = printf("\"%s\"", name); + } + } + else { + /* Use single quotes */ + n = printf("'%s'", name); + } + + free(utf8name); + + return n; +} + +static void long_list(struct stat *statp) +{ + struct group *grp; + struct passwd *pwd; + time_t now = time(0); + const char *datefmt = "%b %d %k:%M"; + char datestr[13]; + + pwd = getpwuid(statp->st_uid); + grp = getgrgid(statp->st_gid); + + if (now - statp->st_ctime > SECONDS_PER_YEAR) datefmt = "%b %d %Y"; + + strftime(datestr, 13, datefmt, localtime(&(statp->st_ctime))); + + /* XXX statp->st_size should use OFF_T_FMT not PRIi64, but our FMT + * XXX macros don't allow setting flags + */ + printf("%c%c%c%c%c%c%c%c%c%c %" PRIuMAX " %-8s %-8s % 10" PRIi64 " %s ", + S_ISDIR(statp->st_mode) ? 'd' : '-', + (statp->st_mode & S_IRUSR) ? 'r' : '-', + (statp->st_mode & S_IWUSR) ? 'w' : '-', + (statp->st_mode & S_IXUSR) ? 'x' : '-', + (statp->st_mode & S_IRGRP) ? 'r' : '-', + (statp->st_mode & S_IWGRP) ? 'w' : '-', + (statp->st_mode & S_IXGRP) ? 'x' : '-', + (statp->st_mode & S_IROTH) ? 'r' : '-', + (statp->st_mode & S_IWOTH) ? 'w' : '-', + (statp->st_mode & S_IXOTH) ? 'x' : '-', + (uintmax_t) statp->st_nlink, // int size differs by platform + pwd->pw_name, grp->gr_name, + (int64_t) statp->st_size, datestr); +} + +struct list_opts { + unsigned utf8 : 1; + unsigned recurse : 1; + unsigned ids : 1; + unsigned longlist : 1; + unsigned meta : 1; + unsigned colorize : 1; + unsigned columns; + unsigned column_size; +}; + +struct list_rock { + struct list_opts *opts; + int count; + int magic_inbox; + struct buf buf; + strarray_t *children; +}; + +static int list_cb(struct findall_data *data, void *rock) +{ + struct list_rock *lrock = (struct list_rock *) rock; + int r = 0; + + /* don't want partial matches */ + if (!data || !data->is_exactmatch) return 0; + + const char *child_name = strarray_nth(mbname_boxes(data->mbname), -1); + const char *color = ""; + const char *path; + + if (lrock->opts->meta) { + path = mbentry_metapath(data->mbentry, 0, 0); + } + else { + path = mbentry_datapath(data->mbentry, 0); + } + + printf("%s", !(lrock->count++ % lrock->opts->columns) ? "\n" : " "); + + if (lrock->opts->ids) printf("%-40s ", data->mbentry->uniqueid); + + if (lrock->opts->longlist || lrock->opts->colorize) { + struct stat sbuf; + + memset(&sbuf, 0, sizeof(struct stat)); + r = stat(path, &sbuf); + + if (lrock->opts->longlist) long_list(&sbuf); + + if (lrock->opts->colorize) { + if (r != 0) + color = ANSI_COLOR_RED; + else if (mbtype_isa(data->mbentry->mbtype) != MBTYPE_EMAIL) + color = ANSI_COLOR_MAGENTA; + else + color = ANSI_COLOR_BR_BLUE; + } + } + + printf("%s", color); + if (lrock->magic_inbox) { + buf_setcstr(&lrock->buf, "INBOX/"); + buf_appendcstr(&lrock->buf, child_name); + child_name = buf_cstring(&lrock->buf); + } + r = print_name(child_name, lrock->opts->utf8); + if (*color) printf("%s", ANSI_COLOR_RESET); + + if (lrock->opts->column_size) { + /* fill column */ + int fill = lrock->opts->column_size - r; + + printf("%-*s", fill > 0 ? fill : 0, ""); + } + + if (lrock->children) strarray_append(lrock->children, data->extname); + + return 0; +} + +static void do_list(mbname_t *mbname, struct list_opts *opts) +{ + mbentry_t *mbentry = NULL; + struct list_rock lrock = { opts, 0, 0, BUF_INITIALIZER, NULL }; + strarray_t names = STRARRAY_INITIALIZER; + int r, i; + + r = mboxlist_lookup_allow_all(mbname_intname(mbname), &mbentry, NULL); + if (!r) { + printf("\n%s:\n", mbname_extname(mbname, &cyr_ls_namespace, "cyrus")); + + if (mbentry->mbtype & MBTYPE_RESERVE) r = IMAP_MAILBOX_NONEXISTENT; + else if (mbentry->mbtype & MBTYPE_DELETED) r = IMAP_MAILBOX_NONEXISTENT; + else if (mbentry->mbtype & MBTYPE_REMOTE) { + printf("Non-local mailbox: %s!%s\n", + mbentry->server, mbentry->partition); + r = IMAP_MAILBOX_NOTSUPPORTED; + } + } + else { + fprintf(stderr, "Invalid mailbox name: '%s'\n", + mbname_extname(mbname, &cyr_ls_namespace, "cyrus")); + } + + mboxlist_entry_free(&mbentry); + + if (!r) { + /* List children */ + int isinbox = mboxname_isusermailbox(mbname_intname(mbname), 1); + + if (opts->recurse) lrock.children = &names; + + mbname_push_boxes(mbname, "%"); + mboxlist_findall(&cyr_ls_namespace, + mbname_extname(mbname, &cyr_ls_namespace, "cyrus"), + 1, 0, 0, &list_cb, &lrock); + + if (isinbox) { + free(mbname_pop_boxes(mbname)); + mbname_push_boxes(mbname, "INBOX"); + mbname_push_boxes(mbname, "%"); + lrock.magic_inbox = 1; + mboxlist_findall(&cyr_ls_namespace, + mbname_extname(mbname, &cyr_ls_namespace, "cyrus"), + 1, 0, 0, &list_cb, &lrock); + } + printf("\n"); + + if (opts->recurse) { + for (i = 0; i < strarray_size(lrock.children); i++) { + mbname_t *mbname = + mbname_from_extname(strarray_nth(lrock.children, i), + &cyr_ls_namespace, NULL); + do_list(mbname, opts); + mbname_free(&mbname); + } + strarray_fini(&names); + } + } + + buf_free(&lrock.buf); +} + +int main(int argc, char **argv) +{ + int r; + int opt; /* getopt() returns an int */ + char *alt_config = NULL; + int is_path = 0; + + /* keep this in alphabetical order */ + static const char short_options[] = "17C:Rilmp"; + + static const struct option long_options[] = { + { "one-per-line", no_argument, NULL, '1' }, + { "no-utf8", no_argument, NULL, '7' }, /* XXX undocumented */ + /* n.b. no long option for -C */ + { "recursive", no_argument, NULL, 'R' }, + { "long", no_argument, NULL, 'l' }, + { "metadata", no_argument, NULL, 'm' }, + { "path", no_argument, NULL, 'p' }, /* XXX undocumented */ + { 0, 0, 0, 0 }, + }; + + // capture options + struct list_opts opts = + { 1 /* default to UTF8 */, 0, 0, 0, 0, + isatty(STDOUT_FILENO), 4 /* default to 4 columns */, 0 }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { + switch(opt) { + case 'C': /* alt config file */ + alt_config = optarg; + break; + + case '7': + opts.utf8 = 0; + break; + + case 'R': + opts.recurse = 1; + break; + + case 'i': + opts.ids = 1; + opts.columns = 1; + break; + + case 'l': + opts.longlist = 1; + opts.columns = 1; + break; + + case 'm': + opts.meta = 1; + break; + + case '1': + opts.columns = 1; + break; + + case 'p': + is_path = 1; + break; + + default: + usage(NULL); + } + } + + if (opts.columns > 1) opts.column_size = 76 / opts.columns; + + cyrus_init(alt_config, "cyr_ls", 0, 0); + + + r = mboxname_init_namespace(&cyr_ls_namespace, + NAMESPACE_OPTION_ADMIN | NAMESPACE_OPTION_UTF8); + if (r) { + fatal(error_message(r), -1); + } + + /* Translate mailboxname */ + mbname_t *mbname = NULL; + r = IMAP_MAILBOX_NONEXISTENT; + if (!is_path && (optind != argc)) { + /* Is this an actual mailbox name */ + mbname = mbname_from_extname(argv[optind], &cyr_ls_namespace, "cyrus"); + + r = mboxlist_lookup_allow_all(mbname_intname(mbname), NULL, NULL); + } + if (r == IMAP_MAILBOX_NONEXISTENT) { + /* Are we in a mailbox directory? */ + const char *path = (optind == argc) ? "." : argv[optind]; + + mbname = mbname_from_path(path); + } + + do_list(mbname, &opts); + + mbname_free(&mbname); + + cyrus_done(); + + exit(0); +} diff --git a/imap/cyr_pwd.c b/imap/cyr_pwd.c new file mode 100644 index 0000000000..e214f689a5 --- /dev/null +++ b/imap/cyr_pwd.c @@ -0,0 +1,127 @@ +/* cyr_pwd.c -- current working directory within a spool dir + * + * Copyright (c) 1994-2019 Carnegie Mellon University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#ifdef HAVE_UNISTD_H +#include +#endif + +#include +#include +#include +#include + +#include "global.h" +#include "mboxname.h" + +/* generated headers are not necessarily in current directory */ +#include "imap/imap_err.h" + +/* current namespace */ +static struct namespace cyr_pwd_namespace; + +static int usage(const char *error) +{ + fprintf(stderr, "usage: cyr_pwd [-C ]\n"); + fprintf(stderr, "\n"); + if (error) { + fprintf(stderr, "\n"); + fprintf(stderr, "ERROR: %s", error); + } + exit(-1); +} + +int main(int argc, char **argv) +{ + int r; + int opt; + char *alt_config = NULL; + + /* keep this in alphabetical order */ + static const char short_options[] = "C:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { + switch(opt) { + case 'C': /* alt config file */ + alt_config = optarg; + break; + + default: + usage(NULL); + } + } + + cyrus_init(alt_config, "cyr_pwd", 0, 0); + + r = mboxname_init_namespace(&cyr_pwd_namespace, NAMESPACE_OPTION_ADMIN); + if (r) { + fatal(error_message(r), -1); + } + + /* Translate mailboxname */ + mbname_t *mbname = NULL; + const char *extname = NULL; + + mbname = mbname_from_path("."); + + if (mbname) + extname = mbname_extname(mbname, &cyr_pwd_namespace, "cyrus"); + + if (extname) + printf("%s\n", extname); + else + fprintf(stderr, "ERROR: not in Cyrus UUID mailbox directory\n"); + + mbname_free(&mbname); + + cyrus_done(); + + exit(0); +} diff --git a/imap/cyr_sequence.c b/imap/cyr_sequence.c deleted file mode 100644 index d9fc764592..0000000000 --- a/imap/cyr_sequence.c +++ /dev/null @@ -1,192 +0,0 @@ -/* cyr_sequence.c -- manipulate sequences - * - * Copyright (c) 1994-2008 Carnegie Mellon University. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions - * are met: - * - * 1. Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in - * the documentation and/or other materials provided with the - * distribution. - * - * 3. The name "Carnegie Mellon University" must not be used to - * endorse or promote products derived from this software without - * prior written permission. For permission or any legal - * details, please contact - * Carnegie Mellon University - * Center for Technology Transfer and Enterprise Creation - * 4615 Forbes Avenue - * Suite 302 - * Pittsburgh, PA 15213 - * (412) 268-7393, fax: (412) 268-7395 - * innovation@andrew.cmu.edu - * - * 4. Redistributions of any form whatsoever must retain the following - * acknowledgment: - * "This product includes software developed by Computing Services - * at Carnegie Mellon University (http://www.cmu.edu/computing/)." - * - * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO - * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY - * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE - * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN - * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING - * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -#include - -#include -#include -#include -#ifdef HAVE_UNISTD_H -#include -#endif -#include -#include -#include -#include - -#include -#include - -#include "sequence.h" -#include "global.h" -#include "util.h" - -/* generated headers are not necessarily in current directory */ -#include "imap/imap_err.h" - -static void usage(const char *name) -{ - fprintf(stderr, "Usage: %s [-C altconfig] [-m maxval] command sequence [args]\n", name); - fprintf(stderr, "\n"); - fprintf(stderr, " - parsed => dump a parsed view of the list structure\n"); - fprintf(stderr, " - compress => dump a compressed list\n"); - fprintf(stderr, " - ismember [num...] => is num in the list for each num\n"); - fprintf(stderr, " - members => all list members in order\n"); - fprintf(stderr, " - create [-s] [items] => generate a new list from the items\n"); - fprintf(stderr, " - prefix numbers with '~' for remove\n"); - exit(-1); -} - -int main(int argc, char *argv[]) -{ - const char *alt_config = NULL; - unsigned maxval = 0; - int flags = SEQ_MERGE; - struct seqset *seq = NULL; - int opt; - unsigned num; - char *res; - const char *origlist = NULL; - - while ((opt = getopt(argc, argv, "C:m:o:s")) != EOF) { - switch (opt) { - case 'C': /* alt config file */ - alt_config = optarg; - break; - case 'm': /* maxval */ - parseuint32(optarg, NULL, &maxval); - break; - case 'o': - origlist = optarg; - break; - case 's': - flags = SEQ_SPARSE; - } - } - - if ((argc - optind) < 1) usage(argv[0]); - - - cyrus_init(alt_config, "cyr_sequence", 0, 0); - - /* special case */ - if (!strcmp(argv[optind], "create")) { - int i; - seq = seqset_init(maxval, flags); - for (i = optind + 1; i < argc; i++) { - char *ptr = argv[i]; - int isadd = 1; - if (*ptr == '~') { - isadd = 0; - ptr++; - } - if (parseuint32(ptr, NULL, &num)) - printf("%s NAN\n", argv[i]); - else - seqset_add(seq, num, isadd); - } - if (origlist) { - unsigned oldmax = seq_lastnum(origlist, NULL); - if (oldmax > maxval) { - struct seqset *origseq = seqset_parse(origlist, NULL, oldmax); - unsigned val; - for (val = maxval + 1; val <= oldmax; val++) - seqset_add(seq, val, seqset_ismember(origseq, val)); - seqset_free(origseq); - } - } - res = seqset_cstring(seq); - printf("%s\n", res); - free(res); - } - else if (!strcmp(argv[optind], "parsed")) { - unsigned i; - seq = seqset_parse(argv[optind+1], NULL, maxval); - printf("Sections: " SIZE_T_FMT "\n", seq->len); - for (i = 0; i < seq->len; i++) { - if (seq->set[i].high == UINT_MAX) - printf(" [%u, *]\n", seq->set[i].low); - else - printf(" [%u, %u]\n", seq->set[i].low, seq->set[i].high); - } - } - else if (!strcmp(argv[optind], "compress")) { - seq = seqset_parse(argv[optind+1], NULL, maxval); - res = seqset_cstring(seq); - printf("%s\n", res); - free(res); - } - else if (!strcmp(argv[optind], "members")) { - seq = seqset_parse(argv[optind+1], NULL, maxval); - while ((num = seqset_getnext(seq))) { - printf("%u\n", num); - } - } - else if (!strcmp(argv[optind], "join")) { - struct seqset *seq2; - seq = seqset_parse(argv[optind+1], NULL, maxval); - seq2 = seqset_parse(argv[optind+2], NULL, maxval); - seqset_join(seq, seq2); - res = seqset_cstring(seq); - printf("%s\n", res); - free(res); - } - else if (!strcmp(argv[optind], "ismember")) { - int i; - seq = seqset_parse(argv[optind+1], NULL, maxval); - for (i = optind + 2; i < argc; i++) { - if (parseuint32(argv[i], NULL, &num)) - printf("%s NAN\n", argv[i]); - else - printf("%d %s\n", num, seqset_ismember(seq, num) ? "Yes" : "No"); - } - } - else { - printf("Unknown command %s", argv[optind]); - } - - seqset_free(seq); - - cyrus_done(); - - return 0; -} diff --git a/imap/cyr_synclog.c b/imap/cyr_synclog.c index c2fe1eec81..9e9ced91a3 100644 --- a/imap/cyr_synclog.c +++ b/imap/cyr_synclog.c @@ -44,6 +44,7 @@ #include +#include #include #include #include @@ -51,6 +52,7 @@ #include #endif +#include "assert.h" #include "global.h" #include "sync_log.h" #include "util.h" @@ -83,7 +85,28 @@ int main(int argc, char *argv[]) char cmd = '\0'; int opt; - while ((opt = getopt(argc, argv, "C:uUvmMacqnsb")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:MUabcmnqsuv"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "unmailbox", no_argument, NULL, 'M' }, + { "unuser", no_argument, NULL, 'U' }, + { "append", no_argument, NULL, 'a' }, + { "subscription", no_argument, NULL, 'b' }, + { "acl", no_argument, NULL, 'c' }, + { "mailbox", no_argument, NULL, 'm' }, + { "annotation", no_argument, NULL, 'n' }, + { "quota", no_argument, NULL, 'q' }, + { "seen", no_argument, NULL, 's' }, + { "user", no_argument, NULL, 'u' }, + { "sieve", no_argument, NULL, 'v' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; diff --git a/imap/cyr_userseen.c b/imap/cyr_userseen.c index 4c8e354bd4..94b55dfa28 100644 --- a/imap/cyr_userseen.c +++ b/imap/cyr_userseen.c @@ -45,6 +45,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -80,7 +81,7 @@ static int deluserseen(const mbentry_t *mbentry, void *rock __attribute__((unuse char *userid = mboxname_to_userid(mbentry->name); if (userid) { - printf("removing seen for %s on %s\n", userid, mailbox->name); + printf("removing seen for %s on %s\n", userid, mailbox_name(mailbox)); if (do_remove) seen_delete_mailbox(userid, mailbox); free(userid); } @@ -93,11 +94,21 @@ static int deluserseen(const mbentry_t *mbentry, void *rock __attribute__((unuse int main(int argc, char *argv[]) { - extern char *optarg; int opt; char *alt_config = NULL; - while ((opt = getopt(argc, argv, "C:d")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:d"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "delete", no_argument, NULL, 'd' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; diff --git a/imap/cyr_virusscan.c b/imap/cyr_virusscan.c index 6da2778335..b0157cd406 100644 --- a/imap/cyr_virusscan.c +++ b/imap/cyr_virusscan.c @@ -45,6 +45,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -53,13 +54,16 @@ #include /* cyrus includes */ +#include "assert.h" #include "global.h" #include "append.h" #include "index.h" #include "mailbox.h" +#include "map.h" #include "message.h" #include "xmalloc.h" #include "mboxlist.h" +#include "parseaddr.h" #include "prot.h" #include "util.h" #include "times.h" @@ -89,6 +93,7 @@ struct scan_rock { struct infected_mbox *i_mbox; struct searchargs *searchargs; struct index_state *idx_state; + struct namespace *namespace; uint32_t msgno; char userid[MAX_MAILBOX_NAME]; int user_infected; @@ -96,19 +101,13 @@ struct scan_rock { int mailboxes_scanned; }; -/* globals for getopt routines */ -extern char *optarg; -extern int optind; -extern int opterr; -extern int optopt; - /* globals for callback functions */ int disinfect = 0; int email_notification = 0; struct infected_mbox *public = NULL; struct infected_mbox *user = NULL; -int verbose = 0; +static int verbose = 0; /* abstract definition of a virus scan engine */ struct scan_engine { @@ -132,6 +131,7 @@ struct clamav_state { void *clamav_init() { unsigned int sigs = 0; + int64_t starttime; int r; /* initialise ClamAV library */ @@ -152,12 +152,14 @@ void *clamav_init() /* load all available databases from default directory */ if (verbose) puts("Loading virus signatures..."); + starttime = now_ms(); if ((r = cl_load(cl_retdbdir(), st->av_engine, &sigs, CL_DB_STDOPT))) { syslog(LOG_ERR, "cl_load: %s", cl_strerror(r)); fatal(cl_strerror(r), EX_SOFTWARE); } - printf("Loaded %d virus signatures.\n", sigs); + printf("Loaded %d virus signatures (%.3f seconds).\n", + sigs, (now_ms() - starttime)/1000.0); /* build av_engine */ if ((r = cl_engine_compile(st->av_engine))) { @@ -192,7 +194,7 @@ int clamav_scanfile(void *state, const char *fname, int r; /* scan file */ -#if LIBCLAMAV_MAJORVER < 9 +#ifdef CL_SCAN_STDOPT r = cl_scanfile(fname, virname, NULL, st->av_engine, CL_SCAN_STDOPT); #else @@ -214,7 +216,7 @@ int clamav_scanfile(void *state, const char *fname, default: printf("cl_scanfile error: %s\n", cl_strerror(r)); - syslog(LOG_ERR, "cl_scanfile error: %s\n", cl_strerror(r)); + syslog(LOG_ERR, "cl_scanfile error: %s", cl_strerror(r)); break; } @@ -250,17 +252,48 @@ int scan_me(struct findall_data *, void *); unsigned virus_check(struct mailbox *mailbox, const struct index_record *record, void *rock); -void append_notifications(); - - -int main (int argc, char *argv[]) { - int option; /* getopt() returns an int */ +static int load_notification_template(struct buf *dst); +static int check_notification_template(const struct buf *template); +static void put_notification_headers(FILE *f, int counter, time_t t, + const mbname_t *mbname); +static void append_notifications(const struct buf *template); + +static const char *default_notification_template = + "The following message was deleted from mailbox '%MAILBOX%'\n" + "because it was infected with virus '%VIRUS%'\n" + "\n" + "\tMessage-ID: %MSG_ID%\n" + "\tDate: %MSG_DATE%\n" + "\tFrom: %MSG_FROM%\n" + "\tSubject: %MSG_SUBJECT%\n" + "\tIMAP UID: %MSG_UID%\n"; + +int main (int argc, char *argv[]) +{ + int opt; char *alt_config = NULL; char *search_str = NULL; struct scan_rock srock; + struct buf notification_template = BUF_INITIALIZER; + struct namespace scan_namespace; + int r; - while ((option = getopt(argc, argv, "C:s:rnv")) != EOF) { - switch (option) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:nrs:v"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "notify", no_argument, NULL, 'n' }, + { "remove-infected", no_argument, NULL, 'r' }, + { "search", required_argument, NULL, 's' }, + { "verbose", no_argument, NULL, 'v' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { + switch (opt) { case 'C': /* alt config file */ alt_config = optarg; break; @@ -290,21 +323,30 @@ int main (int argc, char *argv[]) { memset(&srock, 0, sizeof(struct scan_rock)); + if (email_notification) { + /* load notification template early, so if it fails we haven't wasted + * time initialising the av engine */ + if (load_notification_template(¬ification_template)) { + syslog(LOG_ERR, "Couldn't load notification template"); + fatal("Couldn't load notification template", EX_CONFIG); + } + } + + /* Set namespace -- force standard (internal) */ + if ((r = mboxname_init_namespace(&scan_namespace, NAMESPACE_OPTION_ADMIN))) { + syslog(LOG_ERR, "%s", error_message(r)); + fatal(error_message(r), EX_CONFIG); + } + srock.namespace = &scan_namespace; + if (search_str) { - int r, c; - struct namespace scan_namespace; + int c; struct protstream *scan_in = NULL; struct protstream *scan_out = NULL; scan_in = prot_readmap(search_str, strlen(search_str)+1); /* inc NUL */ scan_out = prot_new(2, 1); - /* Set namespace -- force standard (internal) */ - if ((r = mboxname_init_namespace(&scan_namespace, 1)) != 0) { - syslog(LOG_ERR, "%s", error_message(r)); - fatal(error_message(r), EX_CONFIG); - } - srock.searchargs = new_searchargs("*", GETSEARCH_CHARSET_KEYWORD, &scan_namespace, NULL, NULL, 1); c = get_search_program(scan_in, scan_out, srock.searchargs); @@ -334,7 +376,9 @@ int main (int argc, char *argv[]) { strarray_free(array); } - if (email_notification) append_notifications(); + if (email_notification) append_notifications(¬ification_template); + + buf_free(¬ification_template); printf("\n%d mailboxes scanned, %d infected messages %s\n", srock.mailboxes_scanned, @@ -374,7 +418,9 @@ static void print_header(void) int scan_me(struct findall_data *data, void *rock) { - if (!data || !data->mbname) return 0; + if (!data) return 0; + if (!data->is_exactmatch) return 0; + struct mailbox *mailbox = NULL; int r; struct infected_mbox *i_mbox = NULL; @@ -417,14 +463,14 @@ int scan_me(struct findall_data *data, void *rock) if (owner) { if (user && !strcmp(owner, user->owner)) { i_mbox = user; + free(owner); } else { /* new owner (Inbox) */ struct infected_mbox *new = xzmalloc(sizeof(struct infected_mbox)); - new->owner = xstrdup(owner); + new->owner = owner; new->next = user; i_mbox = user = new; } - free(owner); } #if 0 /* XXX what to do with public mailboxes (bboards)? */ else { @@ -441,7 +487,7 @@ int scan_me(struct findall_data *data, void *rock) srock->i_mbox = i_mbox; if (verbose) printf("Scanning %s...\n", name); - mailbox_expunge(mailbox, virus_check, srock, NULL, EVENT_MESSAGE_EXPUNGE); + mailbox_expunge(mailbox, NULL, virus_check, srock, NULL, EVENT_MESSAGE_EXPUNGE); if (srock->idx_state) index_close(&srock->idx_state); /* closes mailbox */ else mailbox_close(&mailbox); @@ -454,16 +500,27 @@ void create_digest(struct infected_mbox *i_mbox, struct mailbox *mailbox, const struct index_record *record, const char *virname) { struct infected_msg *i_msg = xzmalloc(sizeof(struct infected_msg)); + char *tmp; + struct address addr; + struct buf from = BUF_INITIALIZER; - i_msg->mboxname = xstrdup(mailbox->name); + i_msg->mboxname = xstrdup(mailbox_name(mailbox)); i_msg->virname = xstrdup(virname); i_msg->uid = record->uid; i_msg->msgid = mailbox_cache_get_env(mailbox, record, ENV_MSGID); i_msg->date = mailbox_cache_get_env(mailbox, record, ENV_DATE); - i_msg->from = mailbox_cache_get_env(mailbox, record, ENV_FROM); i_msg->subj = mailbox_cache_get_env(mailbox, record, ENV_SUBJECT); + /* decode the FROM header */ + tmp = mailbox_cache_get_env(mailbox, record, ENV_FROM); + message_parse_env_address(tmp, &addr); + if (addr.name) + buf_printf(&from, "\"%s\" ", addr.name); + buf_printf(&from, "<%s@%s>", addr.mailbox, addr.domain); + free(tmp); + i_msg->from = buf_release(&from); + i_msg->next = i_mbox->msgs; i_mbox->msgs = i_msg; } @@ -496,10 +553,16 @@ unsigned virus_check(struct mailbox *mailbox, /* print header if this is the first infection seen for this user */ if (verbose || !srock->user_infected) print_header(); - printf("%-40s\t%10u\t%6s\t%s\n", mailbox->name, record->uid, + char *extname = mboxname_to_external(mailbox_name(mailbox), + srock->namespace, + NULL); + + printf("%-40s\t%10u\t%6s\t%s\n", extname, record->uid, (record->system_flags & FLAG_SEEN) ? "READ" : "UNREAD", virname); + free(extname); + srock->user_infected ++; srock->total_infected ++; @@ -514,51 +577,201 @@ unsigned virus_check(struct mailbox *mailbox, return r; } -void append_notifications() +static int load_notification_template(struct buf *dst) +{ + const char *template_fname = + config_getstring(IMAPOPT_VIRUSSCAN_NOTIFICATION_TEMPLATE); + int r; + + if (!template_fname) { + buf_setcstr(dst, default_notification_template); + return 0; + } + + int fd = open(template_fname, O_RDONLY); + if (fd == -1) { + syslog(LOG_WARNING, "unable to read notification template file %s (%m), " + "using default instead", + template_fname); + buf_setcstr(dst, default_notification_template); + return 0; + } + + buf_refresh_mmap(dst, 1, fd, template_fname, MAP_UNKNOWN_LEN, NULL); + close(fd); + + /* using a custom template, validate it! */ + r = check_notification_template(dst); + if (r) buf_reset(dst); + + return r; +} + +static int check_notification_template(const struct buf *template) +{ + struct buf chunk = BUF_INITIALIZER; + int fd; + FILE *f; + mbname_t *mbname; + struct protstream *pout; + size_t msgsize; + size_t i; + int r; + + const char *subs[] = { + "%MAILBOX%", + "%VIRUS%", + "%MSG_ID%", + "%MSG_DATE%", + "%MSG_FROM%", + "%MSG_SUBJECT%", + "%MSG_UID%", + }; + + /* warn about missing fields, but they're not catastrophic */ + for (i = 0; i < sizeof(subs) / sizeof(subs[0]); i++) { + if (!memmem(buf_base(template), buf_len(template), + subs[i], strlen(subs[i]))) + syslog(LOG_WARNING, "notification template is missing %s substitution", + subs[i]); + } + + /* stub a message, and do minimal checking for RFC 822 compliance */ + fd = create_tempfile(config_getstring(IMAPOPT_TEMP_PATH)); + if (fd < 0) { + r = IMAP_IOERROR; + goto done; + } + f = fdopen(fd, "w+"); + if (!f) { + r = IMAP_IOERROR; + goto done; + } + mbname = mbname_from_intname("user.nobody"); + put_notification_headers(f, 0, time(NULL), mbname); + mbname_free(&mbname); + + buf_copy(&chunk, template); + buf_tocrlf(&chunk); + /* not bothering to perform substitutions */ + char *encoded_chunk = charset_qpencode_mimebody(buf_base(&chunk), + buf_len(&chunk), + /* force_quote */ 0, NULL); + fputs(encoded_chunk, f); + fputs("\r\n", f); + free(encoded_chunk); + buf_free(&chunk); + + fflush(f); + msgsize = ftell(f); + + pout = prot_new(fd, 0); + prot_rewind(pout); + r = message_copy_strict(pout, NULL, msgsize, /* allow_null */ 0); + prot_free(pout); + + fclose(f); + +done: + return r; +} + +static void put_notification_headers(FILE *f, int counter, time_t t, + const mbname_t *mbname) +{ + pid_t p = getpid(); + char datestr[RFC5322_DATETIME_MAX+1]; + char *encoded_subject; + + time_to_rfc5322(t, datestr, sizeof(datestr)); + encoded_subject = charset_encode_mimeheader( + config_getstring(IMAPOPT_VIRUSSCAN_NOTIFICATION_SUBJECT), 0, 0); + + fprintf(f, "Return-Path: <>\r\n"); + fprintf(f, "Message-ID: \r\n", + (int) p, (int) t, counter, config_servername); + fprintf(f, "Date: %s\r\n", datestr); + fprintf(f, "From: Mail System Administrator <%s>\r\n", + config_getstring(IMAPOPT_POSTMASTER)); + fprintf(f, "To: <%s>\r\n", mbname_userid(mbname)); + fprintf(f, "Subject: %s\r\n", encoded_subject); + fprintf(f, "MIME-Version: 1.0\r\n"); + fprintf(f, "Content-Type: text/plain; charset=UTF-8\r\n"); + fprintf(f, "Content-Transfer-Encoding: quoted-printable\r\n"); + fputs("\r\n", f); + + free(encoded_subject); +} + +static void append_notifications(const struct buf *template) { struct infected_mbox *i_mbox; int outgoing_count = 0; - pid_t p = getpid();; - int fd = create_tempfile(config_getstring(IMAPOPT_TEMP_PATH)); + struct namespace notification_namespace; + + mboxname_init_namespace(¬ification_namespace, /*options*/0); while ((i_mbox = user)) { if (i_mbox->msgs) { - FILE *f = fdopen(fd, "w+"); + FILE *f = NULL; struct infected_msg *msg; - char buf[8192], datestr[RFC5322_DATETIME_MAX+1]; time_t t; struct protstream *pout; struct appendstate as; struct body *body = NULL; long msgsize; - mbname_t *mbname = mbname_from_userid(i_mbox->owner); + mbname_t *owner = mbname_from_userid(i_mbox->owner); + struct buf message = BUF_INITIALIZER; + int first; + int fd, r = 0; + + fd = create_tempfile(config_getstring(IMAPOPT_TEMP_PATH)); + if (fd < 0) { + r = IMAP_IOERROR; + goto user_done; + } + f = fdopen(fd, "w+"); + if (!f) { + r = IMAP_IOERROR; + goto user_done; + } - fprintf(f, "Return-Path: <>\r\n"); t = time(NULL); - snprintf(buf, sizeof(buf), "", - (int) p, (int) t, - outgoing_count++, config_servername); - fprintf(f, "Message-ID: %s\r\n", buf); - time_to_rfc5322(t, datestr, sizeof(datestr)); - fprintf(f, "Date: %s\r\n", datestr); - fprintf(f, "From: Mail System Administrator <%s>\r\n", - config_getstring(IMAPOPT_POSTMASTER)); - /* XXX Need to handle virtdomains */ - fprintf(f, "To: <%s>\r\n", mbname_userid(mbname)); - fprintf(f, "MIME-Version: 1.0\r\n"); - fprintf(f, "Subject: Automatically deleted mail\r\n"); + put_notification_headers(f, outgoing_count++, t, owner); + first = 1; while ((msg = i_mbox->msgs)) { - fprintf(f, "\r\n\r\nThe following message was deleted from mailbox " - "'Inbox%s'\r\n", msg->mboxname+4); /* skip "user" */ - fprintf(f, "because it was infected with virus '%s'\r\n\r\n", - msg->virname); - fprintf(f, "\tMessage-ID: %s\r\n", msg->msgid); - fprintf(f, "\tDate: %s\r\n", msg->date); - fprintf(f, "\tFrom: %s\r\n", msg->from); - fprintf(f, "\tSubject: %s\r\n", msg->subj); - fprintf(f, "\tIMAP UID: %lu\r\n", msg->uid); + struct buf chunk = BUF_INITIALIZER; + char uidbuf[16]; /* UINT32_MAX is 4294967295 */ + int n; + + /* stringify the uid */ + n = snprintf(uidbuf, sizeof(uidbuf), "%lu", msg->uid); + assert(n > 0 && (unsigned) n < sizeof(uidbuf)); + + buf_copy(&chunk, template); + buf_tocrlf(&chunk); + + mbname_t *mailbox = mbname_from_intname(msg->mboxname); + const char *extname = mbname_extname(mailbox, + ¬ification_namespace, + mbname_userid(owner)); + buf_replace_all(&chunk, "%MAILBOX%", extname); + buf_replace_all(&chunk, "%VIRUS%", msg->virname); + buf_replace_all(&chunk, "%MSG_ID%", msg->msgid); + buf_replace_all(&chunk, "%MSG_DATE%", msg->date); + buf_replace_all(&chunk, "%MSG_FROM%", msg->from); + buf_replace_all(&chunk, "%MSG_SUBJECT%", msg->subj); + buf_replace_all(&chunk, "%MSG_UID%", uidbuf); + mbname_free(&mailbox); + + if (!first) + buf_appendcstr(&message, "\r\n"); + else + first = 0; + buf_append(&message, &chunk); + buf_free(&chunk); i_mbox->msgs = msg->next; @@ -572,25 +785,43 @@ void append_notifications() free(msg); } + char *encoded_message = charset_qpencode_mimebody( + buf_base(&message), buf_len(&message), + /* force_quote */ 0, NULL); + fputs(encoded_message, f); fflush(f); msgsize = ftell(f); + free(encoded_message); + buf_free(&message); + /* send MessageAppend event notification */ - append_setup(&as, mbname_intname(mbname), NULL, NULL, 0, NULL, NULL, 0, - EVENT_MESSAGE_APPEND); - mbname_free(&mbname); - - pout = prot_new(fd, 0); - prot_rewind(pout); - append_fromstream(&as, &body, pout, msgsize, t, NULL); - append_commit(&as); - - if (body) { - message_free_body(body); - free(body); + r = append_setup(&as, mbname_intname(owner), NULL, NULL, 0, NULL, NULL, 0, + EVENT_MESSAGE_APPEND); + + if (!r) { + pout = prot_new(fd, 0); + prot_rewind(pout); + r = append_fromstream(&as, &body, pout, msgsize, t, NULL); + /* n.b. append_fromstream calls append_abort itself if it fails */ + if (!r) r = append_commit(&as); + + if (body) { + message_free_body(body); + free(body); + } + prot_free(pout); } - prot_free(pout); + fclose(f); +user_done: + if (r) { + syslog(LOG_ERR, "couldn't send notification to user %s: %s", + mbname_userid(owner), + error_message(r)); + } + + mbname_free(&owner); } user = i_mbox->next; diff --git a/imap/cyr_withlock_run.c b/imap/cyr_withlock_run.c new file mode 100644 index 0000000000..b9a908bb97 --- /dev/null +++ b/imap/cyr_withlock_run.c @@ -0,0 +1,144 @@ +/* cyr_withlock_run.c -- run a command with the global lock or a user lock held + * + * Copyright (c) 1994-2023 Carnegie Mellon University. All rights reserved. + * + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#ifdef HAVE_UNISTD_H +#include +#endif + +#include +#include +#include +#include + +#include "global.h" +#include "mboxname.h" +#include "command.h" +#include "strarray.h" +#include "user.h" + +/* generated headers are not necessarily in current directory */ +#include "imap/imap_err.h" + +/* current namespace */ +static struct namespace cyr_runlock_namespace; + +static int usage(const char *error) +{ + fprintf(stderr, "usage: cyr_runlock [-C ] cmd args\n"); + fprintf(stderr, "\n"); + if (error) { + fprintf(stderr, "\n"); + fprintf(stderr, "ERROR: %s", error); + } + exit(-1); +} + +int runcmd(void *rock) +{ + return run_command_strarray((const strarray_t *)rock); +} + +int main(int argc, char **argv) +{ + int r; + int opt; + char *alt_config = NULL; + char *userid = NULL; + + /* keep this in alphabetical order */ + static const char short_options[] = "C:u:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "user", required_argument, NULL, 'u' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { + switch(opt) { + case 'C': /* alt config file */ + alt_config = optarg; + break; + + case 'u': + userid = optarg; + break; + + default: + usage(NULL); + } + } + + cyrus_init(alt_config, "cyr_runlock", 0, 0); + + r = mboxname_init_namespace(&cyr_runlock_namespace, NAMESPACE_OPTION_ADMIN); + if (r) { + fatal(error_message(r), -1); + } + + strarray_t args = STRARRAY_INITIALIZER; + int i; + for (i = optind; i < argc; i++) + strarray_append(&args, argv[i]); + if (userid) { + static char env_userlock[MAX_MAILBOX_NAME+30]; + snprintf(env_userlock, sizeof(env_userlock), "CYRUS_HAVELOCK_USER=%s", userid); + putenv(env_userlock); + r = user_run_with_lock(userid, runcmd, &args); + } + else { + static char env_havelock[100]; + snprintf(env_havelock, sizeof(env_havelock), "CYRUS_HAVELOCK_GLOBAL=1"); + putenv(env_havelock); + r = mboxname_run_with_lock(runcmd, &args); + } + strarray_fini(&args); + + cyrus_done(); + + exit(r ? EXIT_FAILURE : EXIT_SUCCESS); +} diff --git a/imap/cyrdump.c b/imap/cyrdump.c index 74b525512d..108ff49325 100644 --- a/imap/cyrdump.c +++ b/imap/cyrdump.c @@ -44,6 +44,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -79,15 +80,26 @@ struct incremental_record { int main(int argc, char *argv[]) { - int option; + int opt; int i; char *alt_config = NULL; struct incremental_record irec; progname = basename(argv[0]); - while ((option = getopt(argc, argv, "vC:")) != EOF) { - switch (option) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:v"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "verbose", no_argument, NULL, 'v' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { + switch (opt) { case 'v': verbose++; break; @@ -160,7 +172,6 @@ static int dump_me(struct findall_data *data, void *rock) int r; char boundary[128]; struct imapurl url; - char imapurl[MAX_MAILBOX_PATH+1]; struct incremental_record *irec = (struct incremental_record *) rock; struct searchargs searchargs; struct index_state *state; @@ -170,7 +181,8 @@ static int dump_me(struct findall_data *data, void *rock) unsigned msgno; /* don't want partial matches */ - if (!data || !data->mbname) return 0; + if (!data) return 0; + if (!data->is_exactmatch) return 0; const char *name = mbname_intname(data->mbname); @@ -191,15 +203,18 @@ static int dump_me(struct findall_data *data, void *rock) printf("IMAP-Dump-Version: 0\n"); printf("\n"); - printf("\n", state->mailbox->uniqueid); + printf("\n", mailbox_uniqueid(state->mailbox)); memset(&url, 0, sizeof(struct imapurl)); url.server = config_servername; url.mailbox = name; - imapurl_toURL(imapurl, &url); - printf(" %s\n", imapurl); + + struct buf urlbuf = BUF_INITIALIZER; + imapurl_toURL(&urlbuf, &url); + printf(" %s\n", buf_cstring(&urlbuf)); printf(" %d\n", irec->incruid); printf(" %u\n", state->mailbox->i.last_uid + 1); printf("\n"); + buf_free(&urlbuf); memset(&searchargs, 0, sizeof(struct searchargs)); searchargs.root = search_expr_new(NULL, SEOP_TRUE); diff --git a/imap/dav_db.c b/imap/dav_db.c index f3080be2d6..a569c4e224 100644 --- a/imap/dav_db.c +++ b/imap/dav_db.c @@ -59,8 +59,14 @@ #include "cyrusdb.h" #include "dav_db.h" #include "global.h" +#include "sieve_db.h" +#include "user.h" #include "util.h" #include "xmalloc.h" +#include "xunlink.h" + +/* generated headers are not necessarily in current directory */ +#include "imap/imap_err.h" #define CMD_CREATE_CAL \ "CREATE TABLE IF NOT EXISTS ical_objs (" \ @@ -83,9 +89,23 @@ " comp_flags INTEGER," \ " sched_tag TEXT," \ " alive INTEGER," \ + " UNIQUE( mailbox, imap_uid )," \ " UNIQUE( mailbox, resource ) );" \ "CREATE INDEX IF NOT EXISTS idx_ical_uid ON ical_objs ( ical_uid );" +#define CMD_CREATE_JSCALOBJS \ + "CREATE TABLE IF NOT EXISTS jscal_objs (" \ + " rowid INTEGER NOT NULL," \ + " ical_recurid TEXT NOT NULL DEFAULT ''," \ + " modseq INTEGER NOT NULL," \ + " createdmodseq INTEGER NOT NULL," \ + " dtstart TEXT NOT NULL," \ + " dtend TEXT NOT NULL," \ + " alive INTEGER NOT NULL," \ + " ical_guid TEXT NOT NULL," \ + " PRIMARY KEY (rowid, ical_recurid)" \ + " FOREIGN KEY (rowid) REFERENCES ical_objs (rowid) ON DELETE CASCADE );" + #define CMD_CREATE_CARD \ "CREATE TABLE IF NOT EXISTS vcard_objs (" \ " rowid INTEGER PRIMARY KEY," \ @@ -106,6 +126,7 @@ " name TEXT," \ " nickname TEXT," \ " alive INTEGER," \ + " UNIQUE( mailbox, imap_uid )," \ " UNIQUE( mailbox, resource ) );" \ "CREATE INDEX IF NOT EXISTS idx_vcard_fn ON vcard_objs ( fullname );" \ "CREATE INDEX IF NOT EXISTS idx_vcard_uid ON vcard_objs ( vcard_uid );" @@ -149,12 +170,69 @@ " res_uid TEXT," \ " ref_count INTEGER," \ " alive INTEGER," \ + " UNIQUE( mailbox, imap_uid )," \ " UNIQUE( mailbox, resource ) );" \ - "CREATE INDEX IF NOT EXISTS idx_res_uid ON dav_objs ( res_uid );" + +// dropped in version 12 +#define CMD_CREATE_CALCACHE \ + "CREATE TABLE IF NOT EXISTS ical_jmapcache (" \ + " rowid INTEGER NOT NULL," \ + " userid TEXT NOT NULL," \ + " jmapversion INTEGER NOT NULL," \ + " jmapdata TEXT NOT NULL," \ + " PRIMARY KEY (rowid, userid)" \ + " FOREIGN KEY (rowid) REFERENCES ical_objs (rowid) ON DELETE CASCADE );" + +#define CMD_CREATE_JSCALCACHE \ + "CREATE TABLE IF NOT EXISTS jscal_cache (" \ + " rowid INTEGER NOT NULL," \ + " ical_recurid TEXT NOT NULL," \ + " userid TEXT NOT NULL," \ + " version INTEGER NOT NULL," \ + " data TEXT NOT NULL," \ + " PRIMARY KEY (rowid, ical_recurid, userid)" \ + " FOREIGN KEY (rowid, ical_recurid) REFERENCES jscal_objs (rowid, ical_recurid) ON DELETE CASCADE );" + +// dropped in version 16 +#define CMD_CREATE_CARDCACHE \ + "CREATE TABLE IF NOT EXISTS vcard_jmapcache (" \ + " rowid INTEGER NOT NULL PRIMARY KEY," \ + " jmapversion INTEGER NOT NULL," \ + " jmapdata TEXT NOT NULL," \ + " FOREIGN KEY (rowid) REFERENCES vcard_objs (rowid) ON DELETE CASCADE );" + +#define CMD_CREATE_JSCARDCACHE \ + "CREATE TABLE IF NOT EXISTS jscard_cache (" \ + " rowid INTEGER NOT NULL," \ + " userid TEXT NOT NULL," \ + " jmapversion INTEGER NOT NULL," \ + " jmapdata TEXT NOT NULL," \ + " PRIMARY KEY (rowid, userid)" \ + " FOREIGN KEY (rowid) REFERENCES vcard_objs (rowid) ON DELETE CASCADE );" + +#define CMD_CREATE_SIEVE \ + "CREATE TABLE IF NOT EXISTS sieve_scripts (" \ + " rowid INTEGER PRIMARY KEY," \ + " creationdate INTEGER," \ + " lastupdated INTEGER," \ + " mailbox TEXT NOT NULL," \ + " imap_uid INTEGER," \ + " modseq INTEGER," \ + " createdmodseq INTEGER," \ + " id TEXT NOT NULL," \ + " name TEXT NOT NULL," \ + " contentid TEXT NOT NULL," \ + " isactive INTEGER," \ + " alive INTEGER," \ + " UNIQUE( mailbox, imap_uid )," \ + " UNIQUE( id ) );" \ + "CREATE INDEX IF NOT EXISTS idx_sieve_name ON sieve_scripts ( name );" #define CMD_CREATE CMD_CREATE_CAL CMD_CREATE_CARD CMD_CREATE_EM CMD_CREATE_GR \ - CMD_CREATE_OBJS + CMD_CREATE_OBJS CMD_CREATE_CALCACHE CMD_CREATE_CARDCACHE \ + CMD_CREATE_SIEVE CMD_CREATE_JSCALOBJS CMD_CREATE_JSCALCACHE \ + CMD_CREATE_JSCARDCACHE /* leaves these unused columns around, but that's life. A dav_reconstruct * will fix them */ @@ -182,15 +260,36 @@ #define CMD_DBUPGRADEv7 \ "ALTER TABLE ical_objs ADD COLUMN createdmodseq INTEGER;" \ - "UPDATE ical_objs SET createdmodseq = 1;" \ + "UPDATE ical_objs SET createdmodseq = 0;" \ "ALTER TABLE vcard_objs ADD COLUMN createdmodseq INTEGER;" \ - "UPDATE vcard_objs SET createdmodseq = 1;" \ + "UPDATE vcard_objs SET createdmodseq = 0;" \ "ALTER TABLE dav_objs ADD COLUMN createdmodseq INTEGER;" \ - "UPDATE dav_objs SET createdmodseq = 1;" + "UPDATE dav_objs SET createdmodseq = 0;" #define CMD_DBUPGRADEv8 \ "ALTER TABLE vcard_emails ADD COLUMN ispinned INTEGER NOT NULL DEFAULT 0;" +#define CMD_DBUPGRADEv9 CMD_CREATE_CALCACHE CMD_CREATE_CARDCACHE + +#define CMD_DBUPGRADEv10 \ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_ical_imapuid ON ical_objs ( mailbox, imap_uid );" \ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_vcard_imapuid ON vcard_objs ( mailbox, imap_uid );" \ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_object_imapuid ON dav_objs ( mailbox, imap_uid );" \ + "DROP INDEX IF EXISTS idx_res_uid;" + +#define CMD_DBUPGRADEv14 CMD_CREATE_SIEVE + +#define CMD_DBUPGRADEv15 \ + "DROP TABLE ical_jmapcache;" \ + CMD_CREATE_JSCALOBJS CMD_CREATE_JSCALCACHE \ + "INSERT INTO jscal_objs" \ + " SELECT rowid, '', modseq, createdmodseq, dtstart, dtend, alive, '' FROM ical_objs;" + +#define CMD_DBUPGRADEv16 \ + "DROP TABLE vcard_jmapcache;" \ + CMD_CREATE_JSCARDCACHE + +static int sievedb_upgrade(sqldb_t *db); struct sqldb_upgrade davdb_upgrade[] = { { 2, CMD_DBUPGRADEv2, NULL }, @@ -200,61 +299,166 @@ struct sqldb_upgrade davdb_upgrade[] = { { 6, CMD_DBUPGRADEv6, NULL }, { 7, CMD_DBUPGRADEv7, NULL }, { 8, CMD_DBUPGRADEv8, NULL }, + { 9, CMD_DBUPGRADEv9, NULL }, + { 10, CMD_DBUPGRADEv10, NULL }, + /* Don't upgrade to version 11. We only jump to 11 on CREATE */ + /* Don't upgrade to version 12. This was an intermediate Sieve DB version */ + /* Don't upgrade to version 13. This was an intermediate Sieve DB version */ + { 14, CMD_DBUPGRADEv14, &sievedb_upgrade }, + { 15, CMD_DBUPGRADEv15, NULL }, + { 16, CMD_DBUPGRADEv16, NULL }, { 0, NULL, NULL } }; -#define DB_VERSION 8 +#define DB_VERSION 16 + +static sqldb_t *reconstruct_db; + +/* Create filename corresponding to DAV DB for mailbox */ +EXPORTED void dav_getpath(struct buf *fname, struct mailbox *mailbox) +{ + char *userid = mboxname_to_userid(mailbox_name(mailbox)); + + if (userid) dav_getpath_byuserid(fname, userid); + else buf_setcstr(fname, mailbox_meta_fname(mailbox, META_DAV)); -static int in_reconstruct = 0; + free(userid); +} + +/* Create filename corresponding to DAV DB for userid */ +EXPORTED void dav_getpath_byuserid(struct buf *fname, const char *userid) +{ + char *path = user_hash_meta(userid, FNAME_DAVSUFFIX); + buf_setcstr(fname, path); + free(path); +} EXPORTED sqldb_t *dav_open_userid(const char *userid) { + if (reconstruct_db) return reconstruct_db; + sqldb_t *db = NULL; struct buf fname = BUF_INITIALIZER; dav_getpath_byuserid(&fname, userid); - if (in_reconstruct) buf_printf(&fname, ".NEW"); db = sqldb_open(buf_cstring(&fname), CMD_CREATE, DB_VERSION, davdb_upgrade, - config_getint(IMAPOPT_DAV_LOCK_TIMEOUT) * 1000); + config_getduration(IMAPOPT_DAV_LOCK_TIMEOUT, 's') * 1000); buf_free(&fname); return db; } EXPORTED sqldb_t *dav_open_mailbox(struct mailbox *mailbox) { + if (reconstruct_db) return reconstruct_db; + sqldb_t *db = NULL; struct buf fname = BUF_INITIALIZER; dav_getpath(&fname, mailbox); - if (in_reconstruct) buf_printf(&fname, ".NEW"); db = sqldb_open(buf_cstring(&fname), CMD_CREATE, DB_VERSION, davdb_upgrade, - config_getint(IMAPOPT_DAV_LOCK_TIMEOUT) * 1000); + config_getduration(IMAPOPT_DAV_LOCK_TIMEOUT, 's') * 1000); buf_free(&fname); return db; } +EXPORTED int dav_attach_userid(sqldb_t *db, const char *userid) +{ + assert (!reconstruct_db); + + struct buf fname = BUF_INITIALIZER; + dav_getpath_byuserid(&fname, userid); + int r = sqldb_attach(db, buf_cstring(&fname)); + buf_free(&fname); + return r; +} + +EXPORTED int dav_attach_mailbox(sqldb_t *db, struct mailbox *mailbox) +{ + assert (!reconstruct_db); + + struct buf fname = BUF_INITIALIZER; + dav_getpath(&fname, mailbox); + int r = sqldb_attach(db, buf_cstring(&fname)); + buf_free(&fname); + return r; +} + +EXPORTED int dav_close(sqldb_t **dbp) +{ + if (reconstruct_db) return 0; + + return sqldb_close(dbp); +} + + /* * mboxlist_usermboxtree() callback function to create DAV DB entries for a mailbox */ -static int _dav_reconstruct_mb(const mbentry_t *mbentry, void *rock __attribute__((unused))) +static int _dav_reconstruct_mb(const mbentry_t *mbentry, + void *rock +#ifndef WITH_JMAP + __attribute__((unused)) +#endif + ) { +#ifdef WITH_JMAP + const char *userid = (const char *) rock; + struct buf attrib = BUF_INITIALIZER; +#endif + int (*addproc)(struct mailbox *) = NULL; + int writelock = 0; int r = 0; signals_poll(); + switch (mbtype_isa(mbentry->mbtype)) { #ifdef WITH_DAV - if (mbentry->mbtype & MBTYPES_DAV) { + case MBTYPE_CALENDAR: + case MBTYPE_COLLECTION: + case MBTYPE_ADDRESSBOOK: + addproc = &mailbox_add_dav; + writelock = 1; // write lock so we can delete stale index records + break; +#endif +#ifdef USE_SIEVE + case MBTYPE_SIEVE: + addproc = &mailbox_add_sieve; + break; +#endif +#ifdef WITH_JMAP + case MBTYPE_JMAPSUBMIT: + addproc = &mailbox_add_email_alarms; + break; + + case MBTYPE_EMAIL: + r = annotatemore_lookup(mbentry->name, "/specialuse", userid, &attrib); + if (!r && buf_len(&attrib)) { + strarray_t *specialuse = + strarray_split(buf_cstring(&attrib), NULL, 0); + + if (strarray_contains(specialuse, "\\Snoozed")) { + addproc = &mailbox_add_email_alarms; + } + strarray_free(specialuse); + } + buf_free(&attrib); + break; +#endif + } + + if (addproc) { struct mailbox *mailbox = NULL; /* Open/lock header */ - r = mailbox_open_iwl(mbentry->name, &mailbox); - // needs to be writable to remove bogus lastalarm data - if (!r) r = mailbox_add_dav(mailbox); + if (writelock) + r = mailbox_open_iwl(mbentry->name, &mailbox); + else + r = mailbox_open_irl(mbentry->name, &mailbox); + if (!r) r = addproc(mailbox); mailbox_close(&mailbox); } -#endif return r; } -static void run_audit_tool(const char *tool, const char *srcdb, const char *dstdb) +static void run_audit_tool(const char *tool, const char *userid, const char *srcdb, const char *dstdb) { pid_t pid = fork(); if (pid < 0) @@ -262,7 +466,7 @@ static void run_audit_tool(const char *tool, const char *srcdb, const char *dstd if (pid == 0) { /* child */ - execl(tool, tool, srcdb, dstdb, (void *)NULL); + execl(tool, tool, "-C", config_filename, "-u", userid, srcdb, dstdb, (void *)NULL); exit(-1); } @@ -281,24 +485,32 @@ EXPORTED int dav_reconstruct_user(const char *userid, const char *audit_tool) dav_getpath_byuserid(&newfname, userid); buf_printf(&newfname, ".NEW"); - /* XXX - this still means that alarms can go missing if this - * task is interrupted, but we can't afford to keep the - * alarm database locked for the entire time, it's a single - * blocking database over the entire server */ - caldav_alarm_delete_user(userid); - - in_reconstruct = 1; - - sqldb_t *userdb = dav_open_userid(userid); - sqldb_begin(userdb, "reconstruct"); - int r = mboxlist_usermboxtree(userid, NULL, _dav_reconstruct_mb, NULL, 0); - if (r) - sqldb_rollback(userdb, "reconstruct"); - else - sqldb_commit(userdb, "reconstruct"); - sqldb_close(&userdb); + struct mboxlock *namespacelock = user_namespacelock(userid); - in_reconstruct = 0; + int r = IMAP_IOERROR; + reconstruct_db = sqldb_open(buf_cstring(&newfname), CMD_CREATE, DB_VERSION, davdb_upgrade, + config_getduration(IMAPOPT_DAV_LOCK_TIMEOUT, 's') * 1000); + if (reconstruct_db) { + r = sqldb_begin(reconstruct_db, "reconstruct"); +#ifdef WITH_DAV + // make all the alarm updates to go this database too + if (!r) r = caldav_alarm_set_reconstruct(reconstruct_db); +#endif + // reconstruct everything + if (!r) r = mboxlist_usermboxtree(userid, NULL, + _dav_reconstruct_mb, (void *) userid, 0); +#ifdef WITH_DAV + // make sure all the alarms are resolved + if (!r) r = caldav_alarm_process(0, NULL, /*dryrun*/1); + // commit events over to ther alarm database if we're keeping them + if (!r && !audit_tool) r = caldav_alarm_commit_reconstruct(userid); + else caldav_alarm_rollback_reconstruct(); +#endif + // and commit to this DB + if (r) sqldb_rollback(reconstruct_db, "reconstruct"); + else sqldb_commit(reconstruct_db, "reconstruct"); + sqldb_close(&reconstruct_db); + } /* this actually works before close according to the internets */ if (r) { @@ -306,21 +518,126 @@ EXPORTED int dav_reconstruct_user(const char *userid, const char *audit_tool) if (audit_tool) { printf("Not auditing %s, reconstruct failed %s\n", userid, error_message(r)); } - unlink(buf_cstring(&newfname)); + xunlink(buf_cstring(&newfname)); } else { syslog(LOG_NOTICE, "dav_reconstruct_user: %s SUCCEEDED", userid); if (audit_tool) { - run_audit_tool(audit_tool, buf_cstring(&fname), buf_cstring(&newfname)); - unlink(buf_cstring(&newfname)); + run_audit_tool(audit_tool, userid, buf_cstring(&fname), buf_cstring(&newfname)); + xunlink(buf_cstring(&newfname)); } else { rename(buf_cstring(&newfname), buf_cstring(&fname)); } } + mboxname_release(&namespacelock); + buf_free(&newfname); buf_free(&fname); return 0; } + + +struct sievedb_upgrade_rock { + char *mboxname; + strarray_t *sha1; +}; + +static int sievedb_upgrade_cb(sqlite3_stmt *stmt, void *rock) +{ + struct sievedb_upgrade_rock *srock = (struct sievedb_upgrade_rock *) rock; + + if (!srock->mboxname) { + srock->mboxname = xstrdup((const char *) sqlite3_column_text(stmt, 0)); + } + + if (srock->sha1) { + const char *content = (const char *) sqlite3_column_text(stmt, 1); + unsigned rowid = sqlite3_column_int(stmt, 2); + struct message_guid uuid; + + /* Generate SHA1 from content */ + message_guid_generate(&uuid, content, strlen(content)); + + /* Add SHA1 to our array using rowid as the index */ + strarray_set(srock->sha1, rowid, message_guid_encode(&uuid)); + } + + return 0; +} + +#define CMD_GET_v12_ROWS \ + "SELECT mailbox, content, rowid FROM sieve_scripts;" + +#define CMD_ALTER_v12_TABLE \ + "ALTER TABLE sieve_scripts RENAME COLUMN content TO contentid;" + +#define CMD_UPDATE_v13_ROW \ + "UPDATE sieve_scripts SET contentid = :contentid WHERE rowid = :rowid;" + +#define CMD_GET_v13_ROW1 \ + "SELECT mailbox FROM sieve_scripts LIMIT 1;" + +#define CMD_UPDATE_v13_TABLE \ + "UPDATE sieve_scripts SET mailbox = :mailbox;" + + +/* Upgrade v12/v13 sieve_script table to v14 */ +static int sievedb_upgrade(sqldb_t *db) +{ + struct sievedb_upgrade_rock srock = { NULL, NULL }; + struct sqldb_bindval bval[] = { + { ":rowid", SQLITE_INTEGER, { .i = 0 } }, + { ":contentid", SQLITE_TEXT, { .s = NULL } }, + { ":mailbox", SQLITE_TEXT, { .s = NULL } }, + { NULL, SQLITE_NULL, { .s = NULL } } }; + strarray_t sha1 = STRARRAY_INITIALIZER; + mbentry_t *mbentry = NULL; + int rowid; + int r = 0; + + if (db->version == 12) { + /* Create an array of SHA1 for the content in each record */ + srock.sha1 = &sha1; + r = sqldb_exec(db, CMD_GET_v12_ROWS, NULL, &sievedb_upgrade_cb, &srock); + if (r) goto done; + + /* Rename 'content' -> 'contentid' */ + r = sqldb_exec(db, CMD_ALTER_v12_TABLE, NULL, NULL, NULL); + if (r) goto done; + + /* Rewrite 'contentid' columns with actual ids (SHA1) */ + for (rowid = 1; rowid < strarray_size(&sha1); rowid++) { + bval[0].val.i = rowid; + bval[1].val.s = strarray_nth(&sha1, rowid); + + r = sqldb_exec(db, CMD_UPDATE_v13_ROW, bval, NULL, NULL); + if (r) goto done; + } + } + else if (db->version == 13) { + /* Fetch mailbox name from first record */ + r = sqldb_exec(db, CMD_GET_v13_ROW1, NULL, &sievedb_upgrade_cb, &srock); + if (r) goto done; + } + + /* This will only be set if we are upgrading from v12 or v13 + AND there are records in the table */ + if (!srock.mboxname) goto done; + + r = mboxlist_lookup_allow_all(srock.mboxname, &mbentry, NULL); + if (r) goto done; + + /* Rewrite 'mailbox' columns with mboxid rather than mboxname */ + bval[2].val.s = mbentry->uniqueid; + r = sqldb_exec(db, CMD_UPDATE_v13_TABLE, bval, NULL, NULL); + + done: + mboxlist_entry_free(&mbentry); + strarray_fini(&sha1); + free(srock.mboxname); + + return r; +} diff --git a/imap/dav_db.h b/imap/dav_db.h index 3dfdb63ee2..e9b0170473 100644 --- a/imap/dav_db.h +++ b/imap/dav_db.h @@ -45,10 +45,13 @@ #define DAV_DB_H #include "sqldb.h" -#include "dav_util.h" #include "mailbox.h" #include "util.h" +#define FNAME_DAVSUFFIX "dav" /* per-user DAV DB extension */ + +#define DB_MBOXID_VERSION 11 /* first version with records by mboxid */ + struct dav_data { unsigned rowid; time_t creationdate; @@ -62,15 +65,26 @@ struct dav_data { const char *lock_ownerid; time_t lock_expire; int alive; + int mailbox_byname; /* NOT stored in record - derived from db ver */ }; +/* Create filename corresponding to DAV DB for mailbox */ +void dav_getpath(struct buf *fname, struct mailbox *mailbox); + +/* Create filename corresponding to DAV DB for userid */ +void dav_getpath_byuserid(struct buf *fname, const char *userid); + /* get a database handle corresponding to mailbox */ sqldb_t *dav_open_userid(const char *userid); sqldb_t *dav_open_mailbox(struct mailbox *mailbox); +int dav_close(sqldb_t **dbp); /* delete database corresponding to mailbox */ int dav_delete(struct mailbox *mailbox); int dav_reconstruct_user(const char *userid, const char *audit_tool); +int dav_attach_userid(sqldb_t *db, const char *userid); +int dav_attach_mailbox(sqldb_t *db, struct mailbox *mailbox); + #endif /* DAV_DB_H */ diff --git a/imap/dav_reconstruct.c b/imap/dav_reconstruct.c index 4e8af09cae..419b7b61d3 100644 --- a/imap/dav_reconstruct.c +++ b/imap/dav_reconstruct.c @@ -46,6 +46,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -71,9 +72,6 @@ /* generated headers are not necessarily in current directory */ #include "imap/imap_err.h" -extern int optind; -extern char *optarg; - /* current namespace */ static struct namespace recon_namespace; @@ -100,7 +98,19 @@ int main(int argc, char **argv) int allusers = 0; const char *audit_tool = NULL; - while ((opt = getopt(argc, argv, "C:A:a")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:A:a"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "all", no_argument, NULL, 'a' }, + { "audit-tool", required_argument, NULL, 'A' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; @@ -120,9 +130,10 @@ int main(int argc, char **argv) } cyrus_init(alt_config, "dav_reconstruct", 0, 0); + global_sasl_init(1,0,NULL); /* Set namespace -- force standard (internal) */ - if ((r = mboxname_init_namespace(&recon_namespace, 1)) != 0) { + if ((r = mboxname_init_namespace(&recon_namespace, NAMESPACE_OPTION_ADMIN))) { syslog(LOG_ERR, "%s", error_message(r)); fatal(error_message(r), EX_CONFIG); } @@ -131,9 +142,6 @@ int main(int argc, char **argv) signals_add_handlers(0); sqldb_init(); - /* Initialize libical */ - ical_support_init(); - if (allusers) { mboxlist_alluser(do_user, (void *)audit_tool); } @@ -146,6 +154,10 @@ int main(int argc, char **argv) do_user(argv[i], (void *)audit_tool); } + libcyrus_run_delayed(); + sqldb_done(); + cyrus_done(); + exit(code); } @@ -165,6 +177,8 @@ void shut_down(int code) { in_shutdown = 1; + libcyrus_run_delayed(); + mboxlist_close(); mboxlist_done(); sqldb_done(); diff --git a/imap/dav_util.c b/imap/dav_util.c index 74dfd164f6..815a9f1f48 100644 --- a/imap/dav_util.c +++ b/imap/dav_util.c @@ -45,32 +45,303 @@ #include +#include "append.h" +#include "dav_db.h" #include "dav_util.h" #include "global.h" +#include "httpd.h" #include "mailbox.h" #include "mboxname.h" +#include "spool.h" +#include "strhash.h" +#include "syslog.h" +#include "times.h" #include "user.h" #include "util.h" +#include "xstrnchr.h" -/* Create filename corresponding to DAV DB for mailbox */ -EXPORTED void dav_getpath(struct buf *fname, struct mailbox *mailbox) +/* generated headers are not necessarily in current directory */ +#include "imap/http_err.h" +#include "imap/imap_err.h" + + +EXPORTED int dav_get_validators(struct mailbox *mailbox, void *data, + const char *userid __attribute__((unused)), + struct index_record *record, + const char **etag, time_t *lastmod) { - char *userid = mboxname_to_userid(mailbox->name); + const struct dav_data *ddata = (const struct dav_data *) data; - if (userid) dav_getpath_byuserid(fname, userid); - else buf_setcstr(fname, mailbox_meta_fname(mailbox, META_DAV)); + memset(record, 0, sizeof(struct index_record)); + + if (!ddata->alive) { + /* New resource */ + if (etag) *etag = NULL; + if (lastmod) *lastmod = 0; + } + else if (ddata->imap_uid) { + /* Mapped URL */ + int r; + + /* Fetch index record for the resource */ + r = mailbox_find_index_record(mailbox, ddata->imap_uid, record); + if (r) { + syslog(LOG_ERR, "mailbox_find_index_record(%s, %u) failed: %s", + mailbox_name(mailbox), ddata->imap_uid, error_message(r)); + return r; + } + + if (etag) *etag = message_guid_encode(&record->guid); + if (lastmod) *lastmod = record->internaldate; + } + else { + /* Unmapped URL (empty resource) */ + if (etag) *etag = NULL; + if (lastmod) *lastmod = ddata->creationdate; + } - free(userid); + return 0; } -/* Create filename corresponding to DAV DB for userid */ -EXPORTED void dav_getpath_byuserid(struct buf *fname, const char *userid) + +EXPORTED int dav_store_resource(struct transaction_t *txn, + const char *data, size_t datalen, + struct mailbox *mailbox, + struct index_record *oldrecord, + modseq_t createdmodseq, + const strarray_t *add_imapflags, + const strarray_t *del_imapflags) { - char *path = user_hash_meta(userid, FNAME_DAVSUFFIX); + int ret = HTTP_CREATED, r; + hdrcache_t hdrcache = txn->req_hdrs; + struct stagemsg *stage; + FILE *f = NULL; + const char **hdr, *cte; + quota_t qdiffs[QUOTA_NUMRESOURCES] = QUOTA_DIFFS_DONTCARE_INITIALIZER; + time_t now = time(NULL); + struct appendstate as; + const char *mboxname = mailbox_name(mailbox); + + /* Prepare to stage the message */ + if (!(f = append_newstage(mboxname, now, + strhash(mboxname), /* unique msgnum to avoid clash + during iMIP processing */ + &stage))) { + syslog(LOG_ERR, "append_newstage(%s) failed", mailbox_name(mailbox)); + txn->error.desc = "append_newstage() failed"; + return HTTP_SERVER_ERROR; + } + + /* Create RFC 5322 header for resource */ + if ((hdr = spool_getheader(hdrcache, "User-Agent"))) { + fprintf(f, "User-Agent: %s\r\n", hdr[0]); + } + + if ((hdr = spool_getheader(hdrcache, "From"))) { + fprintf(f, "From: %s\r\n", hdr[0]); + } + else { + char *mimehdr; + + assert(!buf_len(&txn->buf)); + if (strchr(txn->userid, '@')) { + /* XXX This needs to be done via an LDAP/DB lookup */ + buf_printf(&txn->buf, "<%s>", txn->userid); + } + else { + buf_printf(&txn->buf, "<%s@%s>", txn->userid, config_servername); + } + + mimehdr = charset_encode_mimeheader(buf_cstring(&txn->buf), + buf_len(&txn->buf), 0); + fprintf(f, "From: %s\r\n", mimehdr); + free(mimehdr); + buf_reset(&txn->buf); + } + + if ((hdr = spool_getheader(hdrcache, "Subject"))) { + fprintf(f, "Subject: %s\r\n", hdr[0]); + } + + if ((hdr = spool_getheader(hdrcache, "Date"))) { + fprintf(f, "Date: %s\r\n", hdr[0]); + } + else { + char datestr[80]; /* XXX: Why do we need 80 character buffer? */ + time_to_rfc5322(now, datestr, sizeof(datestr)); + fprintf(f, "Date: %s\r\n", datestr); + } + + if ((hdr = spool_getheader(hdrcache, "Message-ID"))) { + fprintf(f, "Message-ID: %s\r\n", hdr[0]); + } + + if ((hdr = spool_getheader(hdrcache, "X-Schedule-User-Address"))) { + fprintf(f, "X-Schedule-User-Address: %s\r\n", hdr[0]); + } - if (buf_cstringnull(fname)) { - buf_setcstr(fname, path); - free(path); + if ((hdr = spool_getheader(hdrcache, "Content-Type"))) { + fprintf(f, "Content-Type: %s\r\n", hdr[0]); } - else buf_initm(fname, path, strlen(path)); + else fputs("Content-Type: application/octet-stream\r\n", f); + + if (!datalen) { + datalen = strlen(data); + cte = "8bit"; + } + else { + cte = strnchr(data, '\0', datalen) ? "binary" : "8bit"; + } + fprintf(f, "Content-Transfer-Encoding: %s\r\n", cte); + + if ((hdr = spool_getheader(hdrcache, "Content-Disposition"))) { + fprintf(f, "Content-Disposition: %s\r\n", hdr[0]); + } + + if ((hdr = spool_getheader(hdrcache, "Content-Description"))) { + fprintf(f, "Content-Description: %s\r\n", hdr[0]); + } + + fprintf(f, "Content-Length: %u\r\n", (unsigned) datalen); + + fputs("MIME-Version: 1.0\r\n\r\n", f); + + /* Write the data to the file */ + fwrite(data, datalen, 1, f); + qdiffs[QUOTA_STORAGE] = ftell(f); + + fclose(f); + + qdiffs[QUOTA_MESSAGE] = 1; + + /* Prepare to append the message to the mailbox */ + if ((r = append_setup_mbox(&as, mailbox, txn->userid, txn->authstate, + 0, qdiffs, 0, 0, EVENT_MESSAGE_NEW|EVENT_CALENDAR))) { + syslog(LOG_ERR, "append_setup(%s) failed: %s", + mailbox_name(mailbox), error_message(r)); + if (r == IMAP_QUOTA_EXCEEDED || r == IMAP_NO_OVERQUOTA) { + /* DAV:quota-not-exceeded */ + txn->error.precond = DAV_OVER_QUOTA; + txn->error.desc = + (r == IMAP_NO_OVERQUOTA) ? "num resources" : "storage"; + ret = HTTP_NO_STORAGE; + } else { + ret = HTTP_SERVER_ERROR; + txn->error.desc = error_message(r); + } + } + else { + struct body *body = NULL; + + strarray_t *flaglist = NULL; + struct entryattlist *annots = NULL; + + if (oldrecord) { + flaglist = mailbox_extract_flags(mailbox, oldrecord, txn->userid); + mailbox_get_annotate_state(mailbox, oldrecord->uid, NULL); + annots = mailbox_extract_annots(mailbox, oldrecord); + } + + /* XXX - casemerge? Doesn't matter with flags */ + if (add_imapflags) { + if (flaglist) + strarray_cat(flaglist, add_imapflags); + else + flaglist = strarray_dup(add_imapflags); + } + if (del_imapflags && flaglist) { + int i; + for (i = 0; i < strarray_size(del_imapflags); i++) { + strarray_remove_all_case(flaglist, strarray_nth(del_imapflags, i)); + } + } + + /* Append the message to the mailbox */ + if ((r = append_fromstage(&as, &body, stage, now, + createdmodseq, flaglist, 0, &annots))) { + syslog(LOG_ERR, "append_fromstage(%s) failed: %s", + mailbox_name(mailbox), error_message(r)); + if (r == IMAP_QUOTA_EXCEEDED || r == IMAP_NO_OVERQUOTA) { + /* DAV:quota-not-exceeded */ + txn->error.precond = DAV_OVER_QUOTA; + txn->error.desc = + (r == IMAP_NO_OVERQUOTA) ? "num resources" : "storage"; + ret = HTTP_NO_STORAGE; + } else { + txn->error.desc = error_message(r); + ret = HTTP_SERVER_ERROR; + } + } + if (body) { + message_free_body(body); + free(body); + } + strarray_free(flaglist); + freeentryatts(annots); + + if (r) append_abort(&as); + else { + /* Commit the append to the mailbox */ + if ((r = append_commit(&as))) { + syslog(LOG_ERR, "append_commit(%s) failed: %s", + mailbox_name(mailbox), error_message(r)); + ret = HTTP_SERVER_ERROR; + txn->error.desc = "append_commit() failed"; + } + else { + if (oldrecord) { + /* Now that we have the replacement message in place + expunge the old one. */ + int userflag; + + ret = HTTP_NO_CONTENT; + + /* Perform the actual expunge */ + r = mailbox_user_flag(mailbox, DFLAG_UNBIND, &userflag, 1); + if (!r) { + oldrecord->user_flags[userflag/32] |= 1 << (userflag & 31); + oldrecord->internal_flags |= FLAG_INTERNAL_EXPUNGED; + r = mailbox_rewrite_index_record(mailbox, oldrecord); + } + if (r) { + syslog(LOG_ERR, "expunging record (%s) failed: %s", + mailbox_name(mailbox), error_message(r)); + txn->error.desc = error_message(r); + ret = HTTP_SERVER_ERROR; + } + } + + if (!r) { + /* Read index record for new message (always the last one) */ + struct index_record newrecord; + struct dav_data ddata; + static char etagbuf[256]; + const char *etag; + + ddata.alive = 1; + ddata.imap_uid = mailbox->i.last_uid; + assert(ddata.imap_uid != 0); + r = dav_get_validators(mailbox, &ddata, txn->userid, &newrecord, + &etag, &txn->resp_body.lastmod); + if (r) { + xsyslog(LOG_ERR, "read index record failed", + "mailbox=<%s> uid=<%u>", + mailbox_name(mailbox), + ddata.imap_uid); + txn->error.desc = error_message(r); + ret = HTTP_SERVER_ERROR; + } + else { + strncpy(etagbuf, etag, 255); + etagbuf[255] = 0; + txn->resp_body.etag = etagbuf; + } + } + } + } + } + + append_removestage(stage); + + return ret; } diff --git a/imap/dav_util.h b/imap/dav_util.h index d5cb7eaa59..7233d4505b 100644 --- a/imap/dav_util.h +++ b/imap/dav_util.h @@ -49,10 +49,121 @@ #define FNAME_DAVSUFFIX "dav" /* per-user DAV DB extension */ -/* Create filename corresponding to DAV DB for mailbox */ -void dav_getpath(struct buf *fname, struct mailbox *mailbox); +/* XML namespace URIs */ +#define XML_NS_DAV "DAV:" +#define XML_NS_CALDAV "urn:ietf:params:xml:ns:caldav" +#define XML_NS_CARDDAV "urn:ietf:params:xml:ns:carddav" +#define XML_NS_ISCHED "urn:ietf:params:xml:ns:ischedule" +#define XML_NS_CS "http://calendarserver.org/ns/" +#define XML_NS_MECOM "http://me.com/_namespace/" +#define XML_NS_MOBME "urn:mobileme:davservices" +#define XML_NS_APPLE "http://apple.com/ns/ical/" +#define XML_NS_USERFLAG "http://cyrusimap.org/ns/userflag/" +#define XML_NS_SYSFLAG "http://cyrusimap.org/ns/sysflag/" +#define XML_NS_DAVMOUNT "http://purl.org/NET/webdav/mount/" +#define XML_NS_JMAPCAL "urn:ietf:params:jmap:calendars" +#define XML_NS_CYRUS "http://cyrusimap.org/ns/" -/* Create filename corresponding to DAV DB for userid */ -void dav_getpath_byuserid(struct buf *fname, const char *userid); +/* Index into preconditions array */ +enum { + /* WebDAV (RFC 4918) preconditions */ + DAV_PROT_PROP = 1, + DAV_BAD_LOCK_TOKEN, + DAV_NEED_LOCK_TOKEN, + DAV_LOCKED, + DAV_FINITE_DEPTH, + + /* WebDAV Versioning (RFC 3253) preconditions */ + DAV_SUPP_REPORT, + DAV_RES_EXISTS, + + /* WebDAV ACL (RFC 3744) preconditions */ + DAV_NEED_PRIVS, + DAV_NO_INVERT, + DAV_NO_ABSTRACT, + DAV_SUPP_PRIV, + DAV_RECOG_PRINC, + DAV_ALLOW_PRINC, + DAV_GRANT_ONLY, + + /* WebDAV Quota (RFC 4331) preconditions */ + DAV_OVER_QUOTA, + DAV_NO_DISK_SPACE, + + /* WebDAV Extended MKCOL (RFC 5689) preconditions */ + DAV_VALID_RESTYPE, + + /* WebDAV Sync (RFC 6578) preconditions */ + DAV_SYNC_TOKEN, + DAV_OVER_LIMIT, + + /* CalDAV (RFC 4791) preconditions */ + CALDAV_SUPP_DATA, + CALDAV_VALID_DATA, + CALDAV_VALID_OBJECT, + CALDAV_SUPP_COMP, + CALDAV_LOCATION_OK, + CALDAV_UID_CONFLICT, + CALDAV_SUPP_FILTER, + CALDAV_VALID_FILTER, + CALDAV_SUPP_COLLATION, + CALDAV_MAX_SIZE, + + /* RSCALE (RFC 7529) preconditions */ + CALDAV_SUPP_RSCALE, + + /* Time Zones by Reference (RFC 7809) preconditions */ + CALDAV_VALID_TIMEZONE, + + /* Managed Attachments (RFC8607) preconditions */ + CALDAV_VALID_MANAGEDID, + + /* Bulk Change (draft-daboo-calendarserver-bulk-change) preconditions */ + CALDAV_CTAG_OK, + + /* CalDAV Scheduling (RFC 6638) preconditions */ + CALDAV_VALID_SCHED, + CALDAV_VALID_ORGANIZER, + CALDAV_UNIQUE_OBJECT, + CALDAV_SAME_ORGANIZER, + CALDAV_ALLOWED_ORG_CHANGE, + CALDAV_ALLOWED_ATT_CHANGE, + CALDAV_DEFAULT_NEEDED, + CALDAV_VALID_DEFAULT, + + /* iSchedule (draft-desruisseaux-ischedule) preconditions */ + ISCHED_UNSUPP_VERSION, + ISCHED_UNSUPP_DATA, + ISCHED_INVALID_DATA, + ISCHED_INVALID_SCHED, + ISCHED_ORIG_MISSING, + ISCHED_MULTIPLE_ORIG, + ISCHED_ORIG_INVALID, + ISCHED_ORIG_DENIED, + ISCHED_RECIP_MISSING, + ISCHED_RECIP_MISMATCH, + ISCHED_VERIFICATION_FAILED, + + /* CardDAV (RFC 6352) preconditions */ + CARDDAV_SUPP_DATA, + CARDDAV_VALID_DATA, + CARDDAV_UID_CONFLICT, + CARDDAV_LOCATION_OK, + CARDDAV_SUPP_FILTER, + CARDDAV_SUPP_COLLATION, + CARDDAV_MAX_SIZE, +}; + +int dav_get_validators(struct mailbox *mailbox, void *data, + const char *userid, struct index_record *record, + const char **etag, time_t *lastmod); + +typedef struct transaction_t txn_t; // defined in httpd.h +int dav_store_resource(struct transaction_t *txn, + const char *data, size_t datalen, + struct mailbox *mailbox, struct index_record *oldrecord, + modseq_t createdmodseq, + const strarray_t *add_imapflags, + const strarray_t *del_imapflags); #endif /* DAV_UTIL_H */ diff --git a/imap/defaultalarms.c b/imap/defaultalarms.c new file mode 100644 index 0000000000..bf29084002 --- /dev/null +++ b/imap/defaultalarms.c @@ -0,0 +1,912 @@ +#include "annotate.h" +#include "bsearch.h" +#include "caldav_util.h" +#include "defaultalarms.h" +#include "jmap_util.h" +#include "syslog.h" + +#define CALDAV_ANNOT_DEFAULTALARM_VEVENT_DATETIME \ + DAV_ANNOT_NS "<" XML_NS_CALDAV ">default-alarm-vevent-datetime" + +#define CALDAV_ANNOT_DEFAULTALARM_VEVENT_DATE \ + DAV_ANNOT_NS "<" XML_NS_CALDAV ">default-alarm-vevent-date" + +#define JMAP_ANNOT_DEFAULTALERTS JMAP_ANNOT_NS "defaultalerts" + +static void defaultalarms_record_fini(struct defaultalarms_record *rec) +{ + if (rec->ical) { + icalcomponent_free(rec->ical); + rec->ical = NULL; + } + + message_guid_set_null(&rec->guid); + free(rec->atag); + rec->atag = NULL; +} + +EXPORTED void defaultalarms_fini(struct defaultalarms *defalarms) +{ + if (defalarms) { + defaultalarms_record_fini(&defalarms->with_time); + defaultalarms_record_fini(&defalarms->with_date); + } +} + +enum internalize_flags { + INTERNALIZE_DETERMINISTIC_UID = (1 << 0), + INTERNALIZE_KEEP_APPLE = (1 << 1), +}; + +static icalcomponent *internalize_alarms(icalcomponent *alarms, enum internalize_flags flags) +{ + icalcomponent *myalarms = icalcomponent_new(ICAL_XROOT_COMPONENT); + struct buf buf = BUF_INITIALIZER; + + if (icalcomponent_isa(alarms) == ICAL_VALARM_COMPONENT) { + icalcomponent_add_component(myalarms, + icalcomponent_clone(alarms)); + } + else { + icalcomponent *valarm; + for (valarm = icalcomponent_get_first_component(alarms, + ICAL_VALARM_COMPONENT); + valarm; + valarm = icalcomponent_get_next_component(alarms, + ICAL_VALARM_COMPONENT)) { + icalcomponent_add_component(myalarms, + icalcomponent_clone(valarm)); + } + } + + icalcomponent *valarm; + for (valarm = icalcomponent_get_first_component(myalarms, ICAL_VALARM_COMPONENT); + valarm; + valarm = icalcomponent_get_next_component(myalarms, ICAL_VALARM_COMPONENT)) { + + if (!icalcomponent_get_x_property_by_name(valarm, "X-JMAP-DEFAULT-ALARM")) { + icalproperty *prop = icalproperty_new(ICAL_X_PROPERTY); + icalproperty_set_x_name(prop, "X-JMAP-DEFAULT-ALARM"); + icalproperty_set_value(prop, icalvalue_new_boolean(1)); + icalcomponent_add_property(valarm, prop); + } + + if (!(flags & INTERNALIZE_KEEP_APPLE)) { + icalcomponent_remove_x_property_by_name(valarm, "X-APPLE-DEFAULT-ALARM"); + } + + if (!icalcomponent_get_uid(valarm)) { + if (flags & INTERNALIZE_DETERMINISTIC_UID) { + // that's just necessary for not-yet migrated legacy alarms + icalcomponent *myalarm = icalcomponent_clone(valarm); + icalcomponent_normalize(myalarm); + buf_setcstr(&buf, icalcomponent_as_ical_string(myalarm)); + icalcomponent_free(myalarm); + + struct message_guid guid = MESSAGE_GUID_INITIALIZER; + message_guid_generate(&guid, buf_base(&buf), buf_len(&buf)); + icalcomponent_set_uid(valarm, message_guid_encode(&guid)); + } + else { + buf_setcstr(&buf, makeuuid()); + icalcomponent_set_uid(valarm, buf_cstring(&buf)); + } + } + + const char *jmapid = icalcomponent_get_jmapid(valarm); + if (!jmapid) { + jmapid = icalcomponent_get_uid(valarm); + if (!jmap_is_valid_id(jmapid)) { + buf_setcstr(&buf, makeuuid()); + jmapid = buf_cstring(&buf); + } + icalcomponent_set_jmapid(valarm, jmapid); + } + } + + icalcomponent_normalize_x(myalarms); + + if (!icalcomponent_get_first_component(myalarms, ICAL_VALARM_COMPONENT)) { + icalcomponent_free(myalarms); + myalarms = NULL; + } + + buf_free(&buf); + return myalarms; +} + +static char *generate_atag(icalcomponent *comp) +{ + // requires comp to be normalized already + + struct buf buf = BUF_INITIALIZER; + char *atag = NULL; + + icalcomponent *valarm; + for (valarm = icalcomponent_get_first_component(comp, ICAL_VALARM_COMPONENT); + valarm; + valarm = icalcomponent_get_next_component(comp, ICAL_VALARM_COMPONENT)) { + + if (!icalcomponent_get_x_property_by_name(valarm, "X-JMAP-DEFAULT-ALARM")) + continue; + + // UID contributes to the atag + const char *uid = icalcomponent_get_uid(valarm); + if (uid) + buf_appendcstr(&buf, uid); + + // TRIGGER contributes to the atag + icalproperty *prop = + icalcomponent_get_first_property(valarm, ICAL_TRIGGER_PROPERTY); + if (prop) + buf_appendcstr(&buf, icalproperty_as_ical_string(prop)); + } + + if (buf_len(&buf)) { + struct message_guid guid = MESSAGE_GUID_INITIALIZER; + message_guid_generate(&guid, buf_base(&buf), buf_len(&buf)); + atag = xstrdup(message_guid_encode_short(&guid, 20)); + } + + buf_free(&buf); + return atag; +} + +static int get_alarms_dl(struct dlist *root, const char *name, + struct defaultalarms_record *rec) +{ + struct dlist *dl = NULL; + if (!dlist_getlist(root, name, &dl)) + return 0; + + const char *guidrep = NULL; + if (!dlist_getatom(dl, "GUID", &guidrep)) + return 0; + + const char *content = NULL; + if (!dlist_getatom(dl, "CONTENT", &content)) + return 0; + + const char *atag = NULL; + if (!dlist_getatom(dl, "ATAG", &atag)) + return 0; + + message_guid_decode(&rec->guid, guidrep); + if (*content) { + rec->ical = icalparser_parse_string(content); + if (rec->ical == NULL) + return 0; + } + rec->atag = xstrdupnull(atag); + + return 1; +} + +static int load_legacy_alarms(const char *mboxname, + const char *userid, + const char *annot, + enum internalize_flags flags, + struct defaultalarms_record *rec, + struct buf *buf) +{ + buf_reset(buf); + + int r = annotatemore_lookup(mboxname, annot, userid, buf); + if (!r && !buf_len(buf)) { + // We stored CalDAV alarms as a shared annotation. + char *ownerid = mboxname_to_userid(mboxname); + if (!strcmpsafe(userid, ownerid)) { + r = annotatemore_lookupmask(mboxname, annot, userid, buf); + } + free(ownerid); + } + if (r && r != CYRUSDB_NOTFOUND) return r; + + buf_trim(buf); + if (!buf_len(buf)) + return 0; + + const char *content = NULL; + const char *guidrep = NULL; + + struct dlist *dl = NULL; + if (!dlist_parsemap(&dl, 1, 0, buf_base(buf), buf_len(buf))) { + if (!dlist_getatom(dl, "CONTENT", &content)) + return CYRUSDB_IOERROR; + + if (!dlist_getatom(dl, "GUID", &guidrep)) + return CYRUSDB_IOERROR; + } + else { + content = buf_cstring(buf); + } + + if (*content) { + icalcomponent *alarms = icalparser_parse_string(content); + if (alarms) { + rec->ical = internalize_alarms(alarms, flags); + icalcomponent_free(alarms); + } + + if (guidrep) { + message_guid_decode(&rec->guid, guidrep); + } + else { + message_guid_generate(&rec->guid, content, strlen(content)); + } + + if (rec->ical) { + rec->atag = generate_atag(rec->ical); + } + } + + dlist_free(&dl); + return 0; +} + +static int load_alarms(const char *mboxname, + const char *userid, + enum internalize_flags legacy_flags, + struct defaultalarms *defalarms) +{ + struct buf buf = BUF_INITIALIZER; + defaultalarms_fini(defalarms); + char *calhomename = caldav_mboxname(userid, NULL); + + const char *annot = JMAP_ANNOT_DEFAULTALERTS; + int r = annotatemore_lookup(mboxname, annot, userid, &buf); + if (!r && buf_len(&buf)) { + struct dlist *root; + if (!dlist_parsemap(&root, 1, 0, buf_base(&buf), buf_len(&buf))) { + if (!get_alarms_dl(root, "WITH_TIME", &defalarms->with_time) || + !get_alarms_dl(root, "WITH_DATE", &defalarms->with_date)) { + + xsyslog(LOG_ERR, "corrupt default alarm annotation value", + "mboxname=<%s> userid=<%s> annot=<%s> value=<%s>", + mboxname, userid, annot, buf_cstring(&buf)); + + defaultalarms_fini(defalarms); + } + } + dlist_free(&root); + } + else { + // Any new JMAP calendar should at least have the zero + // value set in their default alarm annotation. If there + // is no annotation set, this indicates that this user's + // calendars did not get migrated to JMAP calendar default + // alerts. Fall back reading their CalDAV alarms. + r = load_legacy_alarms(mboxname, userid, + CALDAV_ANNOT_DEFAULTALARM_VEVENT_DATETIME, + legacy_flags, &defalarms->with_time, &buf); + + if (!r) + r = load_legacy_alarms(mboxname, userid, + CALDAV_ANNOT_DEFAULTALARM_VEVENT_DATE, + legacy_flags, &defalarms->with_date, &buf); + + if (r) + defaultalarms_fini(defalarms); + } + + free(calhomename); + buf_free(&buf); + return r; +} + +EXPORTED int defaultalarms_load(const char *mboxname, + const char *userid, + struct defaultalarms *defalarms) +{ + return load_alarms(mboxname, userid, + INTERNALIZE_DETERMINISTIC_UID|INTERNALIZE_KEEP_APPLE, defalarms); +} + +static void set_alarms_dl(struct dlist *root, const char *name, icalcomponent *alarms) +{ + struct message_guid guid = MESSAGE_GUID_INITIALIZER; + struct buf content = BUF_INITIALIZER; + char *atag = NULL; + + struct dlist *dl = dlist_newkvlist(root, name); + + if (alarms) { + icalcomponent *myalarms = internalize_alarms(alarms, 0); + if (myalarms) { + buf_setcstr(&content, icalcomponent_as_ical_string(myalarms)); + message_guid_generate(&guid, buf_base(&content), buf_len(&content)); + atag = generate_atag(myalarms); + icalcomponent_free(myalarms); + } + } + + dlist_setatom(dl, "CONTENT", buf_cstring(&content)); + dlist_setatom(dl, "GUID", message_guid_encode(&guid)); + dlist_setatom(dl, "ATAG", atag); + + buf_free(&content); + free(atag); +} + +static int defaultalarms_save_astate(annotate_state_t *astate, + const char *userid, + icalcomponent *with_time, + icalcomponent *with_date) +{ + struct dlist *root = dlist_newkvlist(NULL, "DEFAULTALARMS"); + set_alarms_dl(root, "WITH_TIME", with_time); + set_alarms_dl(root, "WITH_DATE", with_date); + + struct buf buf = BUF_INITIALIZER; + dlist_printbuf(root, 1, &buf); + + int r = annotate_state_write(astate, + JMAP_ANNOT_DEFAULTALERTS, userid, &buf); + + dlist_free(&root); + buf_free(&buf); + return r; +} + +EXPORTED int defaultalarms_save(struct mailbox *mbox, + const char *userid, + icalcomponent *with_time, + icalcomponent *with_date) +{ + annotate_state_t *astate; + + int r = mailbox_get_annotate_state(mbox, 0, &astate); + if (r) { + xsyslog(LOG_ERR, "failed to get annotation state", + "mboxname=<%s> err=<%s>", + mailbox_name(mbox), error_message(r)); + return CYRUSDB_INTERNAL; + } + + return defaultalarms_save_astate(astate, userid, with_time, with_date); +} + +static int compare_valarm(const void **va, const void **vb) +{ + icalcomponent *a = (icalcomponent*)(*va); + icalcomponent *b = (icalcomponent*)(*vb); + + // Regular alarms sort after snooze alarms + int is_snooze_a = + !!icalcomponent_get_first_property(a, ICAL_RELATEDTO_PROPERTY); + int is_snooze_b = + !!icalcomponent_get_first_property(b, ICAL_RELATEDTO_PROPERTY); + if (is_snooze_a != is_snooze_b) + return -(is_snooze_a - is_snooze_b); + + // Alarms with UID sort after alarms without UID + int has_uid_a = !!icalcomponent_get_uid(a); + int has_uid_b = !!icalcomponent_get_uid(b); + if (has_uid_a != has_uid_b) + return has_uid_a - has_uid_b; + + // Default alarms sort after non-default alarms + int is_default_a = + !!icalcomponent_get_x_property_by_name(a, "X-JMAP-DEFAULT-ALARM"); + int is_default_b = + !!icalcomponent_get_x_property_by_name(b, "X-JMAP-DEFAULT-ALARM"); + if (is_default_a != is_default_b) + return is_default_a - is_default_b; + + // Break ties by UID + return strcmpsafe(icalcomponent_get_uid(a), icalcomponent_get_uid(b)); +} + +static void merge_alarms(icalcomponent *comp, icalcomponent *alarms) +{ + // Remove existing alarms + ptrarray_t old_alarms = PTRARRAY_INITIALIZER; + strarray_t related_uids = STRARRAY_INITIALIZER; + + icalcomponent *valarm, *nextalarm; + for (valarm = icalcomponent_get_first_component(comp, ICAL_VALARM_COMPONENT); + valarm; valarm = nextalarm) { + + nextalarm = icalcomponent_get_next_component(comp, ICAL_VALARM_COMPONENT); + + icalcomponent_remove_component(comp, valarm); + ptrarray_append(&old_alarms, valarm); + + icalproperty *prop = icalcomponent_get_first_property(valarm, ICAL_RELATEDTO_PROPERTY); + if (prop) { + const char *related_uid = icalproperty_get_relatedto(prop); + if (related_uid) + strarray_append(&related_uids, related_uid); + } + } + + // Create copy of new default alarms, if any + ptrarray_t new_alarms = PTRARRAY_INITIALIZER; + if (alarms) { + icalcomponent *valarm; + for (valarm = icalcomponent_get_first_component(alarms, ICAL_VALARM_COMPONENT); + valarm; + valarm = icalcomponent_get_next_component(alarms, ICAL_VALARM_COMPONENT)) { + + icalcomponent *myalarm = icalcomponent_clone(valarm); + ptrarray_append(&new_alarms, myalarm); + + /* Replace default description with component summary */ + const char *desc = icalcomponent_get_summary(comp); + if (desc && *desc != '\0') { + icalproperty *prop = + icalcomponent_get_first_property(myalarm, ICAL_DESCRIPTION_PROPERTY); + if (prop) { + icalcomponent_remove_property(myalarm, prop); + icalproperty_free(prop); + } + prop = icalproperty_new_description(desc); + icalcomponent_add_property(myalarm, prop); + } + } + } + + strarray_sort(&related_uids, cmpstringp_raw); + + // Sort alarms, we'll pop from the arrays later. + ptrarray_sort(&old_alarms, compare_valarm); + ptrarray_sort(&new_alarms, compare_valarm); + + // Combine old and new alarms. All new alarms are default alarms. + icalcomponent *old, *new; + do { + old = ptrarray_pop(&old_alarms); + new = ptrarray_pop(&new_alarms); + + if (new) { + // Add JMAP default alarm + icalcomponent_add_component(comp, new); + if (old) { + const char *old_uid = icalcomponent_get_uid(old); + const char *new_uid = icalcomponent_get_uid(new); + if (!strcmpsafe(old_uid, new_uid)) { + // An alarm with the same UID already + // existed in the component. Use its new + // definition, but keep it acknowledged. + icalproperty *prop, *nextprop; + for (prop = icalcomponent_get_first_property(old, + ICAL_ACKNOWLEDGED_PROPERTY); + prop; prop = nextprop) { + + nextprop = icalcomponent_get_next_property(old, + ICAL_ACKNOWLEDGED_PROPERTY); + icalcomponent_remove_property(old, prop); + icalcomponent_add_property(new, prop); + } + + // Throw away old alarm + icalcomponent_free(old); + old = NULL; + } + } + } + + if (old) { + const char *old_uid = icalcomponent_get_uid(old); + + int is_default = + !!icalcomponent_get_x_property_by_name(old, "X-JMAP-DEFAULT-ALARM"); + + int is_apple = !is_default && + !!icalcomponent_get_x_property_by_name(old, "X-APPLE-DEFAULT-ALARM"); + + int is_snoozed = old_uid && + strarray_contains(&related_uids, old_uid); + + int is_acked = !!icalcomponent_get_first_property(old, + ICAL_ACKNOWLEDGED_PROPERTY); + + int is_snooze = !!icalcomponent_get_first_property(old, + ICAL_RELATEDTO_PROPERTY); + + if (is_default) { + // This is a stale default alarm. + if (is_snoozed) { + // Some snooze alarm refers to this alarm. Keep it. + icalcomponent_add_component(comp, old); + + // Make sure it can't trigger anymore. + icalproperty *trigger = + icalcomponent_get_first_property(old, ICAL_TRIGGER_PROPERTY); + if (trigger) { + // Use Apple's magic 5545 timestamp + struct icaltriggertype expired_trigger = { + .time = { + .year = 1976, + .month = 4, + .day = 1, + .hour = 0, + .minute = 55, + .second = 45, + .zone = icaltimezone_get_utc_timezone() + } + }; + icalproperty_set_trigger(trigger, expired_trigger); + } + + if (!is_acked) { + icalcomponent_add_property(old, + icalproperty_new_acknowledged( + icaltime_current_time_with_zone( + icaltimezone_get_utc_timezone()))); + } + } + else { + // Remove obsolete default alarm + icalcomponent_free(old); + } + } + else if (is_snoozed || is_snooze) { + icalcomponent_add_component(comp, old); + } + else if (is_apple) { + icalcomponent_add_component(comp, old); + } + else icalcomponent_free(old); + } + } while (old || new); + + ptrarray_fini(&old_alarms); + ptrarray_fini(&new_alarms); + strarray_fini(&related_uids); +} + +EXPORTED void defaultalarms_insert(struct defaultalarms *defalarms, + icalcomponent *ical, + int set_atag) +{ + icalcomponent *comp = icalcomponent_get_first_real_component(ical); + icalcomponent_kind kind = icalcomponent_isa(comp); + if (kind != ICAL_VEVENT_COMPONENT && kind != ICAL_VTODO_COMPONENT) + return; + + for ( ; comp; comp = icalcomponent_get_next_component(ical, kind)) { + + if (!icalcomponent_get_usedefaultalerts(comp)) + continue; + + // Remove any atag that was set before + icalcomponent_set_usedefaultalerts(comp, 1, NULL); + + struct defaultalarms_record *rec = icalcomponent_temporal_is_date(comp) ? + &defalarms->with_date : &defalarms->with_time; + + if (set_atag) + icalcomponent_set_usedefaultalerts(comp, 1, rec->atag); + + merge_alarms(comp, rec->ical); + } +} + +EXPORTED int defaultalarms_matches_atag(icalcomponent *comp, const char *atag) +{ + int matches_atag = 0; + + icalcomponent *mycomp = icalcomponent_clone(comp); + icalcomponent_normalize_x(mycomp); + char *myatag = generate_atag(mycomp); + matches_atag = !strcmpsafe(myatag, atag); + icalcomponent_free(mycomp); + free(myatag); + + return matches_atag; +} + +// Migration code starts - this should be required after version 3.9 + +static int migrate39_rewrite_peruser_data(struct mailbox *mbox, + const char *userid, + struct caldav_data *cdata, + const char *annotval, + struct defaultalarms *defalarms, + int *rewrite, + struct buf *buf) +{ + struct icalsupport_personal_data data = { 0 }; + struct buf value = BUF_INITIALIZER; + buf_init_ro_cstr(&value, annotval); + int r = 0; + + if (icalsupport_decode_personal_data(&value, &data)) { + xsyslog(LOG_ERR, "invalid per-user data", + "mboxname=<%s> imap_uid=<%d> userid=<%s>", + mailbox_name(mbox), cdata->dav.imap_uid, userid); + r = CYRUSDB_INTERNAL; + goto done; + } + + if (!data.usedefaultalerts) + goto done; + + int is_date = strlen(cdata->dtstart) == 6; + icalcomponent *alarms = is_date ? + defalarms->with_date.ical : defalarms->with_time.ical; + + if (data.vpatch) { + icalcomponent *patch, *nextpatch; + for (patch = icalcomponent_get_first_component(data.vpatch, + ICAL_ANY_COMPONENT); patch; patch = nextpatch) { + + nextpatch = icalcomponent_get_next_component(data.vpatch, + ICAL_ANY_COMPONENT); + + icalproperty *prop = icalcomponent_get_first_property(patch, + ICAL_PATCHTARGET_PROPERTY); + if (prop) { + const char *tgt = icalproperty_get_patchtarget(prop); + + if (!strncasecmpsafe(tgt, "/VCALENDAR/VEVENT", 17) && + !strchr(&tgt[17], '/') && !strchr(&tgt[17], '#')) { + + icalcomponent_remove_x_property_by_name(patch, + "X-APPLE-DEFAULT-ALARM"); + icalcomponent_remove_x_property_by_name(patch, + "X-JMAP-USEDEFAULTALERTS"); + + if (alarms) { + icalcomponent *valarm; + for (valarm = icalcomponent_get_first_component(alarms, + ICAL_VALARM_COMPONENT); + valarm; + valarm = icalcomponent_get_next_component(alarms, + ICAL_VALARM_COMPONENT)) { + + icalcomponent_add_component(patch, + icalcomponent_clone(valarm)); + } + } + + prop = icalcomponent_get_first_property(patch, ICAL_ANY_PROPERTY); + if ((icalproperty_isa(prop) == ICAL_PATCHTARGET_PROPERTY) && + !icalcomponent_get_next_property(patch, ICAL_ANY_PROPERTY) && + !icalcomponent_get_first_component(patch, ICAL_ANY_COMPONENT)) { + + icalcomponent_remove_component(data.vpatch, patch); + icalcomponent_free(patch); + } + } + } + } + + if (!icalcomponent_get_first_component(data.vpatch, ICAL_ANY_COMPONENT)) { + icalcomponent_free(data.vpatch); + data.vpatch = NULL; + } + + } + + buf_reset(buf); + + if (data.vpatch) { + data.usedefaultalerts = 0; + data.modseq = mbox->i.highestmodseq; + data.lastmod = time(NULL); + + icalcomponent_set_dtstamp(data.vpatch, + icaltime_from_timet_with_zone(time(NULL), 0, + icaltimezone_get_utc_timezone())); + + icalsupport_encode_personal_data(buf, &data); + } + + *rewrite = 1; + +done: + icalsupport_personal_data_fini(&data); + return r; +} + +static int migrate39_find_peruser_cb(const char *mboxname __attribute__((unused)), + uint32_t uid __attribute__((unused)), + const char *entry __attribute__((unused)), + const char *userid, + const struct buf *value, + const struct annotate_metadata *mdata __attribute__((unused)), + void *vrock) +{ + if (buf_len(value)) + hash_insert(userid, xstrdup(buf_cstring(value)), (hash_table*)vrock); + + return 0; +} + +HIDDEN void defaultalarms_migrate39(const mbentry_t *mbentry, + enum defaultalarms_migrate39_flags flags, + json_t **errp) +{ + struct defaultalarms defalarms = DEFAULTALARMS_INITIALIZER; + mbname_t *mbname = mbname_from_intname(mbentry->name); + struct mailbox *mbox = NULL; + const char *ownerid = mbname_userid(mbname); + struct buf buf = BUF_INITIALIZER; + annotate_state_t *astate = NULL; + json_t *err = NULL; + json_t *caldav_alarm_err = json_object(); + json_t *sharee_err = json_object(); + + int r = mailbox_open_iwl(mbentry->name, &mbox); + if (r) { + xsyslog(LOG_ERR, "could not open mailbox", + "mboxname=<%s> err=<%s>", + mbentry->name, cyrusdb_strerror(r)); + err = jmap_server_error(r); + goto done; + } + + // Load default alerts, either from JMAP or CalDAV + r = load_alarms(mailbox_name(mbox), ownerid, 0, &defalarms); + if (r) { + xsyslog(LOG_ERR, "could not load default alarms", + "mboxname=<%s> userid=<%s> err=<%s>", + mailbox_name(mbox), ownerid, cyrusdb_strerror(r)); + err = jmap_server_error(r); + goto done; + } + + // We manage our own annotation state, because switching + // the mailbox annotation state to mailbox scope is gnarly + astate = annotate_state_new(); + if (!astate) { + xsyslog(LOG_ERR, "could not get annotation state", + "mboxname=<%s>", mailbox_name(mbox)); + r = CYRUSDB_INTERNAL; + err = jmap_server_error(r); + goto done; + } + annotate_state_set_mailbox(astate, mbox); + + // Set JMAP default alerts annotation if not already set + r = annotatemore_lookup(mailbox_name(mbox), + JMAP_ANNOT_DEFAULTALERTS, ownerid, &buf); + if (!buf_len(&buf)) { + r = defaultalarms_save_astate(astate, ownerid, + defalarms.with_time.ical, defalarms.with_date.ical); + if (r) { + xsyslog(LOG_ERR, "could not set JMAP default alerts", + "mboxname=<%s> userid=<%s> err=<%s>", + mailbox_name(mbox), ownerid, cyrusdb_strerror(r)); + err = jmap_server_error(r); + goto done; + } + } + + if (!(flags & DEFAULTALARMS_MIGRATE_KEEP_CALDAV_ALARMS)) { + // Remove CalDAV alarms on calendar - they only got set by us + buf_reset(&buf); + + const char *annot = CALDAV_ANNOT_DEFAULTALARM_VEVENT_DATETIME; + r = annotate_state_write(astate, annot, "", &buf); + if (r) { + xsyslog(LOG_ERR, "failed to remove annotation", + "mboxname=<%s> annot=<%s> err=<%s>", + mailbox_name(mbox), annot, cyrusdb_strerror(r)); + json_object_set_new(caldav_alarm_err, "withTime", + jmap_server_error(r)); + } + + annot = CALDAV_ANNOT_DEFAULTALARM_VEVENT_DATE; + r = annotate_state_write(astate, annot, "", &buf); + if (r) { + xsyslog(LOG_ERR, "failed to remove annotation", + "mboxname=<%s> annot=<%s> err=<%s>", + mailbox_name(mbox), annot, cyrusdb_strerror(r)); + + json_object_set_new(caldav_alarm_err, "withoutTime", + jmap_server_error(r)); + } + } + + r = annotate_state_commit(&astate); + if (r) { + xsyslog(LOG_ERR, "could not commit annotation state", + "mboxname=<%s>", mailbox_name(mbox)); + err = jmap_server_error(r); + goto done; + } + + // Migrate sharee alerts + + struct caldav_db *caldav_db = caldav_open_mailbox(mbox); + if (!caldav_db) { + xsyslog(LOG_ERR, "could not open caldav.db", "mboxname=<%s>", + mailbox_name(mbox)); + err = jmap_server_error(CYRUSDB_INTERNAL); + goto done; + } + + struct mailbox_iter *mit = mailbox_iter_init(mbox, 0, ITER_SKIP_UNLINKED); + const message_t *msg; + while ((msg = mailbox_iter_step(mit))) { + hash_table peruser = HASH_TABLE_INITIALIZER; + construct_hash_table(&peruser, 32, 0); + const struct index_record *record = msg_record(msg); + annotatemore_findall_mailbox(mbox, record->uid, PER_USER_CAL_DATA, + 0, migrate39_find_peruser_cb, &peruser, ANNOTATE_TOMBSTONES); + + if (!hash_numrecords(&peruser)) { + free_hash_table(&peruser, free); + continue; + } + + struct caldav_data *cdata; + r = caldav_lookup_imapuid(caldav_db, mbentry, record->uid, &cdata, 1); + if (r) { + if (r == CYRUSDB_NOTFOUND) { + xsyslog(LOG_INFO, "ignoring annotation tombstone in caldav.db", + "mboxname=<%s> imap_uid=<%d>", + mailbox_name(mbox), record->uid); + } + else { + xsyslog(LOG_ERR, "can not load entry from caldav.db", + "mboxname=<%s> imap_uid=<%d> err=<%s>", + mailbox_name(mbox), record->uid, cyrusdb_strerror(r)); + free_hash_table(&peruser, free); + + buf_reset(&buf); + buf_printf(&buf, "%d", record->uid); + json_object_set_new(sharee_err, buf_cstring(&buf), + jmap_server_error(r)); + } + continue; + } + + hash_iter *hit = hash_table_iter(&peruser); + while (hash_iter_next(hit)) { + const char *userid = hash_iter_key(hit); + char *annotval = hash_iter_val(hit); + + if (strcmp(userid, ownerid)) { + int rewrite = 0; + migrate39_rewrite_peruser_data(mbox, userid, cdata, annotval, + &defalarms, &rewrite, &buf); + if (rewrite) { + r = mailbox_annotation_write(mbox, record->uid, + PER_USER_CAL_DATA, userid, &buf); + if (r) { + xsyslog(LOG_ERR, "could not rewrite per-user data", + "mboxname=<%s> imap_uid=<%d> userid=<%s> err=<%s>", + mailbox_name(mbox), record->uid, + userid, error_message(r)); + + buf_reset(&buf); + buf_printf(&buf, "%d/%s", record->uid, userid); + json_object_set_new(sharee_err, buf_cstring(&buf), + jmap_server_error(r)); + } + } + } + } + hash_iter_free(&hit); + + free_hash_table(&peruser, free); + } + + mailbox_iter_done(&mit); + caldav_close(caldav_db); + +done: + if (json_object_size(caldav_alarm_err)) { + if (!err) err = json_object(); + json_object_set_new(err, "caldavAlarmErrors", caldav_alarm_err); + } + else json_decref(caldav_alarm_err); + + if (json_object_size(sharee_err)) { + if (!err) err = json_object(); + json_object_set_new(err, "shareeAlarmErrors", sharee_err); + } + else json_decref(sharee_err); + + *errp = err; + + if (astate) annotate_state_abort(&astate); + defaultalarms_fini(&defalarms); + mailbox_close(&mbox); + mbname_free(&mbname); + buf_free(&buf); +} diff --git a/imap/defaultalarms.h b/imap/defaultalarms.h new file mode 100644 index 0000000000..de8492f321 --- /dev/null +++ b/imap/defaultalarms.h @@ -0,0 +1,94 @@ +/* defaultalarms.h -- functions for dealing with default calendar alarms + * + * Copyright (c) 1994-2021 Carnegie Mellon University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The name "Carnegie Mellon University" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For permission or any legal + * details, please contact + * Carnegie Mellon University + * Center for Technology Transfer and Enterprise Creation + * 4615 Forbes Avenue + * Suite 302 + * Pittsburgh, PA 15213 + * (412) 268-7393, fax: (412) 268-7395 + * innovation@andrew.cmu.edu + * + * 4. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by Computing Services + * at Carnegie Mellon University (http://www.cmu.edu/computing/)." + * + * CARNEGIE MELLON UNIVERSITY DISCLAIMS ALL WARRANTIES WITH REGARD TO + * THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS, IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY BE LIABLE + * FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#ifndef DEFAULTALARMS_H +#define DEFAULTALARMS_H + +#include + +#include "dav_util.h" +#include "mailbox.h" + +#define DEFAULTALARMS_INITIALIZER { \ + {NULL, MESSAGE_GUID_INITIALIZER, NULL}, \ + {NULL, MESSAGE_GUID_INITIALIZER, NULL} \ +} + +struct defaultalarms_record { + icalcomponent *ical; + struct message_guid guid; + char *atag; // a ETag-like tag to detect CalDAV client changes +}; + +struct defaultalarms { + struct defaultalarms_record with_time; + struct defaultalarms_record with_date; +}; + +extern int defaultalarms_load(const char *mboxname, const char *userid, + struct defaultalarms *alarms); + +extern int defaultalarms_save(struct mailbox *mbox, const char *userid, + icalcomponent *with_time, + icalcomponent *with_date); + +extern void defaultalarms_fini(struct defaultalarms *defalarms); + +extern void defaultalarms_insert(struct defaultalarms *defalarms, + icalcomponent *ical, + int set_atag); + +extern int defaultalarms_matches_atag(icalcomponent *comp, const char *atag); + +// Migration functions for Cyrus version 3.9 + +enum defaultalarms_migrate39_flags { + DEFAULTALARMS_MIGRATE_NOFLAG = 0, + DEFAULTALARMS_MIGRATE_KEEP_CALDAV_ALARMS = 1 << 0, +}; + +extern void defaultalarms_migrate39(const mbentry_t *mbentry, + enum defaultalarms_migrate39_flags flags, + json_t **errp); + +#endif /* DEFAULTALARMS_H */ diff --git a/imap/deliver.c b/imap/deliver.c index 3c876b1c5d..d3b32cec50 100644 --- a/imap/deliver.c +++ b/imap/deliver.c @@ -46,6 +46,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -75,9 +76,6 @@ /* generated headers are not necessarily in current directory */ #include "imap/imap_err.h" -extern int optind; -extern char *optarg; - static int logdebug = 0; static struct protstream *deliver_out, *deliver_in; @@ -129,6 +127,9 @@ EXPORTED void fatal(const char* s, int code) prot_printf(deliver_out,"421 4.3.0 deliver: %s\r\n", s); prot_flush(deliver_out); cyrus_done(); + + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + exit(code); } @@ -149,7 +150,7 @@ void pipe_through(struct backend *conn) prot_flush(conn->out); } while (!proxy_check_input(protin, deliver_in, deliver_out, - conn->in, conn->out, 0)); + conn->in, conn->out, PROT_NO_FD, NULL, 0)); /* ok, we're done. */ protgroup_free(protin); @@ -169,7 +170,23 @@ int main(int argc, char **argv) char buf[1024]; char *alt_config = NULL; - while ((opt = getopt(argc, argv, "C:df:r:m:a:F:eE:lqD")) != EOF) { + /* keep this in alphabetical order */ + static const char short_options[] = "C:DE:F:a:def:lm:qr:"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "debug", no_argument, NULL, 'D' }, /* XXX undocumented */ + { "auth-id", required_argument, NULL, 'a' }, + { "lmtp", no_argument, NULL, 'l' }, + { "mailbox", required_argument, NULL, 'm' }, + { "ignore-quota", no_argument, NULL, 'q' }, + { "return-path", required_argument, NULL, 'r' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch(opt) { case 'C': /* alt config file */ alt_config = optarg; diff --git a/imap/dlist.c b/imap/dlist.c index 010cc7a99f..ec9e5b8919 100644 --- a/imap/dlist.c +++ b/imap/dlist.c @@ -55,6 +55,7 @@ #include #include #include +#include #include "global.h" #include "assert.h" @@ -69,6 +70,7 @@ #include "message.h" #include "util.h" #include "prot.h" +#include "xunlink.h" /* generated headers are not necessarily in current directory */ #include "imap/imap_err.h" @@ -92,20 +94,23 @@ static void printfile(struct protstream *out, const struct dlist *dl) f = fopen(dl->sval, "r"); if (!f) { - syslog(LOG_ERR, "IOERROR: Failed to read file %s", dl->sval); + xsyslog(LOG_ERR, "IOERROR: Failed to read file", + "sval=<%s>", dl->sval); prot_printf(out, "NIL"); return; } if (fstat(fileno(f), &sbuf) == -1) { - syslog(LOG_ERR, "IOERROR: Failed to stat file %s", dl->sval); + xsyslog(LOG_ERR, "IOERROR: Failed to stat file", + "sval=<%s>", dl->sval); prot_printf(out, "NIL"); fclose(f); return; } size = sbuf.st_size; if (size != dl->nval) { - syslog(LOG_ERR, "IOERROR: Size mismatch %s (%lu != " MODSEQ_FMT ")", - dl->sval, size, dl->nval); + xsyslog(LOG_ERR, "IOERROR: Size mismatch", + "sval=<%s> len=<%lu> expected=<" MODSEQ_FMT ">", + dl->sval, size, dl->nval); prot_printf(out, "NIL"); fclose(f); return; @@ -117,8 +122,8 @@ static void printfile(struct protstream *out, const struct dlist *dl) message_guid_generate(&guid2, msg_base, msg_len); if (!message_guid_equal(&guid2, dl->gval)) { - syslog(LOG_ERR, "IOERROR: GUID mismatch %s", - dl->sval); + xsyslog(LOG_ERR, "IOERROR: GUID mismatch", + "guid=<%s>", message_guid_encode(dl->gval)); prot_printf(out, "NIL"); fclose(f); map_free(&msg_base, &msg_len); @@ -141,15 +146,15 @@ EXPORTED const char *dlist_reserve_path(const char *part, int isarchive, int isb const struct message_guid *guid) { static char buf[MAX_MAILBOX_PATH]; - const char *base; + const char *base = NULL; /* part must be a configured partition name on this server */ if (isbackup) { base = config_backupstagingpath(); } else { - base = isarchive ? config_archivepartitiondir(part) - : config_partitiondir(part); + if (isarchive) base = config_archivepartitiondir(part); + if (!base) base = config_partitiondir(part); } /* we expect to have a base at this point, so let's assert that */ @@ -162,48 +167,82 @@ EXPORTED const char *dlist_reserve_path(const char *part, int isarchive, int isb /* gotta make sure we can create files */ if (cyrus_mkdir(buf, 0755)) { /* it's going to fail later, but at least this will help */ - syslog(LOG_ERR, "IOERROR: failed to create %s/sync./%lu/ for reserve: %m", - base, (unsigned long)getpid()); + xsyslog(LOG_ERR, "IOERROR: failed to create directory for reserve", + "directory=<%s/sync./%lu/> file=<%s>", + base, (unsigned long) getpid(), buf); + } + + if (config_getswitch(IMAPOPT_DEBUG_LOG_SYNC_PARTITION_CHOICE)) { + xsyslog(LOG_DEBUG, "debug_log_sync_partition_choice: chose reserve path", + "base=<%s> reserve_path=<%s>", + base, buf); } + return buf; } static int reservefile(struct protstream *in, const char *part, struct message_guid *guid, unsigned long size, - int isbackup, const char **fname) + int isarchive, int isbackup, const char **fname) { + static struct message_guid debug_writefail_guid = MESSAGE_GUID_INITIALIZER; FILE *file; char buf[8192+1]; int r = 0; + if (debug_writefail_guid.status == GUID_UNKNOWN) { + const char *guidstr = config_getstring(IMAPOPT_DEBUG_WRITEFAIL_GUID); + if (guidstr) { + if (!message_guid_decode(&debug_writefail_guid, guidstr)) { + xsyslog(LOG_DEBUG, "debug_writefail_guid: ignoring invalid guid", + "guid=<%s>", guidstr); + message_guid_set_null(&debug_writefail_guid); + } + } + else { + message_guid_set_null(&debug_writefail_guid); + } + } + /* XXX - write to a temporary file then move in to place! */ - *fname = dlist_reserve_path(part, /*isarchive*/0, isbackup, guid); + *fname = dlist_reserve_path(part, isarchive, isbackup, guid); /* remove any duplicates if they're still here */ - unlink(*fname); + xunlink(*fname); file = fopen(*fname, "w+"); if (!file) { - syslog(LOG_ERR, - "IOERROR: failed to upload file %s", message_guid_encode(guid)); + xsyslog(LOG_ERR, "IOERROR: failed to upload file", + "guid=<%s>", message_guid_encode(guid)); + r = IMAP_IOERROR; + } + else if (debug_writefail_guid.status == GUID_NONNULL + && message_guid_equal(&debug_writefail_guid, guid)) { + /* no error, but pretend the disk is full */ + fclose(file); + file = NULL; + errno = ENOSPC; + xsyslog(LOG_ERR, "IOERROR: failed to upload file (simulated)", + "guid=<%s>", message_guid_encode(guid)); r = IMAP_IOERROR; - /* Note: we still read the file's data from the wire, - * to avoid losing protocol sync */ } + /* Note: in the case of error we still read the file's data from the wire, + * to avoid losing protocol sync */ /* XXX - calculate sha1 on the fly? */ while (size) { size_t n = prot_read(in, buf, size > 8192 ? 8192 : size); if (!n) { - syslog(LOG_ERR, - "IOERROR: reading message: unexpected end of file"); + xsyslog(LOG_ERR, + "IOERROR: reading message: unexpected end of file", NULL); r = IMAP_IOERROR; break; } size -= n; if (!file) continue; if (fwrite(buf, 1, n, file) != n) { - syslog(LOG_ERR, "IOERROR: writing to file '%s': %m", *fname); + xsyslog(LOG_ERR, "IOERROR: write failed", + "fname=<%s>", *fname); r = IMAP_IOERROR; break; } @@ -220,7 +259,8 @@ static int reservefile(struct protstream *in, const char *part, } if (fsync(fileno(file)) < 0) { - syslog(LOG_ERR, "IOERROR: fsyncing file '%s': %m", *fname); + xsyslog(LOG_ERR, "IOERRROR: fsync failed", + "fname=<%s>", *fname); r = IMAP_IOERROR; goto error; } @@ -232,7 +272,7 @@ static int reservefile(struct protstream *in, const char *part, error: if (file) { fclose(file); - unlink(*fname); + xunlink(*fname); *fname = NULL; } return r; @@ -240,6 +280,32 @@ static int reservefile(struct protstream *in, const char *part, /* DLIST STUFF */ +EXPORTED void dlist_push(struct dlist *parent, struct dlist *child) +{ + assert(!child->next); + + if (parent->head) { + child->next = parent->head; + parent->head = child; + } + else { + parent->head = parent->tail = child; + } +} + +EXPORTED struct dlist *dlist_pop(struct dlist *parent) +{ + struct dlist *child; + + assert(parent->head); + + child = parent->head; + parent->head = parent->head->next; + child->next = NULL; + + return child; +} + EXPORTED void dlist_stitch(struct dlist *parent, struct dlist *child) { assert(!child->next); @@ -382,7 +448,7 @@ void dlist_makeguid(struct dlist *dl, const struct message_guid *guid) if (!dl) return; _dlist_clean(dl); if (guid) { - dl->type = DL_GUID, + dl->type = DL_GUID; dl->gval = xzmalloc(sizeof(struct message_guid)); message_guid_copy(dl->gval, guid); } @@ -523,49 +589,49 @@ static struct dlist *dlist_updatechild(struct dlist *parent, const char *name) return dl; } -struct dlist *dlist_updateatom(struct dlist *parent, const char *name, const char *val) +EXPORTED struct dlist *dlist_updateatom(struct dlist *parent, const char *name, const char *val) { struct dlist *dl = dlist_updatechild(parent, name); dlist_makeatom(dl, val); return dl; } -struct dlist *dlist_updateflag(struct dlist *parent, const char *name, const char *val) +EXPORTED struct dlist *dlist_updateflag(struct dlist *parent, const char *name, const char *val) { struct dlist *dl = dlist_updatechild(parent, name); dlist_makeflag(dl, val); return dl; } -struct dlist *dlist_updatenum64(struct dlist *parent, const char *name, bit64 val) +EXPORTED struct dlist *dlist_updatenum64(struct dlist *parent, const char *name, bit64 val) { struct dlist *dl = dlist_updatechild(parent, name); dlist_makenum64(dl, val); return dl; } -struct dlist *dlist_updatenum32(struct dlist *parent, const char *name, uint32_t val) +EXPORTED struct dlist *dlist_updatenum32(struct dlist *parent, const char *name, uint32_t val) { struct dlist *dl = dlist_updatechild(parent, name); dlist_makenum32(dl, val); return dl; } -struct dlist *dlist_updatedate(struct dlist *parent, const char *name, time_t val) +EXPORTED struct dlist *dlist_updatedate(struct dlist *parent, const char *name, time_t val) { struct dlist *dl = dlist_updatechild(parent, name); dlist_makedate(dl, val); return dl; } -struct dlist *dlist_updatehex64(struct dlist *parent, const char *name, bit64 val) +EXPORTED struct dlist *dlist_updatehex64(struct dlist *parent, const char *name, bit64 val) { struct dlist *dl = dlist_updatechild(parent, name); dlist_makehex64(dl, val); return dl; } -struct dlist *dlist_updatemap(struct dlist *parent, const char *name, +EXPORTED struct dlist *dlist_updatemap(struct dlist *parent, const char *name, const char *val, size_t len) { struct dlist *dl = dlist_updatechild(parent, name); @@ -573,7 +639,7 @@ struct dlist *dlist_updatemap(struct dlist *parent, const char *name, return dl; } -struct dlist *dlist_updateguid(struct dlist *parent, const char *name, +EXPORTED struct dlist *dlist_updateguid(struct dlist *parent, const char *name, const struct message_guid *guid) { struct dlist *dl = dlist_updatechild(parent, name); @@ -581,7 +647,7 @@ struct dlist *dlist_updateguid(struct dlist *parent, const char *name, return dl; } -struct dlist *dlist_updatefile(struct dlist *parent, const char *name, +EXPORTED struct dlist *dlist_updatefile(struct dlist *parent, const char *name, const char *part, const struct message_guid *guid, size_t size, const char *fname) { @@ -680,7 +746,7 @@ EXPORTED void dlist_unlink_files(struct dlist *dl) if (!dl->sval) return; syslog(LOG_DEBUG, "%s: unlinking %s", __func__, dl->sval); - unlink(dl->sval); + xunlink(dl->sval); } EXPORTED void dlist_free(struct dlist **dlp) @@ -829,6 +895,15 @@ struct dlistsax_state { struct buf gbuf; }; +#ifdef HAVE_DECLARE_OPTIMIZE +static int _parseqstring(struct dlistsax_state *s, struct buf *buf) + __attribute__((optimize("-O3"))); +static int _parseliteral(struct dlistsax_state *s, struct buf *buf) + __attribute__((optimize("-O3"))); +static int _parseitem(struct dlistsax_state *s, struct buf *buf) + __attribute__((optimize("-O3"))); +#endif + static int _parseqstring(struct dlistsax_state *s, struct buf *buf) { buf->len = 0; @@ -1034,7 +1109,10 @@ static char next_nonspace(struct protstream *in, char c) return c; } -EXPORTED int dlist_parse(struct dlist **dlp, int parsekey, int isbackup, +/* XXX accumulating a lot of flag arguments here, perhaps we should + * XXX consolidate them into a single flags argument with defined bits + */ +EXPORTED int dlist_parse(struct dlist **dlp, int parsekey, int isarchive, int isbackup, struct protstream *in) { struct dlist *dl = NULL; @@ -1062,7 +1140,7 @@ EXPORTED int dlist_parse(struct dlist **dlp, int parsekey, int isbackup, while (c != ')') { struct dlist *di = NULL; prot_ungetc(c, in); - c = dlist_parse(&di, 0, isbackup, in); + c = dlist_parse(&di, 0, isarchive, isbackup, in); if (di) dlist_stitch(dl, di); c = next_nonspace(in, c); if (c == EOF) goto fail; @@ -1078,7 +1156,7 @@ EXPORTED int dlist_parse(struct dlist **dlp, int parsekey, int isbackup, while (c != ')') { struct dlist *di = NULL; prot_ungetc(c, in); - c = dlist_parse(&di, 1, isbackup, in); + c = dlist_parse(&di, 1, isarchive, isbackup, in); if (di) dlist_stitch(dl, di); c = next_nonspace(in, c); if (c == EOF) goto fail; @@ -1099,7 +1177,7 @@ EXPORTED int dlist_parse(struct dlist **dlp, int parsekey, int isbackup, if (c == '\r') c = prot_getc(in); if (c != '\n') goto fail; if (!message_guid_decode(&tmp_guid, gbuf.s)) goto fail; - if (reservefile(in, pbuf.s, &tmp_guid, size, isbackup, &fname)) goto fail; + if (reservefile(in, pbuf.s, &tmp_guid, size, isarchive, isbackup, &fname)) goto fail; dl = dlist_setfile(NULL, kbuf.s, pbuf.s, &tmp_guid, size, fname); /* file literal */ } @@ -1138,7 +1216,7 @@ EXPORTED int dlist_parse(struct dlist **dlp, int parsekey, int isbackup, EXPORTED int dlist_parse_asatomlist(struct dlist **dlp, int parsekey, struct protstream *in) { - int c = dlist_parse(dlp, parsekey, 0, in); + int c = dlist_parse(dlp, parsekey, 0, 0, in); /* make a list with one item */ if (*dlp && !dlist_isatomlist(*dlp)) { @@ -1158,8 +1236,11 @@ EXPORTED int dlist_parsemap(struct dlist **dlp, int parsekey, int isbackup, struct dlist *dl = NULL; stream = prot_readmap(base, len); - prot_setisclient(stream, 1); /* don't sync literals */ - c = dlist_parse(&dl, parsekey, isbackup, stream); + + /* Allow LITERAL+ - this is silly, but required to parse personal CALDATA */ + prot_setisclient(stream, 1); + + c = dlist_parse(&dl, parsekey, /*isarchive*/ 0, isbackup, stream); prot_free(stream); if (c != EOF) { @@ -1280,7 +1361,7 @@ struct dlist *dlist_getkvchild_bykey(struct dlist *dl, return NULL; } -int dlist_toatom(struct dlist *dl, const char **valp) +EXPORTED int dlist_toatom(struct dlist *dl, const char **valp) { const char *str; size_t len; @@ -1638,3 +1719,20 @@ EXPORTED const char *dlist_lastkey(void) { return lastkey; } + +EXPORTED void dlist_rename(struct dlist *dl, const char *name) +{ + free(dl->name); + dl->name = xstrdup(name); +} + +EXPORTED struct dlist *dlist_copy(const struct dlist *dl) +{ + if (!dl) return NULL; + struct buf buf = BUF_INITIALIZER; + struct dlist *new = NULL; + dlist_printbuf(dl, 1, &buf); + dlist_parsemap(&new, 1, 0, buf_base(&buf), buf_len(&buf)); + buf_free(&buf); + return new; +} diff --git a/imap/dlist.h b/imap/dlist.h index 7de2eb9e5c..80437a4503 100644 --- a/imap/dlist.h +++ b/imap/dlist.h @@ -219,7 +219,7 @@ void dlist_print(const struct dlist *dl, int printkeys, struct protstream *out); void dlist_printbuf(const struct dlist *dl, int printkeys, struct buf *outbuf); -int dlist_parse(struct dlist **dlp, int parsekeys, int isbackup, +int dlist_parse(struct dlist **dlp, int parsekeys, int isarchive, int isbackup, struct protstream *in); int dlist_parse_asatomlist(struct dlist **dlp, int parsekey, struct protstream *in); @@ -231,6 +231,8 @@ typedef int dlistsax_cb_t(int type, struct dlistsax_data *data); int dlist_parsesax(const char *base, size_t len, int parsekey, dlistsax_cb_t *proc, void *rock); +void dlist_push(struct dlist *parent, struct dlist *child); +struct dlist *dlist_pop(struct dlist *parent); void dlist_stitch(struct dlist *parent, struct dlist *child); void dlist_unstitch(struct dlist *parent, struct dlist *child); struct dlist *dlist_splice(struct dlist *parent, int num); @@ -243,6 +245,10 @@ struct dlist *dlist_getchildn(struct dlist *dl, int num); struct dlist *dlist_getkvchild_bykey(struct dlist *dl, const char *key, const char *val); +void dlist_rename(struct dlist *dl, const char *name); + +struct dlist *dlist_copy(const struct dlist *dl); + const char *dlist_lastkey(void); /* print a dlist iteratively rather than recursively */ diff --git a/imap/duplicate.c b/imap/duplicate.c index 0980979f7b..ff8548484d 100644 --- a/imap/duplicate.c +++ b/imap/duplicate.c @@ -255,7 +255,7 @@ static int find_cb(void *rock, const char *key, size_t keylen, r = split_key(key, keylen, &dkey); if (r) return 0; /* ignore broken records */ - /* make sure its a mailbox */ + /* make sure it is a mailbox */ if (dkey.to[0] == '.') return 0; /* grab the mark and uid */ @@ -307,7 +307,7 @@ static int prune_p(void *rock, r = split_key(key, keylen, &dkey); if (r) return 1; /* broken record, want to prune it */ - /* grab the rcpt, make sure its a mailbox and lookup its expire time */ + /* grab the rcpt, make sure it is a mailbox and lookup its expire time */ if (prock->expire_table && dkey.to[0] && dkey.to[0] != '.') { expmark = (time_t *) hash_lookup(dkey.to, prock->expire_table); } diff --git a/imap/fetchnews.c b/imap/fetchnews.c index 8ed00ec478..ecebb26152 100644 --- a/imap/fetchnews.c +++ b/imap/fetchnews.c @@ -45,6 +45,7 @@ #ifdef HAVE_UNISTD_H #include #endif +#include #include #include #include @@ -76,33 +77,26 @@ static int newsrc_dbopen = 0; /* must be called after cyrus_init */ static int newsrc_init(const char *fname, int myflags __attribute__((unused))) { - char buf[1024]; - int r = 0; - - if (r != 0) - syslog(LOG_ERR, "DBERROR: init %s: %s", buf, - cyrusdb_strerror(r)); - else { - char *tofree = NULL; + int r; + char *tofree = NULL; - if (!fname) - fname = config_getstring(IMAPOPT_NEWSRC_DB_PATH); + if (!fname) + fname = config_getstring(IMAPOPT_NEWSRC_DB_PATH); - /* create db file name */ - if (!fname) { - tofree = strconcat(config_dir, FNAME_NEWSRCDB, (char *)NULL); - fname = tofree; - } + /* create db file name */ + if (!fname) { + tofree = strconcat(config_dir, FNAME_NEWSRCDB, (char *)NULL); + fname = tofree; + } - r = cyrusdb_open(DB, fname, CYRUSDB_CREATE, &newsrc_db); - if (r != 0) - syslog(LOG_ERR, "DBERROR: opening %s: %s", fname, - cyrusdb_strerror(r)); - else - newsrc_dbopen = 1; + r = cyrusdb_open(DB, fname, CYRUSDB_CREATE, &newsrc_db); + if (r != 0) + syslog(LOG_ERR, "DBERROR: opening %s: %s", fname, + cyrusdb_strerror(r)); + else + newsrc_dbopen = 1; - free(tofree); - } + free(tofree); return r; } @@ -134,14 +128,14 @@ static void usage(void) int init_net(const char *host, char *port, struct protstream **in, struct protstream **out) { - int sock = -1, err; + int sock = -1; struct addrinfo hints, *res, *res0; memset(&hints, 0, sizeof(hints)); hints.ai_family = PF_UNSPEC; hints.ai_socktype = SOCK_STREAM; hints.ai_protocol = 0; - if ((err = getaddrinfo(host, port, &hints, &res0)) != 0) { + if (getaddrinfo(host, port, &hints, &res0) != 0) { syslog(LOG_ERR, "getaddrinfo(%s, %s) failed: %m", host, port); return -1; } @@ -251,7 +245,6 @@ static int fetch(char *msgid, int bymsgid, int main(int argc, char *argv[]) { - extern char *optarg; int opt; char *alt_config = NULL, *port = "119"; const char *peer = NULL, *server = "localhost", *wildmat = "*"; @@ -264,9 +257,26 @@ int main(int argc, char *argv[]) time_t stamp; strarray_t resp = STRARRAY_INITIALIZER; int newnews = 1; - char *datefmt = "%y%m%d %H%M%S"; - - while ((opt = getopt(argc, argv, "C:s:w:f:a:p:ny")) != EOF) { + int y2k_compliant_date_format = 0; + + /* keep this in alphabetical order */ + static const char short_options[] = "C:a:f:np:s:w:y"; + + static const struct option long_options[] = { + /* n.b. no long option for -C */ + { "auth-id", required_argument, NULL, 'a' }, + { "newsstamp-file", required_argument, NULL, 'f' }, + { "no-newnews", no_argument, NULL, 'n' }, + { "password", required_argument, NULL, 'p' }, + { "server", required_argument, NULL, 's' }, + { "groups", required_argument, NULL, 'w' }, + { "yyyy", no_argument, NULL, 'y' }, + { 0, 0, 0, 0 }, + }; + + while (-1 != (opt = getopt_long(argc, argv, + short_options, long_options, NULL))) + { switch (opt) { case 'C': /* alt config file */ alt_config = optarg; @@ -301,7 +311,7 @@ int main(int argc, char *argv[]) break; case 'y': /* newsserver is y2k compliant */ - datefmt = "%Y%m%d %H%M%S"; + y2k_compliant_date_format = 1; break; default: @@ -417,7 +427,18 @@ int main(int argc, char *argv[]) if (stamp) stamp -= 180; /* adjust back 3 minutes */ ptime = gmtime(&stamp); ptime->tm_isdst = -1; - strftime(buf, sizeof(buf), datefmt, ptime); + + if (y2k_compliant_date_format) { + strftime(buf, sizeof(buf), "%Y%m%d %H%M%S", ptime); + } + else { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wformat-y2k" + /* We know this is not y2k compliant! */ + strftime(buf, sizeof(buf), "%y%m%d %H%M%S", ptime); +#pragma GCC diagnostic pop + } + prot_printf(pout, "NEWNEWS %s %s GMT\r\n", wildmat, buf); if (!prot_fgets(buf, sizeof(buf), pin) || strncmp("230", buf, 3)) { diff --git a/imap/fud.c b/imap/fud.c index 32b1bd052f..2d3489f4c5 100644 --- a/imap/fud.c +++ b/imap/fud.c @@ -96,8 +96,12 @@ static void send_reply(struct sockaddr *sfrom, socklen_t sfromsiz, int status, static int soc = 0; /* inetd (master) has handed us the port as stdin */ +#ifndef MAXLOGNAME #define MAXLOGNAME 16 /* should find out for real */ +#endif +#ifndef MAXDOMNAME #define MAXDOMNAME 20 /* should find out for real */ +#endif static int begin_handling(void) { @@ -153,7 +157,7 @@ int service_init(int argc, char **argv, char **envp) { if (geteuid() == 0) fatal("must run as the Cyrus user", EX_USAGE); - setproctitle_init(argc, argv, envp); + proc_settitle_init(argc, argv, envp); signals_set_shutdown(&shut_down); @@ -173,7 +177,7 @@ int service_main(int argc __attribute__((unused)), int r = 0; /* Set namespace */ - if ((r = mboxname_init_namespace(&fud_namespace, 1)) != 0) { + if ((r = mboxname_init_namespace(&fud_namespace, NAMESPACE_OPTION_ADMIN))) { syslog(LOG_ERR, "%s", error_message(r)); fatal(error_message(r), EX_CONFIG); } @@ -384,11 +388,12 @@ static int handle_request(const char *who, const char *name, r = mailbox_open_irl(intname, &mailbox); if (r) { send_reply(sfrom, sfromsiz, REQ_UNK, who, name, 0, 0, 0); + free(intname); return r; } if (mboxname_isusermailbox(intname, 0)) { - int myrights = cyrus_acl_myrights(mystate, mailbox->acl); + int myrights = cyrus_acl_myrights(mystate, mailbox_acl(mailbox)); if (!(myrights & ACL_USER0)) { auth_freestate(mystate); mailbox_close(&mailbox); @@ -407,7 +412,7 @@ static int handle_request(const char *who, const char *name, struct seen *seendb = NULL; struct seendata sd = SEENDATA_INITIALIZER; r = seen_open(who, 0, &seendb); - if (!r) r = seen_read(seendb, mailbox->uniqueid, &sd); + if (!r) r = seen_read(seendb, mailbox_uniqueid(mailbox), &sd); seen_close(&seendb); if (r) { @@ -476,6 +481,7 @@ EXPORTED void fatal(const char* s, int code) recurse_code = code; syslog(LOG_ERR, "Fatal error: %s", s); + if (code != EX_PROTOCOL && config_fatals_abort) abort(); + shut_down(code); } - diff --git a/imap/global.c b/imap/global.c index 4fcfebb31b..14e15386f3 100644 --- a/imap/global.c +++ b/imap/global.c @@ -66,8 +66,10 @@ #include "charset.h" #include "cyr_lock.h" #include "gmtoff.h" +#include "haproxy.h" #include "iptostring.h" #include "global.h" +#include "ical_support.h" #include "libconfig.h" #include "libcyr_cfg.h" #include "mboxlist.h" @@ -81,6 +83,7 @@ #include "xstrlcpy.h" /* generated headers are not necessarily in current directory */ +#include "imap/http_err.h" #include "imap/imap_err.h" #include "imap/mupdate_err.h" @@ -92,7 +95,7 @@ static enum { static int cyrus_init_nodb = 0; -EXPORTED int in_shutdown = 0; +EXPORTED volatile sig_atomic_t in_shutdown = 0; EXPORTED int config_fulldirhash; /* 0 */ EXPORTED int config_implicitrights; /* "lkxa" */ @@ -113,6 +116,11 @@ EXPORTED const char *config_conversations_db; EXPORTED const char *config_backup_db; EXPORTED int charset_flags; EXPORTED int charset_snippet_flags; +EXPORTED size_t config_search_maxsize; +EXPORTED int config_httpprettytelemetry; +EXPORTED int config_take_globallock; +EXPORTED char *config_skip_userlock; +EXPORTED int haproxy_protocol = 0; static char session_id_buf[MAX_SESSIONID_SIZE]; static int session_id_time = 0; @@ -179,6 +187,21 @@ static void cyrus_modules_done() } } +static void debug_toggled(void) +{ + int logmask = setlogmask(0); /* gets the current log mask */ + + if (config_debug) + logmask |= LOG_MASK(LOG_DEBUG); + else + logmask &= ~LOG_MASK(LOG_DEBUG); + + syslog(LOG_INFO, "debug logging turned %s", + config_debug ? "on" : "off"); + + setlogmask(logmask); +} + /* Called before a cyrus application starts (but after command line parameters * are read) */ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flags, int config_need_data) @@ -189,8 +212,9 @@ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flag int umaskval = 0; int syslog_opts = LOG_PID; const char *facility; + char *ident_buf = NULL; - if(cyrus_init_run != NOT_RUNNING) { + if (cyrus_init_run != NOT_RUNNING) { fatal("cyrus_init called twice!", EX_CONFIG); } else { cyrus_init_run = RUNNING; @@ -202,12 +226,13 @@ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flag syslog_opts |= LOG_PERROR; #endif + initialize_http_error_table(); initialize_imap_error_table(); initialize_mupd_error_table(); /* various things can run our commands with only two file descriptors, e.g. old strace on FreeBSD, * or IPC::Run from Perl. Make sure we don't accidentally reuse low FD numbers */ - while(1) { + while (1) { int fd = open("/dev/null", 0); if (fd == -1) fatal("can't open /dev/null", EX_SOFTWARE); if (fd >= 3) { @@ -216,14 +241,24 @@ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flag } } - if(!ident) - fatal("service name was not specified to cyrus_init", EX_CONFIG); + if (!ident) + fatal("service name was not specified to cyrus_init", EX_SOFTWARE); config_ident = ident; - /* xxx we lose here since we can't have the prefix until we load the - * config file */ - openlog(config_ident, syslog_opts, SYSLOG_FACILITY); + /* If we have the syslog prefix in the environment, set it before reading + * config, so that config read failures get logged with the right prefix! + */ + if ((prefix = getenv("CYRUS_SYSLOG_PREFIX"))) { + ident_buf = strconcat(prefix, "/", ident, NULL); + openlog(ident_buf, syslog_opts, SYSLOG_FACILITY); + } + else { + openlog(config_ident, syslog_opts, SYSLOG_FACILITY); + } + + /* allow toggleable debug logging */ + config_toggle_debug_cb = &debug_toggled; /* Load configuration file. This will set config_dir when it finds it */ config_read(alt_config, config_need_data); @@ -233,29 +268,25 @@ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flag fatal("must run as the Cyrus user", EX_USAGE); } - + /* Now that we have config loaded, we might need to openlog again with + * the configured facility and/or prefix. */ prefix = config_getstring(IMAPOPT_SYSLOG_PREFIX); facility = config_getstring(IMAPOPT_SYSLOG_FACILITY); - - /* Reopen the log with the new prefix, if needed */ if (prefix || facility) { - char *ident_buf; int facnum = facility ? get_facility(facility) : SYSLOG_FACILITY; - if (prefix) - ident_buf = strconcat(prefix, "/", ident, (char *)NULL); - else - ident_buf = xstrdup(ident); + /* The $CYRUS_SYSLOG_PREFIX environment variable takes precedence */ + if (!ident_buf) { + if (prefix) + ident_buf = strconcat(prefix, "/", ident, NULL); + else + ident_buf = xstrdup(ident); + } closelog(); openlog(ident_buf, syslog_opts, facnum); - - /* don't free the openlog() string! */ } - - /* allow debug logging */ - if (!config_debug) - setlogmask(~LOG_MASK(LOG_DEBUG)); + /* Do not free ident_buf, syslog needs it for the life of this process! */ /* Look up default partition */ config_defpartition = config_getstring(IMAPOPT_DEFAULTPARTITION); @@ -307,12 +338,17 @@ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flag charset_flags |= CHARSET_MIME_UTF8; /* Set snippet conversion flags. */ - charset_snippet_flags = CHARSET_SNIPPET; + charset_snippet_flags = CHARSET_KEEPCASE; if (config_getenum(IMAPOPT_SEARCH_ENGINE) != IMAP_ENUM_SEARCH_ENGINE_XAPIAN) { /* All search engines other than Xapian require escaped HTML */ charset_snippet_flags |= CHARSET_ESCAPEHTML; } + /* Configure HTTP */ + config_httpprettytelemetry = config_getswitch(IMAPOPT_HTTPPRETTYTELEMETRY); + + config_search_maxsize = config_getbytesize(IMAPOPT_SEARCH_MAXSIZE, 'K'); + if (!cyrus_init_nodb) { /* lookup the database backends */ config_mboxlist_db = config_getstring(IMAPOPT_MBOXLIST_DB); @@ -340,8 +376,8 @@ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flag config_getswitch(IMAPOPT_SKIPLIST_UNSAFE)); libcyrus_config_setstring(CYRUSOPT_TEMP_PATH, config_getstring(IMAPOPT_TEMP_PATH)); - libcyrus_config_setint(CYRUSOPT_PTS_CACHE_TIMEOUT, - config_getint(IMAPOPT_PTSCACHE_TIMEOUT)); + libcyrus_config_setint(CYRUSOPT_PTS_CACHE_TIMEOUT, /* <-- n.b. still an int */ + config_getduration(IMAPOPT_PTSCACHE_TIMEOUT, 's')); libcyrus_config_setswitch(CYRUSOPT_FULLDIRHASH, config_getswitch(IMAPOPT_FULLDIRHASH)); libcyrus_config_setstring(CYRUSOPT_PTSCACHE_DB, @@ -370,6 +406,8 @@ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flag config_getswitch(IMAPOPT_SQL_USESSL)); libcyrus_config_setswitch(CYRUSOPT_SKIPLIST_ALWAYS_CHECKPOINT, config_getswitch(IMAPOPT_SKIPLIST_ALWAYS_CHECKPOINT)); + libcyrus_config_setswitch(CYRUSOPT_ACL_ADMIN_IMPLIES_WRITE, + config_getswitch(IMAPOPT_ACL_ADMIN_IMPLIES_WRITE)); /* Not until all configuration parameters are set! */ libcyrus_init(); @@ -381,6 +419,24 @@ EXPORTED int cyrus_init(const char *alt_config, const char *ident, unsigned flag debug_locks_longer_than = atof(locktime); } + const char *locked_user = getenv("CYRUS_HAVELOCK_USER"); + if (locked_user) { + config_skip_userlock = xstrdup(locked_user); + } + + const char *locked_global = getenv("CYRUS_HAVELOCK_GLOBAL"); + config_take_globallock = config_getswitch(IMAPOPT_GLOBAL_LOCK); + if (locked_global && config_parse_switch(locked_global)) { + config_take_globallock = 0; + } + +#ifdef HAVE_ICAL + /* Initialize libical */ + ical_support_init(); +#endif + + register_mboxgroups_cb(mboxlist_lookup_usergroups); + return 0; } @@ -630,7 +686,7 @@ EXPORTED int is_userid_anonymous(const char *user) } /* - * acl_ok() checks to see if the the inbox for 'user' grants the 'a' + * acl_ok() checks to see if the inbox for 'user' grants the 'a' * right to 'authstate'. Returns 1 if so, 0 if not. */ /* Note that we do not determine if the mailbox is remote or not */ @@ -767,6 +823,8 @@ EXPORTED void cyrus_done(void) if (!cyrus_init_nodb) libcyrus_done(); + + xzfree(config_skip_userlock); } /* @@ -808,7 +866,7 @@ EXPORTED int shutdown_file(char *buf, int size) } } - free(shutdownfilename); + xzfree(shutdownfilename); if (!buf) { buf = tmpbuf; @@ -889,107 +947,7 @@ EXPORTED void parse_sessionid(const char *str, char *sessionid) EXPORTED int capa_is_disabled(const char *str) { - if (!suppressed_capabilities) return 0; - - return (strarray_find_case(suppressed_capabilities, str, 0) >= 0); -} - - -/* Find a message-id looking thingy in a string. Returns a pointer to the - * alloc'd id and the remaining string is returned in the **loc parameter. - * - * This is a poor-man's way of finding the message-id. We simply look for - * any string having the format "< ... @ ... >" and assume that the mail - * client created a properly formatted message-id. - */ -#define MSGID_SPECIALS "<> @\\" - -EXPORTED char *find_msgid(char *str, char **rem) -{ - char *msgid, *src, *dst, *cp; - - if (!str) return NULL; - - msgid = NULL; - src = str; - - /* find the start of a msgid (don't go past the end of the header) */ - while ((cp = src = strpbrk(src, "<\r")) != NULL) { - - /* check for fold or end of header - * - * Per RFC 2822 section 2.2.3, a long header may be folded by - * inserting CRLF before any WSP (SP and HTAB, per section 2.2.2). - * Any other CRLF is the end of the header. - */ - if (*cp++ == '\r') { - if (*cp++ == '\n' && !(*cp == ' ' || *cp == '\t')) { - /* end of header, we're done */ - break; - } - - /* skip fold (or junk) */ - src++; - continue; - } - - /* see if we have (and skip) a quoted localpart */ - if (*cp == '\"') { - /* find the endquote, making sure it isn't escaped */ - do { - ++cp; cp = strchr(cp, '\"'); - } while (cp && *(cp-1) == '\\'); - - /* no endquote, so bail */ - if (!cp) { - src++; - continue; - } - } - - /* find the end of the msgid */ - if ((cp = strchr(cp, '>')) == NULL) - return NULL; - - /* alloc space for the msgid */ - dst = msgid = (char*) xrealloc(msgid, cp - src + 2); - - *dst++ = *src++; - - /* quoted string */ - if (*src == '\"') { - src++; - while (*src != '\"') { - if (*src == '\\') { - src++; - } - *dst++ = *src++; - } - src++; - } - /* atom */ - else { - while (!strchr(MSGID_SPECIALS, *src)) - *dst++ = *src++; - } - - if (*src != '@' || *(dst-1) == '<') continue; - *dst++ = *src++; - - /* domain atom */ - while (!strchr(MSGID_SPECIALS, *src)) - *dst++ = *src++; - - if (*src != '>' || *(dst-1) == '@') continue; - *dst++ = *src++; - *dst = '\0'; - - if (rem) *rem = src; - return msgid; - } - - if (msgid) free(msgid); - return NULL; + return strarray_contains_case(suppressed_capabilities, str); } /* @@ -1000,13 +958,14 @@ EXPORTED const char *get_clienthost(int s, const char **localip, const char **re { #define IPBUF_SIZE (NI_MAXHOST+NI_MAXSERV+2) socklen_t salen; - struct sockaddr_storage localaddr, remoteaddr; + struct sockaddr_storage localaddr = { 0 }, remoteaddr = { 0 }; struct sockaddr *localsock = (struct sockaddr *)&localaddr; struct sockaddr *remotesock = (struct sockaddr *)&remoteaddr; static struct buf clientbuf = BUF_INITIALIZER; static char lipbuf[IPBUF_SIZE], ripbuf[IPBUF_SIZE]; char hbuf[NI_MAXHOST]; int niflags; + int r = 0; buf_reset(&clientbuf); *localip = *remoteip = NULL; @@ -1014,7 +973,16 @@ EXPORTED const char *get_clienthost(int s, const char **localip, const char **re /* determine who we're talking to */ salen = sizeof(struct sockaddr_storage); - if (getpeername(s, remotesock, &salen) == 0 && + + if (haproxy_protocol && haproxy_read_hdr(s, localsock, remotesock) != 0) { + fatal("unable to read HAProxy protocol header", EX_IOERR); + } + + if (remotesock->sa_family == PF_UNSPEC) { + r = getpeername(s, remotesock, &salen); + } + + if (r == 0 && (remotesock->sa_family == AF_INET || remotesock->sa_family == AF_INET6)) { /* connected to an internet socket */ @@ -1035,7 +1003,12 @@ EXPORTED const char *get_clienthost(int s, const char **localip, const char **re buf_printf(&clientbuf, "[%s]", hbuf); salen = sizeof(struct sockaddr_storage); - if (getsockname(s, localsock, &salen) == 0) { + + if (localsock->sa_family == PF_UNSPEC) { + r = getsockname(s, localsock, &salen); + } + + if (r == 0) { /* set the ip addresses here */ if (iptostring(localsock, salen, lipbuf, sizeof(lipbuf)) == 0) { @@ -1049,7 +1022,7 @@ EXPORTED const char *get_clienthost(int s, const char **localip, const char **re fatal("can't get local addr", EX_SOFTWARE); } } else { - /* we're not connected to a internet socket! */ + /* we're not connected to an internet socket! */ buf_setcstr(&clientbuf, UNIX_SOCKET); } diff --git a/imap/global.h b/imap/global.h index 84a7c25f9b..7a350c3a69 100644 --- a/imap/global.h +++ b/imap/global.h @@ -50,6 +50,7 @@ #include "mboxname.h" #include "signals.h" #include "imapparse.h" +#include "libcyr_cfg.h" #include "util.h" #ifdef HAVE_SSL @@ -150,7 +151,6 @@ struct saslprops_t { /* Misc utils */ extern int shutdown_file(char *buf, int size); -extern char *find_msgid(char *, char **); #define UNIX_SOCKET "[unix socket]" extern const char *get_clienthost(int s, const char **localip, const char **remoteip); @@ -160,7 +160,7 @@ extern int saslprops_set_tls(struct saslprops_t *saslprops, sasl_conn_t *saslconn); /* Misc globals */ -extern int in_shutdown; +extern volatile sig_atomic_t in_shutdown; extern int config_fulldirhash; extern int config_implicitrights; extern unsigned long config_metapartition_files; @@ -178,8 +178,13 @@ extern const char *config_userdeny_db; extern const char *config_zoneinfo_db; extern const char *config_conversations_db; extern const char *config_backup_db; +extern int config_take_globallock; +extern char *config_skip_userlock; +extern int config_httpprettytelemetry; extern int charset_flags; extern int charset_snippet_flags; +extern size_t config_search_maxsize; +extern int haproxy_protocol; /* Session ID */ extern void session_new_id(void); diff --git a/imap/haproxy.c b/imap/haproxy.c new file mode 100644 index 0000000000..1727be595f --- /dev/null +++ b/imap/haproxy.c @@ -0,0 +1,156 @@ +/* haproxy.c -- HAProxy protocol functions. + * + * XXX This code is mostly lifted directly from: + * + * https://github.com/haproxy/haproxy/blob/master/doc/proxy-protocol.txt + */ + +#include + +#include +#include +#include +#include +#include +#include + +#include "haproxy.h" + +/* returns 0 on success, <0 upon error */ +EXPORTED int haproxy_read_hdr(int s, struct sockaddr *to, struct sockaddr *from) +{ + const char v2sig[12] = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"; + + union { + struct { + char line[108]; + } v1; + struct { + uint8_t sig[12]; + uint8_t ver_cmd; + uint8_t fam; + uint16_t len; + union { + struct { /* for TCP/UDP over IPv4, len = 12 */ + uint32_t src_addr; + uint32_t dst_addr; + uint16_t src_port; + uint16_t dst_port; + } ip4; + struct { /* for TCP/UDP over IPv6, len = 36 */ + uint8_t src_addr[16]; + uint8_t dst_addr[16]; + uint16_t src_port; + uint16_t dst_port; + } ip6; + struct { /* for AF_UNIX sockets, len = 216 */ + uint8_t src_addr[108]; + uint8_t dst_addr[108]; + } unx; + } addr; + } v2; + } hdr; + + int size, ret; + + do { + ret = recv(s, &hdr, sizeof(hdr), MSG_PEEK); + } while (ret == -1 && errno == EINTR); + + if (ret == -1) + return -1; + + if (ret >= 16 && memcmp(&hdr.v2, v2sig, 12) == 0 && + (hdr.v2.ver_cmd & 0xF0) == 0x20) { + size = 16 + ntohs(hdr.v2.len); + if (ret < size) + return -1; /* truncated or too large header */ + + switch (hdr.v2.ver_cmd & 0xF) { + case 0x01: /* PROXY command */ + switch (hdr.v2.fam) { + case 0x11: /* TCPv4 */ + ((struct sockaddr_in *) from)->sin_family = AF_INET; + ((struct sockaddr_in *) from)->sin_addr.s_addr = + hdr.v2.addr.ip4.src_addr; + ((struct sockaddr_in *) from)->sin_port = + hdr.v2.addr.ip4.src_port; + ((struct sockaddr_in *) to)->sin_family = AF_INET; + ((struct sockaddr_in *) to)->sin_addr.s_addr = + hdr.v2.addr.ip4.dst_addr; + ((struct sockaddr_in *) to)->sin_port = + hdr.v2.addr.ip4.dst_port; + goto done; + case 0x21: /* TCPv6 */ + ((struct sockaddr_in6 *) from)->sin6_family = AF_INET6; + memcpy(&((struct sockaddr_in6 *) from)->sin6_addr, + hdr.v2.addr.ip6.src_addr, 16); + ((struct sockaddr_in6 *) from)->sin6_port = + hdr.v2.addr.ip6.src_port; + ((struct sockaddr_in6 *) to)->sin6_family = AF_INET6; + memcpy(&((struct sockaddr_in6 *) to)->sin6_addr, + hdr.v2.addr.ip6.dst_addr, 16); + ((struct sockaddr_in6 *) to)->sin6_port = + hdr.v2.addr.ip6.dst_port; + goto done; + } + /* unsupported protocol, keep local connection address */ + break; + case 0x00: /* LOCAL command */ + /* keep local connection address for LOCAL */ + break; + default: + return -1; /* not a supported command */ + } + } + else if (ret >= 8 && memcmp(hdr.v1.line, "PROXY", 5) == 0) { + char *end = memchr(hdr.v1.line, '\r', ret - 1); + if (!end || end[1] != '\n') + return -1; /* partial or invalid header */ + *end = '\0'; /* terminate the string to ease parsing */ + size = end + 2 - hdr.v1.line; /* skip header + CRLF */ + /* parse the V1 header using favorite address parsers like inet_pton. + * return -1 upon error, or simply fall through to accept. + */ + char *proto = NULL, lipbuf[NI_MAXHOST], ripbuf[NI_MAXHOST]; + uint16_t lport, rport; + int n = sscanf(hdr.v1.line, "PROXY %ms %s %s %hu %hu", + &proto, ripbuf, lipbuf, &rport, &lport); + if (n >= 1 && !strcmp(proto, "UNKNOWN")) { + ret = 1; + } + else if (n == 5 && !strcmp(proto, "TCP4")) { + ((struct sockaddr_in *) from)->sin_family = AF_INET; + ((struct sockaddr_in *) from)->sin_port = rport; + ret = inet_pton(AF_INET, ripbuf, + &((struct sockaddr_in *) from)->sin_addr); + } + else if (n == 5 && !strcmp(proto, "TCP6")) { + ((struct sockaddr_in6 *) from)->sin6_family = AF_INET6; + ((struct sockaddr_in6 *) from)->sin6_port = rport; + ret = inet_pton(AF_INET6, ripbuf, + &((struct sockaddr_in6 *) from)->sin6_addr); + } + else { + ret = -1; + } + free(proto); + + if (ret != 1) { + /* invalid header */ + return -1; + } + } + else { + /* Wrong protocol */ + return -1; + } + + done: + /* we need to consume the appropriate amount of data from the socket */ + do { + ret = recv(s, &hdr, size, 0); + } while (ret == -1 && errno == EINTR); + + return (ret != size ? -1 : 0); +} diff --git a/imap/haproxy.h b/imap/haproxy.h new file mode 100644 index 0000000000..f6bb1a6aa5 --- /dev/null +++ b/imap/haproxy.h @@ -0,0 +1,10 @@ +/* haproxy.h -- Header for HAProxy protocol functions. */ + +#ifndef INCLUDED_HAPROXY_H +#define INCLUDED_HAPROXY_H + +#include + +extern int haproxy_read_hdr(int s, struct sockaddr *to, struct sockaddr *from); + +#endif /* INCLUDED_HAPROXY_H */ diff --git a/imap/http_admin.c b/imap/http_admin.c index e617985a1d..539b29c748 100644 --- a/imap/http_admin.c +++ b/imap/http_admin.c @@ -58,11 +58,14 @@ #include #include +#include "lib/times.h" +#include "cyr_qsort_r.h" #include "global.h" #include "httpd.h" #include "http_proxy.h" #include "../master/masterconf.h" #include "proc.h" +#include "procinfo.h" #include "proxy.h" #include "ptrarray.h" #include "time.h" @@ -95,7 +98,7 @@ struct namespace_t namespace_admin = { http_allow_noauth_get, /*authschemes*/0, /*mbtype*/0, ALLOW_READ, - admin_init, NULL, NULL, NULL, NULL, NULL, + admin_init, NULL, NULL, NULL, NULL, { { NULL, NULL }, /* ACL */ { NULL, NULL }, /* BIND */ @@ -115,6 +118,7 @@ struct namespace_t namespace_admin = { { NULL, NULL }, /* PROPPATCH */ { NULL, NULL }, /* PUT */ { NULL, NULL }, /* REPORT */ + { NULL, NULL }, /* SEARCH */ { &meth_trace, NULL }, /* TRACE */ { NULL, NULL }, /* UNBIND */ { NULL, NULL } /* UNLOCK */ @@ -227,7 +231,7 @@ static int action_murder(struct transaction_t *txn) /* Add HTML header */ buf_reset(&resp); buf_printf_markup(&resp, level, HTML_DOCTYPE); - buf_printf_markup(&resp, level++, ""); + buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level, "%s", "Available Backend Servers"); @@ -253,7 +257,7 @@ static int action_murder(struct transaction_t *txn) and the config file size/mtime */ assert(!buf_len(&txn->buf)); stat(config_filename, &sbuf); - buf_printf(&txn->buf, "%ld-%ld-%ld", (long) compile_time, + buf_printf(&txn->buf, TIME_T_FMT "-" TIME_T_FMT "-" OFF_T_FMT, compile_time, sbuf.st_mtime, sbuf.st_size); message_guid_generate(&guid, buf_cstring(&txn->buf), buf_len(&txn->buf)); @@ -292,7 +296,7 @@ static int action_murder(struct transaction_t *txn) buf_reset(&resp); buf_printf_markup(&resp, level, HTML_DOCTYPE); - buf_printf_markup(&resp, level++, ""); + buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level, "%s", "Available Backend Servers"); @@ -339,7 +343,7 @@ static int action_menu(struct transaction_t *txn) * Extend this to include config file size/mtime if we add run-time options. */ assert(!buf_len(&txn->buf)); - buf_printf(&txn->buf, "%ld", (long) compile_time); + buf_printf(&txn->buf, TIME_T_FMT, compile_time); message_guid_generate(&guid, buf_cstring(&txn->buf), buf_len(&txn->buf)); etag = message_guid_encode(&guid); @@ -373,7 +377,7 @@ static int action_menu(struct transaction_t *txn) buf_reset(&resp); buf_printf_markup(&resp, level, HTML_DOCTYPE); - buf_printf_markup(&resp, level++, ""); + buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level, "%s", actions[0].desc); buf_printf_markup(&resp, --level, ""); @@ -404,193 +408,16 @@ static int action_menu(struct transaction_t *txn) } -struct proc_info { - pid_t pid; - char *servicename; - char *user; - char *host; - char *mailbox; - char *cmdname; - char state; - time_t start; - unsigned long vmsize; -}; - -typedef struct { - unsigned count; - unsigned alloc; - struct proc_info **data; -} piarray_t; - -static int add_procinfo(pid_t pid, - const char *servicename, const char *host, - const char *user, const char *mailbox, - const char *cmdname, - void *rock) -{ - piarray_t *piarray = (piarray_t *) rock; - struct proc_info *pinfo; - char procpath[100]; - struct stat sbuf; - FILE *f; - - snprintf(procpath, sizeof(procpath), "/proc/%d", pid); - if (stat(procpath, &sbuf)) return 0; - - if (piarray->count >= piarray->alloc) { - piarray->alloc += 100; - piarray->data = xrealloc(piarray->data, - piarray->alloc * sizeof(struct proc_info *)); - } - - pinfo = piarray->data[piarray->count++] = - (struct proc_info *) xzmalloc(sizeof(struct proc_info)); - pinfo->pid = pid; - pinfo->servicename = xstrdupsafe(servicename); - pinfo->host = xstrdupsafe(host); - pinfo->user = xstrdupsafe(user); - pinfo->mailbox = xstrdupsafe(mailbox); - pinfo->cmdname = xstrdupsafe(cmdname); - - strlcat(procpath, "/stat", sizeof(procpath)); - f = fopen(procpath, "r"); - if (f) { - int d; - long ld; - unsigned u; - unsigned long vmsize = 0, lu; - unsigned long long starttime = 0; - char state = 0, *s = NULL; - - int c = fscanf(f, "%d %ms %c " /* 1-3 */ - "%d %d %d %d %d %u " /* 4-9 */ - "%lu %lu %lu %lu %lu %lu " /* 10-15 */ - "%ld %ld %ld %ld %ld %ld " /* 16-21 */ - "%llu %lu %ld", /* 22-24 */ - &d, &s, &state, - &d, &d, &d, &d, &d, &u, - &lu, &lu, &lu, &lu, &lu, &lu, - &ld, &ld, &ld, &ld, &ld, &ld, - &starttime, &vmsize, &ld); - - free(s); - fclose(f); - - if (c != EOF) { - pinfo->state = state; - pinfo->vmsize = vmsize; - pinfo->start = starttime/sysconf(_SC_CLK_TCK); - } - } - - return 0; -} - -#if defined(_GNU_SOURCE) && defined (__GLIBC__) && \ - ((__GLIBC__ > 2) || ((__GLIBC__ == 2) && (__GLIBC_MINOR__ >=0))) -#define HAVE_GLIBC_QSORT_R -#endif - -#if defined(__NEWLIB__) && \ - ((__NEWLIB__ > 2) || ((__NEWLIB__ == 2) && (__NEWLIB_MINOR__ >= 2))) -#if defined(_GNU_SOURCE) -#define HAVE_GLIBC_QSORT_R -#else -#define HAVE_BSD_QSORT_R -#endif -#endif - -#if !defined(HAVE_GLIBC_QSORT_R) && \ - (defined(__FreeBSD__) || defined(__DragonFly__) || defined(__APPLE__)) -#define HAVE_BSD_QSORT_R -#endif - -#ifdef HAVE_BSD_QSORT_R -#define QSORT_R_COMPAR_ARGS(a,b,c) (c,a,b) -#define cyr_qsort_r(base, nmemb, size, compar, thunk) qsort_r(base, nmemb, size, thunk, compar) -#else -#define QSORT_R_COMPAR_ARGS(a,b,c) (a,b,c) -# if defined(HAVE_GLIBC_QSORT_R) -#define cyr_qsort_r(base, nmemb, size, compar, thunk) qsort_r(base, nmemb, size, compar, thunk) -# elif defined(__GNUC__) -static void cyr_qsort_r(void *base, size_t nmemb, size_t size, - int (*compar)(const void *, const void *, void *), - void *thunk) -{ - int compar_func(const void *a, const void *b) - { - return compar(a, b, thunk); - } - qsort(base, nmemb, size, compar_func); -} -# else -# error No qsort_r support -# endif -#endif - -static int sort_procinfo QSORT_R_COMPAR_ARGS( - const void *pa, const void *pb, - void *k) -{ - int r; - const struct proc_info **a = (const struct proc_info**)pa; - const struct proc_info **b = (const struct proc_info**)pb; - char *key = (char*)k; - int rev = islower((int) *key); - - switch (toupper((int) *key)) { - default: - case 'P': - r = (*a)->pid - (*b)->pid; - break; - - case 'S': - r = strcmp((*a)->servicename, (*b)->servicename); - break; - - case 'Q': - r = (*a)->state - (*b)->state; - break; - - case 'T': - r = (*a)->start - (*b)->start; - break; - - case 'V': - r = (*a)->vmsize - (*b)->vmsize; - break; - - case 'H': - r = strcmp((*a)->host, (*b)->host); - break; - - case 'U': - r = strcmp((*a)->user, (*b)->user); - break; - - case 'R': - r = strcmp((*a)->mailbox, (*b)->mailbox); - break; - - case 'C': - r = strcmp((*a)->cmdname, (*b)->cmdname); - break; - } - - return (rev ? -r : r); -} - /* Perform a proc action */ static int action_proc(struct transaction_t *txn) { unsigned level = 0, i; struct buf *body = &txn->resp_body.payload; - piarray_t piarray = { 0, 0, NULL }; - time_t now = time(0), boot_time = 0; + piarray_t piarray; + time_t now = time(0); struct strlist *param; struct tm tnow; char key = 0; - FILE *f; struct proc_columns { char key; const char *name; @@ -636,18 +463,7 @@ static int action_proc(struct transaction_t *txn) columns[0].key = 'p'; } - /* Find boot time in /proc/stat (needed for calculating process start) */ - f = fopen("/proc/stat", "r"); - if (f) { - char buf[1024]; - - while (fgets(buf, sizeof(buf), f)) { - if (sscanf(buf, "btime %ld\n", &boot_time) == 1) break; - while (buf[strlen(buf)-1] != '\n' && fgets(buf, sizeof(buf), f)) { - } - } - fclose(f); - } + init_piarray(&piarray); /* Get and sort info for running processes */ proc_foreach(add_procinfo, &piarray); @@ -658,7 +474,7 @@ static int action_proc(struct transaction_t *txn) /* Send HTML header */ buf_reset(body); buf_printf_markup(body, level, HTML_DOCTYPE); - buf_printf_markup(body, level++, ""); + buf_printf_markup(body, level++, ""); buf_printf_markup(body, level++, ""); buf_printf_markup(body, level, "", "Refresh", "1"); @@ -691,49 +507,29 @@ static int action_proc(struct transaction_t *txn) buf_printf_markup(body, level, "%d", (int) pinfo->pid); buf_printf_markup(body, level, "%s", pinfo->servicename); - if (pinfo->vmsize) { - const char *proc_states[] = { - /* A */ "", /* B */ "", /* C */ "", - /* D */ " (waiting)", - /* E */ "", /* F */ "", /* G */ "", /* H */ "", /* I */ "", - /* J */ "", /* K */ "", /* L */ "", /* M */ "", /* N */ "", - /* O */ "", /* P */ "", /* Q */ "", - /* R */ " (running)", - /* S */ " (sleeping)", - /* T */ " (stopped)", - /* U */ "", /* V */ "", - /* W */ " (paging)", - /* X */ "", /* Y */ "", - /* Z */ " (zombie)" - }; - const char *monthname[] = { - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" - }; - - buf_printf_markup(body, level, "%c%s", pinfo->state, - isupper((int) pinfo->state) ? - proc_states[pinfo->state - 'A'] : ""); - - if (boot_time) { - struct tm start; - - pinfo->start += boot_time; - localtime_r(&pinfo->start, &start); - if (start.tm_yday != tnow.tm_yday) { - buf_printf_markup(body, level, "%s %02d", - monthname[start.tm_mon], start.tm_mday); - } - else { - buf_printf_markup(body, level, "%02d:%02d", - start.tm_hour, start.tm_min); - } + buf_printf_markup(body, level, "%s", pinfo->state); + + if (pinfo->start) { + struct tm start; + + localtime_r(&pinfo->start, &start); + if (start.tm_yday != tnow.tm_yday) { + buf_printf_markup(body, level, "%s %02d", + monthname[start.tm_mon], start.tm_mday); + } + else { + buf_printf_markup(body, level, "%02d:%02d", + start.tm_hour, start.tm_min); } - else buf_printf_markup(body, level, ""); - + } else { + buf_printf_markup(body, level, ""); + } + + if (pinfo->vmsize) { buf_printf_markup(body, level, "%lu", pinfo->vmsize/1024); + } else { + buf_printf_markup(body, level, ""); } - else buf_printf_markup(body, level, ""); buf_printf_markup(body, level, "%s", pinfo->host); buf_printf_markup(body, level, "%s", pinfo->user); @@ -748,7 +544,8 @@ static int action_proc(struct transaction_t *txn) free(pinfo->cmdname); free(pinfo); } - free(piarray.data); + + deinit_piarray(&piarray); /* Finish table */ buf_printf_markup(body, --level, ""); @@ -837,7 +634,7 @@ static int action_df(struct transaction_t *txn) and the config file size/mtime */ assert(!buf_len(&txn->buf)); stat(config_filename, &sbuf); - buf_printf(&txn->buf, "%ld-%ld-%ld", (long) compile_time, + buf_printf(&txn->buf, TIME_T_FMT "-" TIME_T_FMT "-" OFF_T_FMT, compile_time, sbuf.st_mtime, sbuf.st_size); message_guid_generate(&guid, buf_cstring(&txn->buf), buf_len(&txn->buf)); @@ -873,7 +670,7 @@ static int action_df(struct transaction_t *txn) buf_reset(&resp); buf_printf_markup(&resp, level, HTML_DOCTYPE); - buf_printf_markup(&resp, level++, ""); + buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level, "%s", actions[2].desc); buf_printf_markup(&resp, --level, ""); @@ -1169,7 +966,7 @@ static int action_conf(struct transaction_t *txn) and the config file size/mtime */ assert(!buf_len(&txn->buf)); stat(config_filename, &sbuf); - buf_printf(&txn->buf, "%ld-%ld-%ld", (long) compile_time, + buf_printf(&txn->buf, TIME_T_FMT "-" TIME_T_FMT "-" OFF_T_FMT, compile_time, sbuf.st_mtime, sbuf.st_size); message_guid_generate(&guid, buf_cstring(&txn->buf), buf_len(&txn->buf)); @@ -1210,7 +1007,7 @@ static int action_conf(struct transaction_t *txn) buf_reset(&resp); buf_printf_markup(&resp, level, HTML_DOCTYPE); - buf_printf_markup(&resp, level++, ""); + buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level++, ""); buf_printf_markup(&resp, level, "%s", actions[3].desc); buf_printf_markup(&resp, --level, ""); diff --git a/imap/http_applepush.c b/imap/http_applepush.c index c14d7247d7..4337f0af50 100644 --- a/imap/http_applepush.c +++ b/imap/http_applepush.c @@ -62,7 +62,7 @@ struct namespace_t namespace_applepush = { http_allow_noauth_get, /*authschemes*/0, /*mbtype*/0, ALLOW_READ|ALLOW_POST, - &applepush_init, NULL, NULL, NULL, NULL, NULL, + &applepush_init, NULL, NULL, NULL, NULL, { { NULL, NULL }, /* ACL */ { NULL, NULL }, /* BIND */ @@ -82,6 +82,7 @@ struct namespace_t namespace_applepush = { { NULL, NULL }, /* PROPPATCH */ { NULL, NULL }, /* PUT */ { NULL, NULL }, /* REPORT */ + { NULL, NULL }, /* SEARCH */ { NULL, NULL }, /* TRACE */ { NULL, NULL }, /* UNBIND */ { NULL, NULL } /* UNLOCK */ @@ -141,7 +142,7 @@ static int meth_get_applepush(struct transaction_t *txn, } /* mailbox must be calendar or addressbook */ - mbtype = mbentry->mbtype; + mbtype = mbtype_isa(mbentry->mbtype); if (mbtype != MBTYPE_CALENDAR && mbtype != MBTYPE_ADDRESSBOOK) goto done; @@ -153,12 +154,12 @@ static int meth_get_applepush(struct transaction_t *txn, goto done; } - aps_topic = config_getstring(mbtype == MBTYPE_CALENDAR ? + aps_topic = config_getstring(mbtype_isa(mbtype) == MBTYPE_CALENDAR ? IMAPOPT_APS_TOPIC_CALDAV : IMAPOPT_APS_TOPIC_CARDDAV); if (!aps_topic) { syslog(LOG_ERR, "aps_topic_%s not configured, can't subscribe", - mbtype == MBTYPE_CALENDAR ? "caldav" : "carddav"); + mbtype_isa(mbtype) == MBTYPE_CALENDAR ? "caldav" : "carddav"); goto done; } @@ -168,7 +169,7 @@ static int meth_get_applepush(struct transaction_t *txn, struct mboxevent *mboxevent = mboxevent_new(EVENT_APPLEPUSHSERVICE_DAV); mboxevent_set_applepushservice_dav(mboxevent, aps_topic, token, httpd_userid, mailbox_userid, mailbox_uniqueid, mbtype, - 86400); // XXX interval from config + config_getduration(IMAPOPT_APS_EXPIRY, 'd')); mboxevent_notify(&mboxevent); mboxevent_free(&mboxevent); @@ -287,7 +288,7 @@ int propfind_pushkey(const xmlChar *name, xmlNsPtr ns, /* key is userid and mailbox uniqueid */ buf_reset(&fctx->buf); buf_printf(&fctx->buf, "%s/%s", - fctx->req_tgt->userid, fctx->mailbox->uniqueid); + fctx->req_tgt->userid, mailbox_uniqueid(fctx->mailbox)); xml_add_prop(HTTP_OK, fctx->ns[NS_DAV], &propstat[PROPSTAT_OK], name, ns, BAD_CAST buf_cstring(&fctx->buf), 0); diff --git a/imap/http_caldav.c b/imap/http_caldav.c index 52d34927ef..83476fee1d 100644 --- a/imap/http_caldav.c +++ b/imap/http_caldav.c @@ -48,7 +48,6 @@ * calendars. * - Support COPY/MOVE on collections * - Add more required properties? - * - calendar-query REPORT (handle timezone, timezone-id) * - free-busy-query REPORT (check ACL and transp on all calendars) * - sync-collection REPORT - need to handle Depth infinity? */ @@ -66,12 +65,13 @@ #include "acl.h" #include "append.h" #include "caldav_db.h" +#include "caldav_util.h" #include "charset.h" #include "css3_color.h" +#include "defaultalarms.h" #include "global.h" #include "hash.h" #include "httpd.h" -#include "http_caldav.h" #include "http_caldav_sched.h" #include "http_dav.h" #include "http_dav_sharing.h" @@ -79,6 +79,7 @@ #include "index.h" #include "ical_support.h" #include "jmap_ical.h" +#include "jmap_notif.h" #include "jcal.h" #include "xcal.h" #include "map.h" @@ -86,12 +87,12 @@ #include "mboxlist.h" #include "message.h" #include "message_guid.h" +#include "msgrecord.h" #include "proxy.h" #include "times.h" #include "spool.h" #include "strhash.h" -#include "stristr.h" -#include "tok.h" +#include "user.h" #include "util.h" #include "version.h" #include "webdav_db.h" @@ -106,11 +107,6 @@ #include "imap/http_err.h" #include "imap/imap_err.h" -#define TZ_STRIP (1<<9) - -#define SHARED_MODSEQ \ - DAV_ANNOT_NS "<" XML_NS_CYRUS ">shared-modseq" - #ifdef HAVE_RSCALE #include @@ -123,14 +119,13 @@ static int rscale_cmp(const void *a, const void *b) #endif /* HAVE_RSCALE */ -static struct caldav_db *auth_caldavdb = NULL; static time_t compile_time; static struct buf ical_prodid_buf = BUF_INITIALIZER; +static int64_t icalendar_max_size; unsigned config_allowsched = IMAP_ENUM_CALDAV_ALLOWSCHEDULING_OFF; const char *ical_prodid = NULL; icaltimezone *utc_zone = NULL; -struct strlist *cua_domains = NULL; icalarray *rscale_calendars = NULL; struct partial_comp_t { @@ -159,10 +154,6 @@ static unsigned long caldav_allow_cb(struct request_target_t *tgt); static int caldav_parse_path(const char *path, struct request_target_t *tgt, const char **resultstr); -static int caldav_get_validators(struct mailbox *mailbox, void *data, - const char *userid, struct index_record *record, - const char **etag, time_t *lastmod); - static modseq_t caldav_get_modseq(struct mailbox *mailbox, void *data, const char *userid); @@ -179,7 +170,10 @@ static int caldav_delete_cal(struct transaction_t *txn, struct mailbox *mailbox, struct index_record *record, void *data); static int caldav_get(struct transaction_t *txn, struct mailbox *mailbox, - struct index_record *record, void *data, void **obj); + struct index_record *record, void *data, void **obj, + struct mime_type_t *mime); + +static int caldav_mkcol(struct mailbox *mailbox); static int caldav_post(struct transaction_t *txn); static int caldav_patch(struct transaction_t *txn, void *obj); static int caldav_put(struct transaction_t *txn, void *obj, @@ -228,10 +222,18 @@ static int propfind_scheddefault(const xmlChar *name, xmlNsPtr ns, struct propfind_ctx *fctx, xmlNodePtr prop, xmlNodePtr resp, struct propstat propstat[], void *rock); +static int proppatch_scheddefault(xmlNodePtr prop, unsigned set, + struct proppatch_ctx *pctx, + struct propstat propstat[], + void *rock __attribute__((unused))); static int propfind_schedtag(const xmlChar *name, xmlNsPtr ns, struct propfind_ctx *fctx, xmlNodePtr prop, xmlNodePtr resp, struct propstat propstat[], void *rock); +static int propfind_caltransp(const xmlChar *name, xmlNsPtr ns, + struct propfind_ctx *fctx, + xmlNodePtr prop, xmlNodePtr resp, + struct propstat propstat[], void *rock); static int proppatch_caltransp(xmlNodePtr prop, unsigned set, struct proppatch_ctx *pctx, struct propstat propstat[], void *rock); @@ -268,8 +270,17 @@ static int propfind_sharingmodes(const xmlChar *name, xmlNsPtr ns, struct propfind_ctx *fctx, xmlNodePtr prop, xmlNodePtr resp, struct propstat propstat[], void *rock); - -static void strip_vtimezones(icalcomponent *ical); +static int propfind_caldav_alarms(const xmlChar *name, xmlNsPtr ns, + struct propfind_ctx *fctx, + xmlNodePtr prop, xmlNodePtr resp, + struct propstat propstat[], void *rock); +static int propfind_shareesactas(const xmlChar *name, xmlNsPtr ns, + struct propfind_ctx *fctx, + xmlNodePtr prop, xmlNodePtr resp, + struct propstat propstat[], void *rock); +static int proppatch_shareesactas(xmlNodePtr prop, unsigned set, + struct proppatch_ctx *pctx, + struct propstat propstat[], void *rock); static int report_cal_query(struct transaction_t *txn, struct meth_params *rparams, @@ -313,11 +324,9 @@ static struct mime_type_t caldav_mime_types[] = { }; static struct patch_doc_t caldav_patch_docs[] = { -#ifdef HAVE_VPATCH { ICALENDAR_CONTENT_TYPE "; component=VPATCH; optinfo=\"PATCH-VERSION:1\"", &caldav_patch }, -#endif - { NULL, &caldav_patch /* silence compiler when !HAVE_VPATCH */} + { NULL, &caldav_patch } }; /* Array of supported REPORTs */ @@ -500,10 +509,15 @@ static const struct prop_entry caldav_props[] = { propfind_schedtag, NULL, NULL }, { "schedule-default-calendar-URL", NS_CALDAV, PROP_COLLECTION, - propfind_scheddefault, NULL, NULL }, + propfind_scheddefault, proppatch_scheddefault, NULL }, { "schedule-calendar-transp", NS_CALDAV, PROP_COLLECTION | PROP_PERUSER, - propfind_fromdb, proppatch_caltransp, NULL }, + propfind_caltransp, proppatch_caltransp, NULL }, + + /* CalDAV Sharing (draft-pot-caldav-sharing) properties */ + { "calendar-user-address-set", NS_CALDAV, + PROP_COLLECTION | PROP_PERUSER, + propfind_caluseraddr, proppatch_caluseraddr, NULL }, /* Calendar Availability (RFC 7953) properties */ { "calendar-availability", NS_CALDAV, @@ -551,6 +565,20 @@ static const struct prop_entry caldav_props[] = { PROP_COLLECTION, propfind_pushkey, NULL, NULL }, + /* Apple Default Alarm properties */ + { "default-alarm-vevent-datetime", NS_CALDAV, + PROP_COLLECTION | PROP_PERUSER, + propfind_caldav_alarms, proppatch_todb_nomask, NULL }, + { "default-alarm-vevent-date", NS_CALDAV, + PROP_COLLECTION | PROP_PERUSER, + propfind_caldav_alarms, proppatch_todb_nomask, NULL }, + + + /* JMAP calendar properties */ + { "sharees-act-as", NS_JMAPCAL, + PROP_COLLECTION, + propfind_shareesactas, proppatch_shareesactas, NULL }, + { NULL, 0, 0, NULL, NULL, NULL } }; @@ -576,7 +604,7 @@ static struct meth_params caldav_params = { { CALDAV_UID_CONFLICT, &caldav_copy }, &caldav_delete_cal, &caldav_get, - { CALDAV_LOCATION_OK, MBTYPE_CALENDAR }, + { CALDAV_LOCATION_OK, MBTYPE_CALENDAR, &caldav_mkcol }, caldav_patch_docs, { POST_ADDMEMBER | POST_SHARE, &caldav_post, { NS_CALDAV, "calendar-data", &caldav_import } }, @@ -592,15 +620,11 @@ struct namespace_t namespace_calendar = { http_allow_noauth_get, /*authschemes*/0, MBTYPE_CALENDAR, (ALLOW_READ | ALLOW_POST | ALLOW_WRITE | ALLOW_DELETE | -#ifdef HAVE_VPATCH ALLOW_PATCH | ALLOW_USERDATA | -#endif -#ifdef HAVE_VAVAILABILITY ALLOW_CAL_AVAIL | -#endif ALLOW_DAV | ALLOW_PROPPATCH | ALLOW_MKCOL | ALLOW_ACL | ALLOW_CAL ), &my_caldav_init, &my_caldav_auth, my_caldav_reset, &my_caldav_shutdown, - &dav_premethod, /*bearer*/NULL, + &dav_premethod, { { &meth_acl, &caldav_params }, /* ACL */ { NULL, NULL }, /* BIND */ @@ -620,6 +644,7 @@ struct namespace_t namespace_calendar = { { &meth_proppatch, &caldav_params }, /* PROPPATCH */ { &meth_put, &caldav_params }, /* PUT */ { &meth_report, &caldav_params }, /* REPORT */ + { NULL, NULL }, /* SEARCH */ { &meth_trace, &caldav_parse_path }, /* TRACE */ { NULL, NULL }, /* UNBIND */ { &meth_unlock, &caldav_params } /* UNLOCK */ @@ -633,7 +658,7 @@ struct namespace_t namespace_freebusy = { http_allow_noauth_get, /*authschemes*/0, MBTYPE_CALENDAR, ALLOW_READ, - NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, { { NULL, NULL }, /* ACL */ { NULL, NULL }, /* BIND */ @@ -668,12 +693,8 @@ static const struct cal_comp_t { { "VTODO", CAL_COMP_VTODO }, { "VJOURNAL", CAL_COMP_VJOURNAL }, { "VFREEBUSY", CAL_COMP_VFREEBUSY }, -#ifdef HAVE_VAVAILABILITY { "VAVAILABILITY", CAL_COMP_VAVAILABILITY }, -#endif -#ifdef HAVE_VPOLL { "VPOLL", CAL_COMP_VPOLL }, -#endif // { "VTIMEZONE", CAL_COMP_VTIMEZONE }, // { "VALARM", CAL_COMP_VALARM }, { NULL, 0 } @@ -682,11 +703,6 @@ static const struct cal_comp_t { static void my_caldav_init(struct buf *serverinfo) { - const char *domains; - char *domain; - tok_t tok; - - buf_printf(serverinfo, " SQLite/%s", sqlite3_libversion()); buf_printf(serverinfo, " LibiCal/%s", ICAL_VERSION); #ifdef HAVE_RSCALE if ((rscale_calendars = icalrecurrencetype_rscale_supported_calendars())) { @@ -695,7 +711,6 @@ static void my_caldav_init(struct buf *serverinfo) buf_printf(serverinfo, " ICU4C/%s", U_ICU_VERSION); } #endif - buf_printf(serverinfo, " Jansson/%s", JANSSON_VERSION); namespace_calendar.enabled = config_httpmodules & IMAP_ENUM_HTTPMODULES_CALDAV; @@ -706,7 +721,6 @@ static void my_caldav_init(struct buf *serverinfo) fatal("Required 'calendarprefix' option is not set", EX_CONFIG); } -#ifdef HAVE_IANA_PARAMS config_allowsched = config_getenum(IMAPOPT_CALDAV_ALLOWSCHEDULING); if (config_allowsched) { namespace_calendar.allow |= ALLOW_CAL_SCHED; @@ -718,13 +732,18 @@ static void my_caldav_init(struct buf *serverinfo) if (config_getswitch(IMAPOPT_CALDAV_ALLOWATTACH)) namespace_calendar.allow |= ALLOW_CAL_ATTACH; -#endif /* HAVE_IANA_PARAMS */ + if (config_getswitch(IMAPOPT_CALDAV_ACCEPT_INVALID_RRULES)) { +#ifdef HAVE_INVALID_RRULE_HANDLING + ical_set_invalid_rrule_handling_setting(ICAL_RRULE_IGNORE_INVALID); +#else + syslog(LOG_WARNING, + "Your version of libical can not accept invalid RRULEs"); +#endif + } -#ifdef HAVE_TZ_BY_REF if (namespace_tzdist.enabled) { namespace_calendar.allow |= ALLOW_CAL_NOTZ; } -#endif caldav_init(); webdav_init(); @@ -743,137 +762,10 @@ static void my_caldav_init(struct buf *serverinfo) "-//CyrusIMAP.org/Cyrus %s//EN", CYRUS_VERSION); ical_prodid = buf_cstring(&ical_prodid_buf); - /* Create an array of calendar-user-address-set domains */ - domains = config_getstring(IMAPOPT_CALENDAR_USER_ADDRESS_SET); - if (!domains) domains = config_defdomain; - if (!domains) domains = config_servername; - - tok_init(&tok, domains, " \t", TOK_TRIMLEFT|TOK_TRIMRIGHT); - while ((domain = tok_next(&tok))) appendstrlist(&cua_domains, domain); - tok_fini(&tok); - utc_zone = icaltimezone_get_utc_timezone(); -} - -static int _create_mailbox(const char *userid, const char *mailboxname, - int type, int useracl, int anyoneacl, - const char *displayname) -{ - int r = 0; - char rights[100]; - struct mailbox *mailbox = NULL; - - r = mboxlist_lookup(mailboxname, NULL, NULL); - if (!r) return 0; - if (r != IMAP_MAILBOX_NONEXISTENT) return r; - - /* Create locally */ - r = mboxlist_createmailbox(mailboxname, type, - NULL, 0, - userid, httpd_authstate, - 0, 0, 0, 0, displayname ? &mailbox : NULL); - if (!r && displayname) { - annotate_state_t *astate = NULL; - - r = mailbox_get_annotate_state(mailbox, 0, &astate); - if (!r) { - const char *annot = DAV_ANNOT_NS "<" XML_NS_DAV ">displayname"; - struct buf value = BUF_INITIALIZER; - - buf_init_ro_cstr(&value, displayname); - r = annotate_state_writemask(astate, annot, userid, &value); - } - - mailbox_close(&mailbox); - } - if (!r && useracl) { - cyrus_acl_masktostr(useracl, rights); - r = mboxlist_setacl(&httpd_namespace, mailboxname, userid, rights, - 1, httpd_userid, httpd_authstate); - } - if (!r && anyoneacl) { - cyrus_acl_masktostr(anyoneacl, rights); - r = mboxlist_setacl(&httpd_namespace, mailboxname, "anyone", rights, - 1, httpd_userid, httpd_authstate); - } - - if (r) syslog(LOG_ERR, "IOERROR: failed to create %s (%s)", - mailboxname, error_message(r)); - return r; -} - -int caldav_create_defaultcalendars(const char *userid) -{ - int r; - char *mailboxname; - struct buf acl = BUF_INITIALIZER; - - /* calendar-home-set */ - mailboxname = caldav_mboxname(userid, NULL); - r = mboxlist_lookup(mailboxname, NULL, NULL); - if (r == IMAP_MAILBOX_NONEXISTENT) { - /* Find location of INBOX */ - char *inboxname = mboxname_user_mbox(userid, NULL); - mbentry_t *mbentry = NULL; - - r = http_mlookup(inboxname, &mbentry, NULL); - free(inboxname); - if (r == IMAP_MAILBOX_NONEXISTENT) r = IMAP_INVALID_USER; - if (!r && mbentry->server) { - proxy_findserver(mbentry->server, &http_protocol, httpd_userid, - &backend_cached, NULL, NULL, httpd_in); - mboxlist_entry_free(&mbentry); - free(mailboxname); - return r; - } - mboxlist_entry_free(&mbentry); - - if (!r) r = _create_mailbox(userid, mailboxname, MBTYPE_CALENDAR, - ACL_ALL | DACL_READFB, DACL_READFB, NULL); - } - - free(mailboxname); - if (r) goto done; - - if (config_getswitch(IMAPOPT_CALDAV_CREATE_DEFAULT)) { - /* Default calendar */ - mailboxname = caldav_mboxname(userid, SCHED_DEFAULT); - r = _create_mailbox(userid, mailboxname, MBTYPE_CALENDAR, - ACL_ALL | DACL_READFB, DACL_READFB, "personal"); - free(mailboxname); - if (r) goto done; - } - - if (config_getswitch(IMAPOPT_CALDAV_CREATE_SCHED) && - namespace_calendar.allow & ALLOW_CAL_SCHED) { - /* Scheduling Inbox */ - mailboxname = caldav_mboxname(userid, SCHED_INBOX); - r = _create_mailbox(userid, mailboxname, MBTYPE_CALENDAR, - ACL_ALL | DACL_SCHED, DACL_SCHED, NULL); - free(mailboxname); - if (r) goto done; - - /* Scheduling Outbox */ - mailboxname = caldav_mboxname(userid, SCHED_OUTBOX); - r = _create_mailbox(userid, mailboxname, MBTYPE_CALENDAR, - ACL_ALL | DACL_SCHED, 0, NULL); - free(mailboxname); - if (r) goto done; - } - - if (config_getswitch(IMAPOPT_CALDAV_CREATE_ATTACH) && - namespace_calendar.allow & ALLOW_CAL_ATTACH) { - /* Managed Attachment Collection */ - mailboxname = caldav_mboxname(userid, MANAGED_ATTACH); - r = _create_mailbox(userid, mailboxname, MBTYPE_COLLECTION, - ACL_ALL, ACL_READ, NULL); - free(mailboxname); - if (r) goto done; - } - done: - buf_free(&acl); - return r; + icalendar_max_size = config_getbytesize(IMAPOPT_ICALENDAR_MAX_SIZE, 'B'); + if (icalendar_max_size <= 0) icalendar_max_size = BYTESIZE_UNLIMITED; } static int my_caldav_auth(const char *userid) @@ -883,25 +775,34 @@ static int my_caldav_auth(const char *userid) /* admin or proxy from frontend - won't have DAV database */ return 0; } + if (config_mupdate_server && !config_getstring(IMAPOPT_PROXYSERVERS)) { /* proxy-only server - won't have DAV database */ return 0; } - else { - /* Open CalDAV DB for 'userid' */ - my_caldav_reset(); - auth_caldavdb = caldav_open_userid(userid); - if (!auth_caldavdb) { - syslog(LOG_ERR, "Unable to open CalDAV DB for userid %s", userid); - return HTTP_UNAVAILABLE; - } - } /* Auto-provision calendars for 'userid' */ - int r = caldav_create_defaultcalendars(userid); + mbentry_t *mbentry = NULL; + int r = caldav_create_defaultcalendars(userid, &httpd_namespace, + httpd_authstate, &mbentry); + if (r == IMAP_MAILBOX_NONEXISTENT && mbentry && mbentry->server) { + /* Force creation of default calendars on backend */ + proxy_findserver(mbentry->server, &http_protocol, httpd_userid, + &backend_cached, NULL, NULL, httpd_in); + } + mboxlist_entry_free(&mbentry); + if (r) { syslog(LOG_ERR, "could not autoprovision calendars for userid %s: %s", userid, error_message(r)); + if (r == IMAP_INVALID_USER) { + /* We successfully authenticated, but don't have a user INBOX. + Assume that the user has yet to be fully provisioned, + or the user is being renamed. + */ + return HTTP_UNAVAILABLE; + } + return HTTP_SERVER_ERROR; } @@ -910,8 +811,7 @@ static int my_caldav_auth(const char *userid) static void my_caldav_reset(void) { - if (auth_caldavdb) caldav_close(auth_caldavdb); - auth_caldavdb = NULL; + // nothing to do } static void my_caldav_shutdown(void) @@ -921,9 +821,6 @@ static void my_caldav_shutdown(void) buf_free(&ical_prodid_buf); - freestrlist(cua_domains); - cua_domains = NULL; - my_caldav_reset(); webdav_done(); caldav_done(); @@ -1018,106 +915,6 @@ static int caldav_parse_path(const char *path, struct request_target_t *tgt, return 0; } - -#define STRIP_OWNER_CAL_DATA \ - "CALDATA %(VPATCH {248+}\r\n" \ - "BEGIN:VPATCH\r\n" \ - "VERSION:1\r\n" \ - "DTSTAMP:19760401T005545Z\r\n" \ - "UID:strip-owner-cal-data\r\n" \ - "BEGIN:PATCH\r\n" \ - "PATCH-TARGET:/VCALENDAR/ANY\r\n" \ - "PATCH-DELETE:/VALARM\r\n" \ - "PATCH-DELETE:#TRANSP\r\n" \ - "PATCH-DELETE:#X-MOZ-LASTACK\r\n" \ - "PATCH-DELETE:#X-MOZ-SNOOZE-TIME\r\n" \ - "END:PATCH\r\n" \ - "END:VPATCH\r\n)" - - -static int is_personalized(struct mailbox *mailbox, - const struct caldav_data *cdata, - const char *userid, struct buf *userdata) -{ - if (cdata->comp_flags.shared) { - /* Lookup per-user calendar data */ - int r = mailbox_get_annotate_state(mailbox, cdata->dav.imap_uid, NULL); - - if (!r) { - mbname_t *mbname = NULL; - - if (mailbox->i.options & OPT_IMAP_SHAREDSEEN) { - /* No longer using per-user-data - use owner data */ - mbname = mbname_from_intname(mailbox->name); - userid = mbname_userid(mbname); - } - - r = mailbox_annotation_lookup(mailbox, cdata->dav.imap_uid, - PER_USER_CAL_DATA, userid, userdata); - mbname_free(&mbname); - } - - if (!r && buf_len(userdata)) return 1; - buf_free(userdata); - } - else if (!(mailbox->i.options & OPT_IMAP_SHAREDSEEN) && - !mboxname_userownsmailbox(userid, mailbox->name)) { - buf_init_ro_cstr(userdata, STRIP_OWNER_CAL_DATA); - return 1; - } - - return 0; -} - - -static int caldav_get_validators(struct mailbox *mailbox, void *data, - const char *userid, struct index_record *record, - const char **etag, time_t *lastmod) -{ - - const struct caldav_data *cdata = (const struct caldav_data *) data; - struct buf userdata = BUF_INITIALIZER; - - int r = dav_get_validators(mailbox, data, userid, record, etag, lastmod); - if (r) return r; - - if ((namespace_calendar.allow & ALLOW_USERDATA) && - cdata->dav.imap_uid && cdata->comp_flags.shared && - is_personalized(mailbox, cdata, userid, &userdata)) { - struct dlist *dl; - - /* Parse the userdata and fetch the validators */ - dlist_parsemap(&dl, 1, 0, buf_base(&userdata), buf_len(&userdata)); - - if (etag) { - char buf[2*MESSAGE_GUID_SIZE]; - struct message_guid *user_guid; - - dlist_getguid(dl, "GUID", &user_guid); - - /* Per-user ETag is GUID of concatenated GUIDs */ - message_guid_export(&record->guid, buf); - message_guid_export(user_guid, buf+MESSAGE_GUID_SIZE); - message_guid_generate(user_guid, buf, sizeof(buf)); - *etag = message_guid_encode(user_guid); - } - if (lastmod) { - time_t user_lastmod; - - dlist_getdate(dl, "LASTMOD", &user_lastmod); - - /* Per-user Last-Modified is latest mod time */ - *lastmod = MAX(record->internaldate, user_lastmod); - } - - dlist_free(&dl); - buf_free(&userdata); - } - - return 0; -} - - static modseq_t caldav_get_modseq(struct mailbox *mailbox, void *data, const char *userid) { @@ -1127,7 +924,7 @@ static modseq_t caldav_get_modseq(struct mailbox *mailbox, if ((namespace_calendar.allow & ALLOW_USERDATA) && cdata->comp_flags.shared && - is_personalized(mailbox, cdata, userid, &userdata)) { + caldav_is_personalized(mailbox, cdata, userid, &userdata)) { modseq_t shared_modseq = cdata->dav.modseq; struct dlist *dl; int r; @@ -1152,6 +949,98 @@ static modseq_t caldav_get_modseq(struct mailbox *mailbox, return modseq; } +static int proppatch_scheddefault(xmlNodePtr prop, unsigned set, + struct proppatch_ctx *pctx, + struct propstat propstat[], + void *rock __attribute__((unused))) +{ + /* Only allow PROPPATCH on CALDAV:schedule-inbox-URL */ + if ((pctx->txn->req_tgt.flags != TGT_SCHED_INBOX) || !set) { + xml_add_prop(HTTP_FORBIDDEN, pctx->ns[NS_DAV], &propstat[PROPSTAT_FORBID], + prop->name, prop->ns, NULL, DAV_PROT_PROP); + *pctx->ret = HTTP_FORBIDDEN; + return HTTP_FORBIDDEN; + } + + /* Validate property value */ + int precond = CALDAV_VALID_DEFAULT; + char *href = NULL; + mbname_t *mbname = NULL; + + xmlNodePtr node = xmlFirstElementChild(prop); + if (node) { + if (!xmlStrcmp(node->name, BAD_CAST "href")) { + href = (char*) xmlNodeGetContent(node); + if (href && *href) { + /* Strip trailing '/' character */ + size_t len = strlen(href); + if (len > 1 && href[len-1] == '/') { + href[len-1] = '\0'; + } + } + } + } + + if (href) { + buf_reset(&pctx->buf); + if (strchr(httpd_userid, '@') || !httpd_extradomain) { + buf_printf(&pctx->buf, "%s/%s/%s/", namespace_calendar.prefix, + USER_COLLECTION_PREFIX, httpd_userid); + } + else { + buf_printf(&pctx->buf, "%s/%s/%s@%s/", namespace_calendar.prefix, + USER_COLLECTION_PREFIX, httpd_userid, httpd_extradomain); + } + if (!strncmp(href, buf_cstring(&pctx->buf), buf_len(&pctx->buf))) { + const char *cal = href + buf_len(&pctx->buf); + if (cal) { + char *mboxname = caldav_mboxname(httpd_userid, cal); + if (mboxname_iscalendarmailbox(mboxname, 0) && + mboxname_policycheck(mboxname) == 0) { + mbname = mbname_from_intname(mboxname); + } + free(mboxname); + } + } + } + + if (mbname) { + char *calhomename = caldav_mboxname(httpd_userid, NULL); + struct mailbox *calhome = NULL; + struct mailbox *mailbox = NULL; + int r = mailbox_open_iwl(calhomename, &calhome); + if (!r) r = mailbox_open_iwl(mbname_intname(mbname), &mailbox); + if (!r) { + annotate_state_t *astate = NULL; + r = mailbox_get_annotate_state(calhome, 0, &astate); + if (!r) { + const char *annotname = + DAV_ANNOT_NS "<" XML_NS_CALDAV ">schedule-default-calendar"; + + const strarray_t *boxes = mbname_boxes(mbname); + buf_setcstr(&pctx->buf, strarray_nth(boxes, strarray_size(boxes)-1)); + r = annotate_state_writemask(astate, annotname, httpd_userid, &pctx->buf); + } + } + mailbox_close(&mailbox); + mailbox_close(&calhome); + free(calhomename); + if (!r) precond = 0; + } + + if (precond) { + xml_add_prop(HTTP_FORBIDDEN, pctx->ns[NS_DAV], &propstat[PROPSTAT_FORBID], + prop->name, prop->ns, NULL, precond); + } + else { + xml_add_prop(HTTP_OK, pctx->ns[NS_DAV], &propstat[PROPSTAT_OK], + prop->name, prop->ns, NULL, 0); + } + + mbname_free(&mbname); + free(href); + return precond ? HTTP_FORBIDDEN : 0; +} /* Check headers for any preconditions */ static int caldav_check_precond(struct transaction_t *txn, @@ -1162,11 +1051,47 @@ static int caldav_check_precond(struct transaction_t *txn, const struct caldav_data *cdata = (const struct caldav_data *) data; const char *stag = cdata && cdata->organizer ? cdata->sched_tag : NULL; const char **hdr; - int precond; + int precond = 0; + + if (txn->meth == METH_DELETE) { + if (!cdata) { + /* Must not delete default scheduling calendar */ + char *defaultname = caldav_scheddefault(httpd_userid, 0); + if (defaultname) { + char *defaultmboxname = caldav_mboxname(httpd_userid, defaultname); + if (!strcmp(mailbox_name(mailbox), defaultmboxname)) { + precond = HTTP_FORBIDDEN; + txn->error.precond = CALDAV_DEFAULT_NEEDED; + } + free(defaultmboxname); + free(defaultname); + } + if (precond) return precond; + } + else { + int rights = httpd_myrights(httpd_authstate, txn->req_tgt.mbentry); + if (!(rights & DACL_RMRSRC) && (rights & DACL_WRITEOWNRSRC)) { + /* User may delete events with no organizer or where + * they are organizer. */ + if (cdata->organizer) { + strarray_t schedule_addresses = STRARRAY_INITIALIZER; + caldav_get_schedule_addresses(txn->req_hdrs, + txn->req_tgt.mbentry->name, + txn->req_tgt.userid, + &schedule_addresses); + if (!strarray_contains(&schedule_addresses, cdata->organizer)) { + precond = HTTP_FORBIDDEN; + } + strarray_fini(&schedule_addresses); + if (precond) return precond; + } + } + } + } /* Do normal WebDAV/HTTP checks (primarily for lock-token via If header) */ precond = dav_check_precond(txn, params, mailbox, data, etag, lastmod); - if (precond == HTTP_PRECOND_FAILED && + if (precond == HTTP_PRECOND_FAILED && cdata && cdata->comp_flags.tzbyref && !cdata->organizer && cdata->sched_tag) { /* Resource has just had VTIMEZONEs stripped - check if conditional matches previous ETag */ @@ -1282,7 +1207,7 @@ static int _scheduling_enabled(struct transaction_t *txn, struct buf buf = BUF_INITIALIZER; int is_enabled = 1; - annotatemore_lookupmask(mailbox->name, entry, httpd_userid, &buf); + annotatemore_lookupmask_mbox(mailbox, entry, "", &buf); /* legacy */ if (!strcasecmp(buf_cstring(&buf), "no")) is_enabled = 0; @@ -1339,7 +1264,7 @@ static int caldav_copy(struct transaction_t *txn, void *obj, /* XXX - set calendar-user-address based on original message? */ /* XXX - get createdmodseq from source */ r = caldav_store_resource(txn, ical, dest_mbox, dest_rsrc, 0, - db, flags, httpd_userid, NULL); + db, flags, httpd_userid, NULL, NULL, NULL); return r; } @@ -1357,16 +1282,21 @@ enum { REFCNT_INC = 1 }; +struct update_rock { + struct mailbox *attachments; + struct webdav_db *webdavdb; +}; + static void update_refcount(const char *mid, short *op, - struct mailbox *attachments) + struct update_rock *urock) { switch (*op) { case REFCNT_DEC: - decrement_refcount(mid, attachments, attachments->local_webdav); + decrement_refcount(mid, urock->attachments, urock->webdavdb); break; case REFCNT_INC: - increment_refcount(mid, attachments->local_webdav); + increment_refcount(mid, urock->webdavdb); break; } } @@ -1386,10 +1316,10 @@ static int open_attachments(const char *userid, struct mailbox **attachments, } else { /* Open the WebDAV DB corresponding to the attachments collection */ - *webdavdb = mailbox_open_webdav(*attachments); + *webdavdb = webdav_open_mailbox(*attachments); if (!*webdavdb) { syslog(LOG_ERR, - "webdav_open_mailbox(%s) failed", (*attachments)->name); + "webdav_open_mailbox(%s) failed", mailbox_name(*attachments)); ret = HTTP_SERVER_ERROR; } } @@ -1400,17 +1330,16 @@ static int open_attachments(const char *userid, struct mailbox **attachments, } /* Check an iCal object to see if managed attachments are being manipulated */ -static int manage_attachments(struct transaction_t *txn, - struct mailbox *mailbox, - icalcomponent *ical, struct caldav_data *cdata, - icalcomponent **oldical, char **schedule_address) +HIDDEN int caldav_manage_attachments(const char *userid, + icalcomponent *ical, + icalcomponent *oldical) { /* Compare any managed attachments in new and existing resources */ struct mailbox *attachments = NULL; struct webdav_db *webdavdb = NULL; struct hash_table mattach_table = HASH_TABLE_INITIALIZER; icalcomponent *comp = NULL; - icalcomponent_kind kind; + icalcomponent_kind kind = ICAL_NO_COMPONENT; icalproperty *prop; icalparameter *param; const char *mid; @@ -1444,8 +1373,7 @@ static int manage_attachments(struct transaction_t *txn, if (!attachments) { /* Open attachments collection and its DAV DB for writing */ - ret = open_attachments(httpd_userid, - &attachments, &webdavdb); + ret = open_attachments(userid, &attachments, &webdavdb); if (ret) goto done; } @@ -1454,8 +1382,7 @@ static int manage_attachments(struct transaction_t *txn, webdav_lookup_uid(webdavdb, mid, &wdata); if (!wdata->dav.rowid) { - txn->error.precond = CALDAV_VALID_MANAGEDID; - ret = HTTP_FORBIDDEN; + ret = HTTP_NOT_FOUND; goto done; } @@ -1473,46 +1400,31 @@ static int manage_attachments(struct transaction_t *txn, } /* Compare existing managed attachments to those in new resource */ - if (cdata->comp_flags.mattach) { - if (!*oldical) { - syslog(LOG_NOTICE, "LOADING ICAL %u", cdata->dav.imap_uid); + comp = icalcomponent_get_first_real_component(oldical); + kind = icalcomponent_isa(comp); - /* Load message containing the resource and parse iCal data */ - *oldical = caldav_record_to_ical(mailbox, cdata, - NULL, schedule_address); - if (!*oldical) { - txn->error.desc = "Failed to read record"; - ret = HTTP_SERVER_ERROR; - goto done; + for (; comp; + comp = icalcomponent_get_next_component(oldical, kind)) { + for (prop = icalcomponent_get_first_property(comp, + ICAL_ATTACH_PROPERTY); + prop; + prop = icalcomponent_get_next_property(comp, + ICAL_ATTACH_PROPERTY)) { + + param = icalproperty_get_managedid_parameter(prop); + if (!param) continue; + + mid = icalparameter_get_managedid(param); + op = hash_lookup(mid, &mattach_table); + if (!op) { + /* Attachment removed from ical */ + op = xmalloc(sizeof(short)); + *op = REFCNT_DEC; + hash_insert(mid, op, &mattach_table); } - } - - comp = icalcomponent_get_first_real_component(*oldical); - kind = icalcomponent_isa(comp); - - for (; comp; - comp = icalcomponent_get_next_component(*oldical, kind)) { - for (prop = icalcomponent_get_first_property(comp, - ICAL_ATTACH_PROPERTY); - prop; - prop = icalcomponent_get_next_property(comp, - ICAL_ATTACH_PROPERTY)) { - - param = icalproperty_get_managedid_parameter(prop); - if (!param) continue; - - mid = icalparameter_get_managedid(param); - op = hash_lookup(mid, &mattach_table); - if (!op) { - /* Attachment removed from ical */ - op = xmalloc(sizeof(short)); - *op = REFCNT_DEC; - hash_insert(mid, op, &mattach_table); - } - else if (*op != REFCNT_DEC) { - /* Attachment still in ical */ - *op = REFCNT_HOLD; - } + else if (*op != REFCNT_DEC) { + /* Attachment still in ical */ + *op = REFCNT_HOLD; } } } @@ -1520,70 +1432,55 @@ static int manage_attachments(struct transaction_t *txn, if (hash_numrecords(&mattach_table)) { if (!attachments) { /* Open attachments collection and its DAV DB for writing */ - ret = open_attachments(httpd_userid, &attachments, &webdavdb); + ret = open_attachments(userid, &attachments, &webdavdb); if (ret) goto done; } /* Update reference counts of attachments in hash table */ + struct update_rock urock = { attachments, webdavdb }; hash_enumerate(&mattach_table, (void(*)(const char*,void*,void*)) &update_refcount, - attachments); + &urock); } - done: +done: free_hash_table(&mattach_table, free); + if (webdavdb) webdav_close(webdavdb); mailbox_close(&attachments); return ret; } - -static void get_schedule_addresses(struct transaction_t *txn, - strarray_t *addresses) +static int manage_attachments(struct transaction_t *txn, + struct mailbox *mailbox, + icalcomponent *ical, struct caldav_data *cdata, + icalcomponent **oldical, strarray_t *schedule_addresses) { - struct buf buf = BUF_INITIALIZER; - - /* allow override of schedule-address per-message (FM specific) */ - const char **hdr = spool_getheader(txn->req_hdrs, "Schedule-Address"); - - if (hdr) { - if (!strncasecmp(hdr[0], "mailto:", 7)) - strarray_append(addresses, hdr[0]+7); - else - strarray_append(addresses, hdr[0]); - } - else { - /* find schedule address based on the destination calendar's user */ - - /* check calendar-user-address-set for target user */ - const char *annotname = - DAV_ANNOT_NS "<" XML_NS_CALDAV ">calendar-user-address-set"; - char *mailboxname = caldav_mboxname(txn->req_tgt.userid, NULL); - int r = annotatemore_lookupmask(mailboxname, annotname, - txn->req_tgt.userid, &buf); - free(mailboxname); - if (!r && buf.len > 7 && - !strncasecmp(buf_cstring(&buf), "mailto:", 7)) { - strarray_append(addresses, buf_cstring(&buf) + 7); - } - else if (strchr(txn->req_tgt.userid, '@')) { - /* userid corresponding to target */ - strarray_append(addresses, txn->req_tgt.userid); - } - else { - /* append fully qualified userids */ - struct strlist *domains; + int ret = 0; - for (domains = cua_domains; domains; domains = domains->next) { - buf_reset(&buf); - buf_printf(&buf, "%s@%s", txn->req_tgt.userid, domains->s); + if (cdata->comp_flags.mattach) { + if (!*oldical) { + syslog(LOG_NOTICE, "LOADING ICAL %u", cdata->dav.imap_uid); - strarray_appendm(addresses, buf_release(&buf)); + /* Load message containing the resource and parse iCal data */ + *oldical = caldav_record_to_ical(mailbox, cdata, + NULL, schedule_addresses); + if (!*oldical) { + txn->error.desc = "Failed to read record"; + ret = HTTP_SERVER_ERROR; + goto done; } } } - buf_free(&buf); + ret = caldav_manage_attachments(httpd_userid, ical, *oldical); + if (ret == HTTP_NOT_FOUND) { + txn->error.precond = CALDAV_VALID_MANAGEDID; + ret = HTTP_FORBIDDEN; + } + +done: + return ret; } @@ -1595,7 +1492,8 @@ static int caldav_delete_cal(struct transaction_t *txn, struct caldav_data *cdata = (struct caldav_data *) data; icalcomponent *ical = NULL; struct buf buf = BUF_INITIALIZER; - char *schedule_address = NULL; + strarray_t schedule_addresses = STRARRAY_INITIALIZER; + int is_draft = record->system_flags & FLAG_DRAFT; int r = 0; /* Only process deletes on regular calendar collections */ @@ -1604,19 +1502,18 @@ static int caldav_delete_cal(struct transaction_t *txn, if ((namespace_calendar.allow & ALLOW_CAL_ATTACH) && cdata->comp_flags.mattach) { r = manage_attachments(txn, mailbox, NULL, - cdata, &ical, &schedule_address); + cdata, &ical, &schedule_addresses); if (r) goto done; } if (cdata->organizer) { /* Scheduling object resource */ - strarray_t schedule_addresses = STRARRAY_INITIALIZER; const char **hdr; /* XXX - check date range? - don't send in the past */ /* Load message containing the resource and parse iCal data */ - if (!ical) ical = record_to_ical(mailbox, record, &schedule_address); + if (!ical) ical = record_to_ical(mailbox, record, &schedule_addresses); if (!ical) { syslog(LOG_ERR, @@ -1625,37 +1522,54 @@ static int caldav_delete_cal(struct transaction_t *txn, return HTTP_SERVER_ERROR; } - if (schedule_address) { - strarray_appendm(&schedule_addresses, schedule_address); - schedule_address = NULL; - } - get_schedule_addresses(txn, &schedule_addresses); + caldav_get_schedule_addresses(txn->req_hdrs, txn->req_tgt.mbentry->name, + txn->req_tgt.userid, &schedule_addresses); /* XXX - after legacy records are gone, we can strip this and just not send a * cancellation if deleting a record which was never replied to... */ - char *userid = mboxname_to_userid(txn->req_tgt.mbentry->name); - if (strarray_find_case(&schedule_addresses, cdata->organizer, 0) >= 0) { + char *cal_ownerid = mboxname_to_userid(txn->req_tgt.mbentry->name); + char *sched_userid = (txn->req_tgt.flags == TGT_DAV_SHARED) ? + xstrdup(txn->req_tgt.userid) : NULL; + if (strarray_contains_case(&schedule_addresses, cdata->organizer)) { /* Organizer scheduling object resource */ - schedule_address = xstrdupnull(cdata->organizer); - if (_scheduling_enabled(txn, mailbox)) - sched_request(userid, schedule_address, ical, NULL); + if (_scheduling_enabled(txn, mailbox) && !is_draft) + sched_request(cal_ownerid, sched_userid, &schedule_addresses, + cdata->organizer, ical, NULL, SCHED_MECH_CALDAV); } else if (!(hdr = spool_getheader(txn->req_hdrs, "Schedule-Reply")) || strcasecmp(hdr[0], "F")) { /* Attendee scheduling object resource */ - schedule_address = xstrdupnull(strarray_nth(&schedule_addresses, 0)); - if (_scheduling_enabled(txn, mailbox) && schedule_address) - sched_reply(userid, schedule_address, ical, NULL); + if (_scheduling_enabled(txn, mailbox) && strarray_size(&schedule_addresses) && !is_draft) + sched_reply(cal_ownerid, sched_userid, &schedule_addresses, + ical, NULL, SCHED_MECH_CALDAV); } - free(userid); - strarray_fini(&schedule_addresses); + free(sched_userid); + free(cal_ownerid); + } + +#ifdef WITH_JMAP + if (calendar_has_sharees(mailbox->mbentry)) { + if (!ical) ical = record_to_ical(mailbox, record, &schedule_addresses); + if (ical) { + icalcomponent *comp = icalcomponent_get_first_real_component(ical); + if (comp && icalcomponent_isa(comp) == ICAL_VEVENT_COMPONENT) { + int r2 = jmap_create_caldaveventnotif(txn, httpd_userid, + httpd_authstate, mailbox_name(mailbox), + cdata->ical_uid, &schedule_addresses, is_draft, ical, NULL); + if (r2) { + xsyslog(LOG_ERR, "jmap_create_caldaveventnotif failed", + "error=%s", error_message(r2)); + } + } + } } +#endif done: if (ical) icalcomponent_free(ical); - free(schedule_address); + strarray_fini(&schedule_addresses); buf_free(&buf); return r; @@ -1709,13 +1623,13 @@ static void add_timezone(icalparameter *param, void *data) if (tzrock->old) { /* Fetch tz from old object and add to new */ icaltimezone *tz = icalcomponent_get_timezone(tzrock->old, tzid); - if (tz) vtz = icalcomponent_new_clone(icaltimezone_get_component(tz)); + if (tz) vtz = icalcomponent_clone(icaltimezone_get_component(tz)); } else { /* Fetch tz from builtin repository */ icaltimezone *tz = icaltimezone_get_builtin_timezone(tzid); - if (tz) vtz = icalcomponent_new_clone(icaltimezone_get_component(tz)); + if (tz) vtz = icalcomponent_clone(icaltimezone_get_component(tz)); } if (vtz) icalcomponent_add_component(tzrock->new, vtz); @@ -1776,6 +1690,7 @@ static int export_calendar(struct transaction_t *txn) txn->resp_body.lastmod = mailbox->index_mtime; txn->resp_body.maxage = 3600; /* 1 hr */ txn->flags.cc |= CC_MAXAGE | CC_REVALIDATE; /* don't use stale data */ + if (httpd_userid) txn->flags.cc |= CC_PRIVATE; if (precond != HTTP_NOT_MODIFIED) break; @@ -1793,10 +1708,10 @@ static int export_calendar(struct transaction_t *txn) txn->resp_body.type = mime->content_type; /* Set filename of resource */ - r = annotatemore_lookupmask(mailbox->name, displayname_annot, - httpd_userid, &name); + r = annotatemore_lookupmask_mbox(mailbox, displayname_annot, + httpd_userid, &name); /* fall back to last part of mailbox name */ - if (r || !name.len) buf_setcstr(&name, strrchr(mailbox->name, '.') + 1); + if (r || !name.len) buf_setcstr(&name, strrchr(mailbox_name(mailbox), '.') + 1); buf_reset(&txn->buf); buf_printf(&txn->buf, "%s.%s", buf_cstring(&name), mime->file_ext); @@ -1836,14 +1751,12 @@ static int export_calendar(struct transaction_t *txn) mailbox_user_flag(mailbox, DFLAG_UNBIND, &unbind_flag, 1); mailbox_user_flag(mailbox, DFLAG_UNCHANGED, &unchanged_flag, 1); -#ifdef HAVE_TZ_BY_REF if (namespace_calendar.allow & ALLOW_CAL_NOTZ) { /* Add link to tzdist */ buf_printf(&link, "<%s>; rel=\"timezone-service\"", namespace_tzdist.prefix); strarray_appendm(&txn->resp_body.links, buf_release(&link)); } -#endif /* HAVE_TZ_BY_REF */ } /* Check for optional CalDAV-Timezones header */ @@ -1870,10 +1783,10 @@ static int export_calendar(struct transaction_t *txn) construct_hash_table(&tzid_table, 10, 1); /* Get description and color of calendar */ - r = annotatemore_lookupmask(mailbox->name, description_annot, - httpd_userid, &desc); - r = annotatemore_lookupmask(mailbox->name, color_annot, - httpd_userid, &color); + r = annotatemore_lookupmask_mbox(mailbox, description_annot, + httpd_userid, &desc); + r = annotatemore_lookupmask_mbox(mailbox, color_annot, + httpd_userid, &color); /* Begin (converted) iCalendar stream */ sep = mime->begin_stream(buf, mailbox, ical_prodid, buf_cstring(&name), @@ -1894,7 +1807,7 @@ static int export_calendar(struct transaction_t *txn) struct caldav_data *cdata; icalcomponent *ical = NULL; - r = caldav_lookup_imapuid(caldavdb, mailbox->name, + r = caldav_lookup_imapuid(caldavdb, txn->req_tgt.mbentry, record->uid, &cdata, 0); if (syncmodseq) { @@ -1923,7 +1836,7 @@ static int export_calendar(struct transaction_t *txn) struct caldav_data *cdata; /* Fetch the CalDAV db record */ - r = caldav_lookup_imapuid(caldavdb, mailbox->name, + r = caldav_lookup_imapuid(caldavdb, txn->req_tgt.mbentry, record->uid, &cdata, 0); if (!r && need_tz && cdata->comp_flags.tzbyref) { @@ -1946,7 +1859,6 @@ static int export_calendar(struct transaction_t *txn) comp; comp = icalcomponent_get_next_component(ical, ICAL_ANY_COMPONENT)) { - struct buf *cal_str; icalcomponent_kind kind = icalcomponent_isa(comp); /* Don't duplicate any VTIMEZONEs in our iCalendar */ @@ -1999,10 +1911,10 @@ static int export_calendar(struct transaction_t *txn) if (n++ && *sep) { /* Add separator, if necessary */ buf_reset(buf); - buf_printf_markup(buf, 0, sep); + buf_printf_markup(buf, 0, "%s", sep); write_body(0, txn, buf_cstring(buf), buf_len(buf)); } - cal_str = mime->from_object(comp); + struct buf *cal_str = mime->from_object(comp); write_body(0, txn, buf_base(cal_str), buf_len(cal_str)); buf_destroy(cal_str); } @@ -2057,6 +1969,8 @@ struct list_cal_rock { struct cal_info *cal; unsigned len; unsigned alloc; + char *scheddefault; + size_t defaultlen; }; static int list_cal_cb(const mbentry_t *mbentry, void *rock) @@ -2065,7 +1979,6 @@ static int list_cal_cb(const mbentry_t *mbentry, void *rock) struct cal_info *cal; static size_t inboxlen = 0; static size_t outboxlen = 0; - static size_t defaultlen = 0; char *shortname; size_t len; int r, rights, any_rights = 0; @@ -2080,12 +1993,11 @@ static int list_cal_cb(const mbentry_t *mbentry, void *rock) if (!inboxlen) inboxlen = strlen(SCHED_INBOX) - 1; if (!outboxlen) outboxlen = strlen(SCHED_OUTBOX) - 1; - if (!defaultlen) defaultlen = strlen(SCHED_DEFAULT) - 1; - /* Make sure its a calendar */ - if (mbentry->mbtype != MBTYPE_CALENDAR) goto done; + /* Make sure it is a calendar */ + if (mbtype_isa(mbentry->mbtype) != MBTYPE_CALENDAR) goto done; - /* Make sure its readable */ + /* Make sure it is readable */ rights = httpd_myrights(httpd_authstate, mbentry); if ((rights & DACL_READ) != DACL_READ) goto done; @@ -2098,8 +2010,8 @@ static int list_cal_cb(const mbentry_t *mbentry, void *rock) goto done; /* Lookup DAV:displayname */ - r = annotatemore_lookupmask(mbentry->name, displayname_annot, - httpd_userid, &displayname); + r = annotatemore_lookupmask_mbe(mbentry, displayname_annot, + httpd_userid, &displayname); /* fall back to the last part of the mailbox name */ if (r || !displayname.len) buf_setcstr(&displayname, shortname); @@ -2117,7 +2029,8 @@ static int list_cal_cb(const mbentry_t *mbentry, void *rock) cal->flags = 0; /* Is this the default calendar? */ - if (len == defaultlen && !strncmp(shortname, SCHED_DEFAULT, defaultlen)) { + if (len == lrock->defaultlen && + !strncmpsafe(shortname, lrock->scheddefault, lrock->defaultlen)) { cal->flags |= CAL_IS_DEFAULT; } @@ -2143,16 +2056,16 @@ static int list_cal_cb(const mbentry_t *mbentry, void *rock) } /* Is this calendar transparent? */ - r = annotatemore_lookupmask(mbentry->name, schedtransp_annot, - httpd_userid, &schedtransp); + r = annotatemore_lookupmask_mbe(mbentry, schedtransp_annot, + httpd_userid, &schedtransp); if (!r && !strcmp(buf_cstring(&schedtransp), "transparent")) { cal->flags |= CAL_IS_TRANSP; } buf_free(&schedtransp); /* Which component types are supported? */ - r = annotatemore_lookupmask(mbentry->name, calcompset_annot, - httpd_userid, &calcompset); + r = annotatemore_lookupmask_mbe(mbentry, calcompset_annot, + httpd_userid, &calcompset); if (!r && buf_len(&calcompset)) { cal->types = strtoul(buf_cstring(&calcompset), NULL, 10); } @@ -2222,13 +2135,13 @@ static int list_calendars(struct transaction_t *txn) stat(mboxlist, &sbuf); lastmod = MAX(compile_time, sbuf.st_mtime); assert(!buf_len(&txn->buf)); - buf_printf(&txn->buf, "%ld-%ld-%ld", + buf_printf(&txn->buf, TIME_T_FMT "-" TIME_T_FMT "-" OFF_T_FMT, compile_time, sbuf.st_mtime, sbuf.st_size); /* stat() config file for Last-Modified and ETag */ stat(config_filename, &sbuf); lastmod = MAX(lastmod, sbuf.st_mtime); - buf_printf(&txn->buf, "-%ld-%ld", sbuf.st_mtime, sbuf.st_size); + buf_printf(&txn->buf, "-" TIME_T_FMT "-" OFF_T_FMT, sbuf.st_mtime, sbuf.st_size); etag = buf_cstring(&txn->buf); /* Check any preconditions */ @@ -2265,7 +2178,7 @@ static int list_calendars(struct transaction_t *txn) /* Send HTML header */ buf_reset(body); buf_printf_markup(body, level, HTML_DOCTYPE); - buf_printf_markup(body, level++, ""); + buf_printf_markup(body, level++, ""); buf_printf_markup(body, level++, ""); buf_printf_markup(body, level, "%s", "Available Calendars"); buf_printf_markup(body, level++, "